Replace raw fmt.Fprintf output in outcomes_history.go with console package#39248
Conversation
…y.go Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
…ory table Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the outcomes history CLI command’s human-readable output to use the shared pkg/console formatting utilities (styled section headers/info messages and rendered tables) instead of raw fmt.Fprintf output, aligning the command with other CLI output patterns in the codebase.
Changes:
- Replace plain text section headers with
console.FormatSectionHeader(...). - Replace stat lines with
console.FormatInfoMessage(...). - Render objective bucket and representative item lists using
console.RenderTable(...).
Show a summary per file
| File | Description |
|---|---|
| pkg/cli/outcomes_history.go | Switches user-visible (stderr) output to console-formatted headers/info messages and table rendering for buckets/items. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 1/1 changed files
- Comments generated: 1
| fmt.Fprint(os.Stderr, console.RenderTable(console.TableConfig{ | ||
| Title: "Top objective buckets", | ||
| Headers: []string{"Bucket", "Count", "Mapped Value", "Contributed Value"}, | ||
| Rows: bucketRows, | ||
| })) |
|
✅ PR Code Quality Reviewer completed the code quality review. |
|
🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅ |
|
✅ Test Quality Sentinel completed test quality analysis. No test files were added or modified in this PR. Only pkg/cli/outcomes_history.go was changed (production code only). Test Quality Sentinel skipped. |
|
✅ Design Decision Gate 🏗️ completed the design decision gate check. No ADR enforcement needed: PR #39248 does not have the implementation label (has_implementation_label=false) and has only 29 new lines in business logic directories (default_business_additions=29, threshold=100; requires_adr_by_default_volume=false). Neither Condition A nor Condition B is met. |
There was a problem hiding this comment.
Skills-Based Review 🧠
Applied /zoom-out, /improve-codebase-architecture, and /grill-with-docs — approving with minor suggestions.
📋 Key Themes & Highlights
Key Themes
- Fmt.Fprintf inconsistency (line 299): inner section headers use
fmt.Fprintfwith embedded\n; outer header usesfmt.Fprintln. Minor, but easy to align. - Magic numbers (lines 305, 323): display caps of
8and5should be named constants for clarity and discoverability. - Ambiguous column header (line 332):
"Value"in the representative items table could be"Objective"or"Score"to match domain vocabulary.
Positive Highlights
- ✅ Correct
FormatInfoMessagecall pattern: usingfmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf(...)))is the safe approach — this is better than the pre-existing pattern intrial_confirmation.gowhere the styled string is passed as a format string tofmt.Fprintf, which is a latent bug. - ✅ Clean table conversion: the two ad-hoc loops are now proper
RenderTablecalls with explicit column headers — significant readability improvement. - ✅ Well-scoped: single file, +29/-9, zero logic change.
Test Coverage Note
renderHistoricalObjectiveReport has no test coverage. Since output always goes to os.Stderr this requires a bit of effort (e.g. capture via os.Pipe as done in TestRunOutcomesHistory_JSON), but a snapshot test would give confidence that future console API changes don't silently break the output structure.
🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 375.3 AIC · ⌖ 13.7 AIC · ⊞ 27.9K
| fmt.Fprintf(os.Stderr, " Sample size: %d\n", report.SampleSize) | ||
| fmt.Fprintf(os.Stderr, " Scored items: %d\n", report.ScoredItems) | ||
| fmt.Fprintf(os.Stderr, " Total objective value: %d\n", report.TotalObjectiveValue) | ||
| fmt.Fprintf(os.Stderr, "\n%s\n", console.FormatSectionHeader(strings.ToUpper(report.Source))) |
There was a problem hiding this comment.
[/zoom-out] Minor inconsistency: the outer header (line 166) uses fmt.Fprintln, but this inner section header uses fmt.Fprintf with embedded \n characters.
The leading blank line is important for visual separation, but expressing it as a bare fmt.Fprintln(os.Stderr) followed by fmt.Fprintln(os.Stderr, console.FormatSectionHeader(...)) would be more explicit and consistent with the rest of the file.
💡 Suggested change
// Before
fmt.Fprintf(os.Stderr, "\n%s\n", console.FormatSectionHeader(strings.ToUpper(report.Source)))
// After
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, console.FormatSectionHeader(strings.ToUpper(report.Source)))This matches the fmt.Fprintln pattern used at line 166 and makes blank-line intent explicit.
|
|
||
| if len(report.ObjectiveBuckets) > 0 { | ||
| fmt.Fprintln(os.Stderr, " Top objective buckets:") | ||
| bucketRows := make([][]string, 0, min(len(report.ObjectiveBuckets), 8)) |
There was a problem hiding this comment.
[/improve-codebase-architecture] Magic display-cap numbers (8 here and 5 at line 323) are unexplained. Named constants communicate intent and make the caps easy to adjust in one place.
💡 Suggested change
Add at the top of the file (alongside the existing const block):
const (
maxDisplayedBuckets = 8
maxDisplayedRepresentativeItems = 5
)Then use:
bucketRows := make([][]string, 0, min(len(report.ObjectiveBuckets), maxDisplayedBuckets))
for _, bucket := range report.ObjectiveBuckets[:min(len(report.ObjectiveBuckets), maxDisplayedBuckets)] {Note: buildHistoricalObjectiveReport already caps RepresentativeItems at 15; documenting the relationship between that cap and the 5-item display limit would also clarify intent.
| } | ||
| fmt.Fprint(os.Stderr, console.RenderTable(console.TableConfig{ | ||
| Title: "Representative items", | ||
| Headers: []string{"Number", "Value", "Title"}, |
There was a problem hiding this comment.
[/grill-with-docs] The "Value" column header is ambiguous in the context of this table. A reader seeing the column header in isolation won't know it means "objective score". The rest of the codebase consistently uses terms like objective_value and ObjectiveValue in its domain model.
Consider "Objective" or "Score" to keep column naming aligned with the domain language and make the table self-explanatory without needing to refer to the command's broader context.
There was a problem hiding this comment.
Review: outcomes_history.go console styling migration
The change is clean and correctly migrates all output through the console package. One non-blocking concern about information density in the bucket table.
Details
What was verified
RenderTablereturns a trailing\n;fmt.Fprint(notfmt.Fprintln) is correctly used — no double-newline.FormatSectionHeaderdoes not add a trailing newline;fmt.Fprintlnon line 166 is correct.strconvwas already imported; newconsoleimport is the only addition.- All
min(len(...), N)slice patterns match the original and are safe. RenderTablewith an emptyHeadersslice returns""— but headers are hardcoded strings here, so this guard never triggers.
Concern
- See inline comment on line 314 — the bucket table drops the inline formula notation (
count × mapped = contributed) that made the scoring breakdown self-explanatory.
Sub-agent note
The grumpy-coder sub-agent did not complete within the timeout window; findings above are from independent second-pass analysis.
🔎 Code quality review by PR Code Quality Reviewer · 374.2 AIC · ⌖ 13.3 AIC · ⊞ 17.3K
| strconv.Itoa(bucket.ContributedValue), | ||
| }) | ||
| } | ||
| fmt.Fprint(os.Stderr, console.RenderTable(console.TableConfig{ |
There was a problem hiding this comment.
Bucket table loses the formula relationship between columns: the old %3d x %3d = %4d notation made count × mapped_value = contributed_value visually explicit inline; the new 4-column layout hides it.
💡 Suggested fix
The old one-liner:
label1 3 x 10 = 30
immediately communicates that Count × MappedValue = ContributedValue. The new table rows just show three numbers and readers who are unfamiliar with the scoring model must infer the arithmetic.
Consider one of:
- Keep three columns but rename the header to make the formula explicit:
"Count × Mapped = Contributed"as a single column showing the full expressionfmt.Sprintf("%d x %d = %d", count, mapped, contributed). - Drop
Mapped ValueandContributed Valueas separate columns and replace with a singleScore (count×mapped=total)column. - Add a footnote in the table title, e.g.
"Top objective buckets (count × mapped value = contributed value)".
All data is still present in the table, so this is non-blocking, but the information is meaningfully less readable for diagnosing why a particular bucket dominates the objective score.
| fmt.Fprintf(os.Stderr, " Scored items: %d\n", report.ScoredItems) | ||
| fmt.Fprintf(os.Stderr, " Total objective value: %d\n", report.TotalObjectiveValue) | ||
| fmt.Fprintf(os.Stderr, "\n%s\n", console.FormatSectionHeader(strings.ToUpper(report.Source))) | ||
| fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Sample size: %d", report.SampleSize))) |
There was a problem hiding this comment.
FormatInfoMessage always emits the i Unicode character even in non-TTY mode, changing machine-parseable stderr output without warning.
💡 Details
From the console package source:
func FormatInfoMessage(message string) string {
return applyStyle(styles.Info, "i ") + message
}applyStyle only conditionally applies ANSI colour codes; the "i " literal prefix is always concatenated. Before this change:
Sample size: 42
Scored items: 10
Total objective value: 300
After, in any non-TTY context (CI logs, 2>file, shell pipe):
i Sample size: 42
i Scored items: 10
i Total objective value: 300
Any script, grep pattern, golden-file test, or downstream parser keyed on the old format will silently break. If the intent is purely cosmetic styling, FormatInfoMessage is the wrong function for non-interactive output paths; consider only calling it when tty.IsStderrTerminal() is true, or use fmt.Fprintf(os.Stderr, " Sample size: %d\n", ...) unconditionally with styling layered on top only in TTY mode.
| fmt.Fprintf(os.Stderr, " Sample size: %d\n", report.SampleSize) | ||
| fmt.Fprintf(os.Stderr, " Scored items: %d\n", report.ScoredItems) | ||
| fmt.Fprintf(os.Stderr, " Total objective value: %d\n", report.TotalObjectiveValue) | ||
| fmt.Fprintf(os.Stderr, "\n%s\n", console.FormatSectionHeader(strings.ToUpper(report.Source))) |
There was a problem hiding this comment.
FormatSectionHeader, FormatInfoMessage, and RenderTable all call isTTY() which resolves to tty.IsStdoutTerminal(), but every byte of output in this function is written to os.Stderr.
💡 Impact and suggested fix
This creates an inverted TTY mismatch:
gh aw outcomes history 2>log.txt— stdout is a terminal,isTTY()returnstrue, ANSI escape codes and box-drawing characters are written intolog.txt.gh aw outcomes history 2>&1 | less— stdout is a pipe,isTTY()returnsfalse, all styling is stripped from what the user sees in the pager.
The console package itself demonstrates the correct pattern — RenderTitleBox/RenderErrorBox use tty.IsStderrTerminal() for their stderr output. This diff introduces a new class of stderr output using functions that check the wrong fd.
Fix options:
- Gate these calls:
if tty.IsStderrTerminal() { fmt.Fprintln(os.Stderr, console.FormatSectionHeader(...)) } else { fmt.Fprintf(os.Stderr, "\n%s\n", ...) } - Add
FormatSectionHeaderForStderr/FormatInfoMessageForStderrvariants that calltty.IsStderrTerminal(). - Pass an
io.Writer+ TTY boolean down intorenderHistoricalObjectiveReportso callers control the decision.
|
Hey One thing that would strengthen this PR:
If you'd like a hand, you can assign this prompt to your coding agent:
|
|
@copilot run pr-finisher skill |
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
…tput Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Added |
|
@copilot review all comments and address unresolved review feedback.
|
pkg/cli/outcomes_history.gohad zero use of theconsolepackage — all user-visible output was plainfmt.Fprintf(os.Stderr, ...)with no styling.Changes
console.FormatSectionHeader()console.FormatInfoMessage()console.RenderTable()— headers: Bucket, Count, Mapped Value, Contributed Valueconsole.RenderTable()— headers: Number, Value, TitleTestRunOutcomesHistory_PrettyOutputwhich captures stderr and asserts the pretty output contains the expected section headers ("Objective history for …", source names), stat lines, and both table titles/headers