diff --git a/cmd/app/status.go b/cmd/app/status.go index 5b5580b7..e6a42053 100644 --- a/cmd/app/status.go +++ b/cmd/app/status.go @@ -207,7 +207,9 @@ func renderStatus(rep appstatus.Report) { case !rep.Health.Reachable && rep.Total == 0: pterm.Error.Println("Cluster is not reachable. Is it running and is your kube-context correct?") if rep.HealthErr != nil { - pterm.Error.Printf(" cause: %v\n", rep.HealthErr) + // Same shape as the shared error panel's cause row — dim key, + // indented under the headline, no repeated error tag. + pterm.DefaultBasicText.Printf(" %s %v\n", pterm.FgGray.Sprintf("%-7s", "cause"), rep.HealthErr) } return case !rep.Health.Reachable: diff --git a/cmd/app/upgrade.go b/cmd/app/upgrade.go index 13155bec..294fdbe5 100644 --- a/cmd/app/upgrade.go +++ b/cmd/app/upgrade.go @@ -177,7 +177,8 @@ func previewOutOfSync(ctx context.Context, manager *argocd.Manager, verbose, pru for _, a := range apps { if a.Sync != argocd.ArgoCDSyncSynced { outOfSync++ - pterm.Info.Printf(" OutOfSync: %s (health=%s, sync=%s)\n", a.Name, a.Health, a.Sync) + // List item, not a standalone status: no repeated info tag. + pterm.DefaultBasicText.Printf(" OutOfSync: %s (health=%s, sync=%s)\n", a.Name, a.Health, a.Sync) } } if outOfSync == 0 { diff --git a/internal/chart/providers/argocd/wait.go b/internal/chart/providers/argocd/wait.go index 695d683d..6542fafa 100644 --- a/internal/chart/providers/argocd/wait.go +++ b/internal/chart/providers/argocd/wait.go @@ -12,6 +12,7 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/chart/utils/config" "github.com/flamingo-stack/openframe-cli/internal/platform" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + sharedui "github.com/flamingo-stack/openframe-cli/internal/shared/ui" uispinner "github.com/flamingo-stack/openframe-cli/internal/shared/ui/spinner" "github.com/pterm/pterm" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -83,9 +84,9 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn // Show initial verbose info if enabled if config.Verbose { pterm.Info.Println("Starting ArgoCD application synchronization...") - pterm.Debug.Println(" - Waiting for applications to be created by app-of-apps") - pterm.Debug.Println(" - Each application must reach Healthy + Synced status") - pterm.Debug.Println(" - Progress updates every 10 seconds in verbose mode") + pterm.Debug.Println("Waiting for applications to be created by app-of-apps") + pterm.Debug.Println("Each application must reach Healthy + Synced status") + pterm.Debug.Println("Progress updates every 10 seconds in verbose mode") } // Display: the live dashboard (interactive terminal, non-verbose) shows @@ -114,12 +115,18 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn // waitNote routes one-off in-wait announcements: pinned under the live // dashboard when it is active (a plain print would be visually swallowed // by the area redraw within 2s), ordinary silence-aware prints otherwise. - waitNote := func(styled string) { + // It takes the status printer plus the RAW message — not a pre-styled + // string — so the non-dashboard path prints through the printer's own + // writer: that is where the CI ::warning:: annotation tee lives, and a + // pre-styled DefaultBasicText print silently bypassed it. The dashboard + // path never annotates, but it only runs on interactive terminals — CI is + // always the printer path. + waitNote := func(p *pterm.PrefixPrinter, format string, args ...any) { if dash != nil { - dash.Note(styled) + dash.Note(p.Sprintf(format, args...)) return } - pterm.DefaultBasicText.Println(styled) + p.Printfln(format, args...) } // Function to stop spinner safely @@ -424,7 +431,7 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn // Reset consecutive failures on successful query if consecutiveFailures > 0 { - waitNote(pterm.Success.Sprint("Application queries restored")) + waitNote(&pterm.Success, "Application queries restored") consecutiveFailures = 0 } @@ -513,18 +520,18 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn if config.SyncStragglersOnStall { if !stragglerSyncTriggered { stragglerSyncTriggered = true - stallNote(pterm.Warning.Sprintf("No progress for %s; triggering sync of %d OutOfSync application(s): %v", - stallAfter.Round(time.Second), len(stragglers), stragglers)) + stallNote(&pterm.Warning, "No progress for %s; triggering sync of %d OutOfSync application(s): %v", + stallAfter.Round(time.Second), len(stragglers), stragglers) patched, failedCount, syncErr := m.syncApplicationsByName(localCtx, stragglers, false) if failedCount > 0 { - stallNote(pterm.Warning.Sprintf("Straggler sync: %d triggered, %d failed (first error: %v)", patched, failedCount, syncErr)) + stallNote(&pterm.Warning, "Straggler sync: %d triggered, %d failed (first error: %v)", patched, failedCount, syncErr) } } } else if !stallHintShown { stallHintShown = true - stallNote(pterm.Warning.Sprintf("No progress for %s; %d application(s) are OutOfSync and may have auto-sync disabled: %v", - stallAfter.Round(time.Second), len(stragglers), stragglers)) - stallNote(pterm.Info.Sprint("They will not sync on their own — run `openframe app upgrade --sync` (or sync them in ArgoCD) to roll them out.")) + stallNote(&pterm.Warning, "No progress for %s; %d application(s) are OutOfSync and may have auto-sync disabled: %v", + stallAfter.Round(time.Second), len(stragglers), stragglers) + stallNote(&pterm.Info, "They will not sync on their own — run `openframe app upgrade --sync` (or sync them in ArgoCD) to roll them out.") } } @@ -598,15 +605,15 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn // out to its timeout. triggerRepoServerRecovery already // hard-refreshed app.Name; cover the rest. if refreshed := m.hardRefreshApplications(localCtx, appNames(unknownApps)); refreshed > 0 { - waitNote(pterm.Info.Sprintf("Hard-refreshed %d application(s) stuck in Unknown.", refreshed)) + waitNote(&pterm.Info, "Hard-refreshed %d application(s) stuck in Unknown.", refreshed) } } else { - waitNote(pterm.Warning.Sprint("Could not restart the ArgoCD repo-server; continuing to wait.")) + waitNote(&pterm.Warning, "Could not restart the ArgoCD repo-server; continuing to wait.") } } else if repoServerRecoveryAttempts == maxRepoServerRecoveryAttempts { repoServerRecoveryAttempts++ // prevent repeated attempts - waitNote(pterm.Warning.Sprintf("ArgoCD repo-server did not recover after %d restarts; continuing to wait for the timeout.", - maxRepoServerRecoveryAttempts)) + waitNote(&pterm.Warning, "ArgoCD repo-server did not recover after %d restarts; continuing to wait for the timeout.", + maxRepoServerRecoveryAttempts) } break // Only recover one app at a time } @@ -618,12 +625,12 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn // (throttled); the per-application dump stays behind --verbose. if len(unknownApps) > 0 && elapsed > 5*time.Minute && time.Since(lastUnknownWarn) >= 5*time.Minute { lastUnknownWarn = time.Now() - waitNote(pterm.Warning.Sprintf("%d application(s) have 'Unknown' status after %s. Possible causes: controller pod not ready, git repository unreachable, or resource constraints.", - len(unknownApps), elapsed.Round(time.Second))) + waitNote(&pterm.Warning, "%d application(s) have 'Unknown' status after %s. Possible causes: controller pod not ready, git repository unreachable, or resource constraints.", + len(unknownApps), elapsed.Round(time.Second)) if config.Verbose { describeUnknownApps(unknownApps) } else if dash == nil { - pterm.Info.Println(" Re-run with --verbose for per-application detail.") + pterm.Info.Println("Re-run with --verbose for per-application detail.") } } @@ -634,7 +641,7 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn lastStuckSummary = time.Now() for _, app := range apps { if app.Health != ArgoCDHealthHealthy && app.Health != ArgoCDHealthMissing { - line := fmt.Sprintf(" Stuck app %s: health=%s sync=%s", app.Name, app.Health, app.Sync) + line := fmt.Sprintf("Stuck app %s: health=%s sync=%s", app.Name, app.Health, app.Sync) if app.Condition != "" { line += " condition=" + app.Condition } @@ -656,13 +663,20 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn lastProgressPrint = time.Now() delta := currentlyReady - heartbeatLastReady heartbeatLastReady = currentlyReady - pterm.Info.Printf("[%s] apps %d/%d ready (%+d since last check) · elapsed %s\n", - time.Now().Format("15:04:05"), currentlyReady, totalApps, delta, elapsed.Round(time.Second)) + beat := fmt.Sprintf("apps %d/%d ready (%+d since last check) · elapsed %s", + currentlyReady, totalApps, delta, elapsed.Round(time.Second)) + // The embedded clock serves plain (non-verbose) CI logs; under + // --verbose every status line is already timestamped by the + // writer, and a second clock on the same row is noise. + if !sharedui.TimestampsActive() { + beat = fmt.Sprintf("[%s] ", time.Now().Format("15:04:05")) + beat + } + pterm.Info.Println(beat) if p := pendingSummary(apps, 6); p != "" { - pterm.Info.Printf(" pending: %s\n", p) + pterm.Info.Printf("pending: %s\n", p) } if config.Verbose && len(healthyApps) > 0 && len(healthyApps) <= 5 { - pterm.Debug.Printf(" Recently completed: %v\n", healthyApps) + pterm.Debug.Printf("Recently completed: %v\n", healthyApps) } } diff --git a/internal/chart/services/cluster.go b/internal/chart/services/cluster.go index c4d49268..f9df3b90 100644 --- a/internal/chart/services/cluster.go +++ b/internal/chart/services/cluster.go @@ -47,7 +47,8 @@ func (c *ClusterSelector) SelectCluster(args []string, nonInteractive, verbose b if verbose { pterm.Info.Printf("Found %d clusters\n", len(clusters)) for _, cluster := range clusters { - pterm.Info.Printf(" - %s (%s)\n", cluster.Name, cluster.Status) + // Items under the Info header: no repeated info tag per line. + pterm.DefaultBasicText.Printf(" - %s (%s)\n", cluster.Name, cluster.Status) } } diff --git a/internal/cluster/providers/eks/teardown.go b/internal/cluster/providers/eks/teardown.go index 1e98104a..c9cbdb6f 100644 --- a/internal/cluster/providers/eks/teardown.go +++ b/internal/cluster/providers/eks/teardown.go @@ -271,7 +271,9 @@ func (p *Provider) sweepOrphanedVolumes(ctx context.Context, rec tfengine.Record func printOrphanList(volumes []string, name string) { pterm.Warning.Printf("%d EBS volume(s) tagged for cluster %q survived the destroy (PVC-provisioned, outside terraform state):\n", len(volumes), name) for _, id := range volumes { - pterm.Warning.Printf(" - %s\n", id) + // Items under the Warning header go through DefaultBasicText so the + // warning tag isn't repeated per line (same pattern as cleanup lists). + pterm.DefaultBasicText.Printf(" - %s\n", id) } } diff --git a/internal/cluster/providers/gke/teardown.go b/internal/cluster/providers/gke/teardown.go index c1f791bf..b3cd8411 100644 --- a/internal/cluster/providers/gke/teardown.go +++ b/internal/cluster/providers/gke/teardown.go @@ -262,10 +262,13 @@ func printOrphanList(disks []disk, name string) { for _, d := range disks { // The location tells the operator WHICH cluster's disks these are — // GKE cluster names repeat across locations. + // List items go through DefaultBasicText (the repo's header+items + // pattern): the Warning header above carries the severity, and + // repeating the warning tag on every item just breaks the column. if loc := d.location(); loc != "" { - pterm.Warning.Printf(" - %s (%s)\n", d.name, loc) + pterm.DefaultBasicText.Printf(" - %s (%s)\n", d.name, loc) } else { - pterm.Warning.Printf(" - %s\n", d.name) + pterm.DefaultBasicText.Printf(" - %s\n", d.name) } } } diff --git a/internal/shared/errors/errors.go b/internal/shared/errors/errors.go index aed75937..169b4b4d 100644 --- a/internal/shared/errors/errors.go +++ b/internal/shared/errors/errors.go @@ -142,7 +142,9 @@ func (eh *ErrorHandler) handleGenericError(err error) { // In GitHub Actions the failure also becomes a job/PR annotation, so the // cause is visible without opening the 40-minute log. ui.ErrorAnnotation(headline, firstLine(cause)) - pterm.Error.Printf("%s %s\n", ui.Glyphs().Fail, headline) + // No manual failure glyph: the themed pterm.Error prefix carries it + // (✖ interactively, the "error" tag otherwise). + pterm.Error.Printf("%s\n", headline) if cause != "" { panelRow("cause", cause) } diff --git a/internal/shared/ui/ghactions.go b/internal/shared/ui/ghactions.go index bd043dd3..6e4ddcfc 100644 --- a/internal/shared/ui/ghactions.go +++ b/internal/shared/ui/ghactions.go @@ -38,6 +38,15 @@ func ErrorAnnotation(title, message string) { fmt.Printf("::error title=%s::%s\n", escapeAnnotationProperty(title), escapeAnnotationData(message)) } +// WarningAnnotation surfaces a warning as a ::warning:: annotation (shown in +// the job summary and on the PR when applicable). +func WarningAnnotation(title, message string) { + if !InGitHubActions() { + return + } + fmt.Printf("::warning title=%s::%s\n", escapeAnnotationProperty(title), escapeAnnotationData(message)) +} + // AppendStepSummary appends a markdown fragment to the job's Step Summary. // Best-effort: a missing or unwritable summary file is silently skipped. func AppendStepSummary(markdown string) { diff --git a/internal/shared/ui/silent.go b/internal/shared/ui/silent.go index 6550771c..0395f20c 100644 --- a/internal/shared/ui/silent.go +++ b/internal/shared/ui/silent.go @@ -25,16 +25,41 @@ func ApplyGlobalOutputFlags(cmd *cobra.Command) { } if v, _ := cmd.Flags().GetBool("verbose"); v && !silentFlag { pterm.EnableDebugMessages() - // Timestamped debug lines: --verbose exists to correlate the CLI's - // actions with cluster events, which needs a clock on every line. - pterm.Debug = *pterm.Debug.WithWriter(NewTimestampWriter(os.Stdout)) + // Timestamped status lines: --verbose exists to correlate the CLI's + // actions with cluster events, which needs a clock on every line. ALL + // status printers get the clock, not just Debug — info/warning lines + // are events on the same timeline, and a timestamp on only some rows + // leaves the message columns ragged. One shared writer keeps the + // line-start state consistent across printers. + timestamped = true + ts := NewTimestampWriter(os.Stdout) + for _, p := range []*pterm.PrefixPrinter{ + &pterm.Debug, &pterm.Info, &pterm.Warning, &pterm.Error, &pterm.Success, + } { + *p = *p.WithWriter(ts) + } } + // Last: the theme reads IsPlain/IsSilent, which the flags above just set. + // Under --verbose it also composes with the timestamp writers above: the + // CI annotation tee wraps Warning's writer, so it sees the rendered text + // BEFORE the clock is prepended — annotations stay timestamp-free. + ApplyStatusPrefixTheme() } // silent records whether --silent suppressed non-error output. Read by the logo // renderer so it can honor the flag. var silent bool +// timestamped records whether the status printers carry a per-line wall clock +// (--verbose). Long-running loops that embed their OWN clock in messages (the +// ArgoCD wait heartbeat does, for plain CI logs) consult this to avoid +// printing two clocks on one line. +var timestamped bool + +// TimestampsActive reports whether status-printer lines are already +// timestamped by the --verbose writer. +func TimestampsActive() bool { return timestamped } + // SetSilent honors the --silent flag's contract ("suppress all output except // errors"): it routes every non-error pterm printer to io.Discard and marks the // UI silent so the ASCII logo is skipped. Error and Fatal printers are left diff --git a/internal/shared/ui/status_theme.go b/internal/shared/ui/status_theme.go new file mode 100644 index 00000000..e30ce9fe --- /dev/null +++ b/internal/shared/ui/status_theme.go @@ -0,0 +1,137 @@ +package ui + +import ( + "io" + "os" + "regexp" + "strings" + "sync" + + "github.com/pterm/pterm" +) + +// ApplyStatusPrefixTheme restyles the package-level pterm status printers that +// the whole CLI prints through (pterm.Info/Warning/Error/Success/Debug), so a +// single call here re-themes every call site. +// +// Interactive terminals get quiet glyph prefixes from the shared GlyphSet +// (which already degrades to ASCII under OPENFRAME_ASCII/TERM=dumb): a dim +// bullet for Info, ▲/✖/✔ for Warning/Error/Success — no background badges. +// Non-interactive output (CI, pipes, --plain) keeps word tags for +// grep-ability, but lowercase, column-aligned, and foreground-colored instead +// of the block badges; the words themselves stay info/warning/error/success. +// +// Inside GitHub Actions, Warning and Error additionally emit +// ::warning::/::error:: workflow commands so they surface as job/PR +// annotations instead of sinking into the log. +// +// It mutates pterm's package-level printers (like SetSilent), so it runs once, +// early, from ApplyGlobalOutputFlags — after the --plain/--silent flags are +// applied, because both the mode choice and the silent writers must win. +// pterm's With* helpers copy the printer struct, so composing with SetSilent's +// io.Discard writers and the --verbose timestamp writer is order-safe either +// way; running last just keeps the reasoning simple. +func ApplyStatusPrefixTheme() { + interactive := IsTerminal() && !IsPlain() + + type look struct { + printer *pterm.PrefixPrinter + glyph string // interactive prefix + word string // non-interactive prefix, column-aligned + style *pterm.Style + } + g := Glyphs() + looks := []look{ + {&pterm.Info, g.Bullet, "info ", pterm.NewStyle(pterm.FgGray)}, + {&pterm.Warning, g.Warn, "warning", pterm.NewStyle(pterm.FgLightYellow)}, + {&pterm.Error, g.Fail, "error ", pterm.NewStyle(pterm.FgLightRed)}, + {&pterm.Success, g.OK, "success", pterm.NewStyle(pterm.FgLightGreen)}, + {&pterm.Debug, g.Bullet, "debug ", pterm.NewStyle(pterm.FgGray)}, + } + for _, l := range looks { + text := l.word + if interactive { + text = l.glyph + } + *l.printer = *l.printer.WithPrefix(pterm.Prefix{Text: text, Style: l.style}) + } + // Info's non-interactive tag stays cyan (its message color family), not the + // gray the interactive bullet uses — in a colorless-context log the tag is + // the only severity signal. + if !interactive { + pterm.Info = *pterm.Info.WithPrefix(pterm.Prefix{Text: "info ", Style: pterm.NewStyle(pterm.FgLightCyan)}) + } + + // Surface warnings as Actions annotations. Errors are NOT teed here: the + // shared error handler already emits a richer ::error:: annotation + // (headline as title, cause as message) for every command failure, and a + // second writer-level annotation would duplicate it. The tee respects + // --silent, which discarded Warning's writer above — silent means errors + // only, annotations included. + if InGitHubActions() && !IsSilent() { + pterm.Warning = *pterm.Warning.WithWriter(newAnnotationWriter(pterm.Warning.Writer, "warning")) + } +} + +// annotationWriter tees a status printer's output into a GitHub Actions +// ::warning:: or ::error:: workflow command, with ANSI styling and the +// printer's own prefix column stripped. +// +// Each distinct message is annotated ONCE per process: the runner echoes every +// workflow command inline in the log ("Warning: …"), so re-annotating a +// repeating message (the ArgoCD wait re-prints its stuck-app summary every few +// minutes) would double a growing share of the log — and GitHub keeps only 10 +// annotations per step, so repeats also crowd out genuinely new warnings. +type annotationWriter struct { + inner io.Writer + level string + mu sync.Mutex + seen map[string]struct{} +} + +func newAnnotationWriter(inner io.Writer, level string) io.Writer { + if inner == nil { + inner = os.Stdout + } + return &annotationWriter{inner: inner, level: level, seen: make(map[string]struct{})} +} + +var ansiSeq = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func (a *annotationWriter) Write(p []byte) (int, error) { + n, err := a.inner.Write(p) + msg := strings.TrimSpace(ansiSeq.ReplaceAllString(string(p), "")) + // Drop the printer's own severity marker — the annotation level already + // carries it. Exactly ONE marker is stripped (the printed line always + // starts with the printer's tag, and stripping sequentially would eat + // message text: "warning errors found" must not become "s found"). + // Under NO_COLOR pterm's RawOutput mode renders the tag as ": ", + // so a trailing colon after the matched prefix is dropped too. + for _, prefix := range []string{"warning", "error", Glyphs().Warn, Glyphs().Fail} { + rest, ok := strings.CutPrefix(msg, prefix) + if !ok { + continue + } + msg = strings.TrimSpace(strings.TrimPrefix(rest, ":")) + break + } + if msg == "" { + return n, err + } + a.mu.Lock() + _, dup := a.seen[msg] + if !dup { + a.seen[msg] = struct{}{} + } + a.mu.Unlock() + if dup { + return n, err + } + // Reuse the escaped emitters so runner parsing rules live in one place. + if a.level == "warning" { + WarningAnnotation("openframe", msg) + } else { + ErrorAnnotation("openframe", msg) + } + return n, err +} diff --git a/internal/shared/ui/status_theme_test.go b/internal/shared/ui/status_theme_test.go new file mode 100644 index 00000000..c2eddd94 --- /dev/null +++ b/internal/shared/ui/status_theme_test.go @@ -0,0 +1,129 @@ +package ui + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/pterm/pterm" + "github.com/stretchr/testify/assert" +) + +// restorePrinters snapshots the package-level pterm printers the theme +// mutates and restores them on cleanup, so theme tests don't leak styling +// into other tests in the package. +func restorePrinters(t *testing.T) { + t.Helper() + info, warn, errP, succ, debug := pterm.Info, pterm.Warning, pterm.Error, pterm.Success, pterm.Debug + t.Cleanup(func() { + pterm.Info, pterm.Warning, pterm.Error, pterm.Success, pterm.Debug = info, warn, errP, succ, debug + }) +} + +// Test env has no TTY on stdout, so the theme must pick the non-interactive +// look: lowercase, column-aligned word tags — the exact words are part of the +// log contract (grep -i WARNING must still hit). +func TestApplyStatusPrefixTheme_NonInteractive(t *testing.T) { + restorePrinters(t) + + ApplyStatusPrefixTheme() + + assert.Equal(t, "info ", pterm.Info.Prefix.Text) + assert.Equal(t, "warning", pterm.Warning.Prefix.Text) + assert.Equal(t, "error ", pterm.Error.Prefix.Text) + assert.Equal(t, "success", pterm.Success.Prefix.Text) + assert.Equal(t, "debug ", pterm.Debug.Prefix.Text) + + // All tags occupy one column so messages line up. + for _, text := range []string{ + pterm.Info.Prefix.Text, pterm.Warning.Prefix.Text, pterm.Error.Prefix.Text, + pterm.Success.Prefix.Text, pterm.Debug.Prefix.Text, + } { + assert.Len(t, text, 7) + } +} + +func TestAnnotationWriter_EmitsWorkflowCommand(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + + // WarningAnnotation prints to os.Stdout; capture it. + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + t.Cleanup(func() { os.Stdout = old }) + + aw := newAnnotationWriter(io.Discard, "warning") + _, err := aw.Write([]byte("\x1b[93mwarning\x1b[0m disk almost full\n")) + assert.NoError(t, err) + + _ = w.Close() + os.Stdout = old + var sb strings.Builder + _, _ = io.Copy(&sb, r) + + got := sb.String() + assert.Contains(t, got, "::warning title=openframe::disk almost full") + // ANSI styling and the prefix column must not leak into the annotation. + assert.NotContains(t, got, "\x1b[") + assert.NotContains(t, got, "::warning title=openframe::warning") +} + +// A message printed repeatedly (the ArgoCD wait re-prints its stuck-app +// summary every few minutes) must annotate only once: the runner echoes every +// workflow command inline in the log, and GitHub keeps just 10 annotations per +// step, so repeats both double the log and crowd out new warnings. +func TestAnnotationWriter_DeduplicatesRepeats(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + t.Cleanup(func() { os.Stdout = old }) + + aw := newAnnotationWriter(io.Discard, "warning") + for range 3 { + _, _ = aw.Write([]byte("warning Stuck app tenant: health=Degraded\n")) + } + _, _ = aw.Write([]byte("warning Stuck app mysql: health=Progressing\n")) + + _ = w.Close() + os.Stdout = old + var sb strings.Builder + _, _ = io.Copy(&sb, r) + + got := sb.String() + assert.Equal(t, 1, strings.Count(got, "Stuck app tenant")) + assert.Equal(t, 1, strings.Count(got, "Stuck app mysql")) +} + +// Exactly one severity marker is stripped, in both forms pterm prints it: +// the padded/styled tag ("warning msg") and RawOutput's "warning: msg" +// (NO_COLOR). Stripping must not cascade into message text. +func TestAnnotationWriter_PrefixStripping(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "true") + + cases := []struct { + line string + want string + }{ + // RawOutput (NO_COLOR) form: no leading colon may leak through. + {"warning: disk almost full\n", "::warning title=openframe::disk almost full\n"}, + // A message that itself starts with "error..." must survive intact. + {"warning errors found in 3 charts\n", "::warning title=openframe::errors found in 3 charts\n"}, + } + for _, tc := range cases { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + aw := newAnnotationWriter(io.Discard, "warning") + _, _ = aw.Write([]byte(tc.line)) + + _ = w.Close() + os.Stdout = old + var sb strings.Builder + _, _ = io.Copy(&sb, r) + assert.Equal(t, tc.want, sb.String(), "line %q", tc.line) + } +}