From 4cf08ce00a0818f4158a724004046ce8fcc5cfe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 07:33:30 +0300 Subject: [PATCH 1/7] Fix line-numbered diffs for accurate finding locations --- CHANGELOG.md | 21 ++++++++ README.md | 5 ++ internal/cli/dryrun.go | 5 +- internal/cli/remote_pr.go | 94 +++++++++++++++++++++++++--------- internal/cli/remote_pr_test.go | 47 +++++++++++++++++ internal/cli/review.go | 9 +++- internal/diff/anchor.go | 84 ++++++++++++++++++++++++++++++ internal/diff/anchor_test.go | 91 ++++++++++++++++++++++++++++++++ internal/diff/format.go | 50 ++++++++++++++---- internal/i18n/messages.en.yml | 1 + internal/i18n/messages.tr.yml | 1 + internal/remote/comment.go | 44 ++++++++++++++-- internal/remote/remote_test.go | 40 +++++++++++++++ internal/rules/prompt.go | 24 ++++++--- 14 files changed, 469 insertions(+), 47 deletions(-) create mode 100644 internal/diff/anchor.go create mode 100644 internal/diff/anchor_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c96fe97..e5ef5cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v > Tags prior to **v0.4.0** were cut in the private repository and produced no > public artifacts; the first publicly released version is v0.4.0. +## [1.2.1] + +### Fixed +- **`commitbrief remote pr` no longer mis-places inline comments.** + Comments are now anchored to the diff side each finding's line lives on + — `RIGHT` (new file) for added/context lines, `LEFT` (old file) for + removed lines — instead of unconditionally posting `side=RIGHT`. A + finding whose line falls outside the diff (or whose POST GitHub rejects) + is appended to the review summary under a "Findings that could not be + attached to a specific line" heading rather than being silently dropped. +### Changed +- **Line-numbered diffs for more accurate finding locations.** Every + review (local and `remote pr`) now sends the model a diff with each + changed line prefixed by the line number a comment would anchor to + (`| `), so the model copies line numbers instead of + counting them from the `@@` hunk header. This sharply reduces findings + landing on the wrong line (closing braces, blank lines). The on-disk + cache is rebuilt once on upgrade because the system prompt changed; the + diff component of the cache key is unaffected (the numbered form is a + deterministic function of the plain diff). + ## [1.2.0] ### Fixed diff --git a/README.md b/README.md index 53bb3f6..6dbecf2 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,11 @@ sets the severity at or above which the verdict becomes request-changes; provider. `--fail-on` is ignored here — the GitHub verdict replaces the exit-code gate. +Each comment is anchored to the diff side its line lives on — `RIGHT` +(new file) for added/context lines, `LEFT` (old file) for removed ones. +A finding whose line falls outside the diff (or whose POST is rejected) +is not dropped: it is appended to the review summary so nothing is lost. + ## Continuous integration Run CommitBrief on pull requests with the **[CommitBrief Review GitHub diff --git a/internal/cli/dryrun.go b/internal/cli/dryrun.go index 5f71842..9137a6d 100644 --- a/internal/cli/dryrun.go +++ b/internal/cli/dryrun.go @@ -66,7 +66,10 @@ func newDryRunCmd() *cobra.Command { // need it and Diff.String() rewalks the file tree on // every call. diffText := parsed.String() - p := prompt.Build(loaded, app.Lang, diffText) + // Estimate against the line-numbered diff the review will + // actually send (see review.go); the cache key still keys on + // the plain diffText so dry-run and the real run collide. + p := prompt.Build(loaded, app.Lang, parsed.NumberedString()) // UC-19: surface output-tokens / context-window / cost // alongside the input-tokens estimate so dry-run answers diff --git a/internal/cli/remote_pr.go b/internal/cli/remote_pr.go index 7709d97..ad87d99 100644 --- a/internal/cli/remote_pr.go +++ b/internal/cli/remote_pr.go @@ -126,34 +126,35 @@ func runRemotePR(cmd *cobra.Command, prID string, f remotePRFlags, runner remote return err } - findings, oid, err := reviewPRDiff(ctx, runner, prID, f, app, prov, loaded, meta.LastOID()) + findings, anchors, oid, err := reviewPRDiff(ctx, runner, prID, f, app, prov, loaded, meta.LastOID()) if err != nil { return err } - return submitPRReview(ctx, runner, prID, f, meta, oid, findings, threshold, whoami, app) + return submitPRReview(ctx, runner, prID, f, meta, oid, findings, anchors, threshold, whoami, app) } // reviewPRDiff fetches the PR diff, runs one review, and guards against a // race: if the PR head OID changed during the review it retries once, -// then aborts (ADR-0016 §7). Returns the findings plus the OID they were -// produced against (used to anchor inline comments). -func reviewPRDiff(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, app *appContext, prov provider.Provider, loaded rules.Loaded, lastOID string) ([]render.Finding, string, error) { +// then aborts (ADR-0016 §7). Returns the findings, the per-file anchor +// index they map onto, and the OID they were produced against (both used +// to place inline comments). +func reviewPRDiff(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, app *appContext, prov provider.Provider, loaded rules.Loaded, lastOID string) ([]render.Finding, map[string]diff.FileAnchors, string, error) { for attempt := 0; ; attempt++ { infof("%s", app.Catalog.T("remote.reviewing")) - findings, err := reviewOnePRDiff(ctx, runner, prID, f, app, prov, loaded) + findings, anchors, err := reviewOnePRDiff(ctx, runner, prID, f, app, prov, loaded) if err != nil { - return nil, "", err + return nil, nil, "", err } newOID, err := remote.FetchLastOID(ctx, runner, prID, f.repo) if err != nil { - return nil, "", err + return nil, nil, "", err } if newOID == lastOID { - return findings, lastOID, nil + return findings, anchors, lastOID, nil } if attempt >= 1 { - return nil, "", errors.New(app.Catalog.T("remote.too_volatile")) + return nil, nil, "", errors.New(app.Catalog.T("remote.too_volatile")) } infof("%s", app.Catalog.T("remote.race_retry")) lastOID = newOID @@ -163,20 +164,23 @@ func reviewPRDiff(ctx context.Context, runner remote.Runner, prID string, f remo // reviewOnePRDiff runs the structured review pipeline once against the // PR's current diff. Bot-mode: the secret scanner warns but never aborts // (ADR-0016 §3); the local-config guard and cost preflight are skipped. -func reviewOnePRDiff(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, app *appContext, prov provider.Provider, loaded rules.Loaded) ([]render.Finding, error) { +// Returns the findings plus the anchor index of the (filtered) diff the +// model reviewed, so comments can be pinned to the correct side. +func reviewOnePRDiff(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, app *appContext, prov provider.Provider, loaded rules.Loaded) ([]render.Finding, map[string]diff.FileAnchors, error) { rawDiff, err := remote.FetchDiff(ctx, runner, prID, f.repo) if err != nil { - return nil, err + return nil, nil, err } parsed, err := diff.Parse(git.Diff{Content: rawDiff, Origin: git.OriginDiff}) if err != nil { - return nil, err + return nil, nil, err } parsed = diff.Filter(parsed, buildMatcher(app.RepoRoot)) if parsed.Empty() { - return []render.Finding{}, nil + return []render.Finding{}, map[string]diff.FileAnchors{}, nil } diffText := parsed.String() + anchors := parsed.Anchors() if app.Config.Guard.SecretScan && !global.allowSecrets { if hits := guard.ScanForSecrets(diffText); len(hits) > 0 { @@ -184,7 +188,10 @@ func reviewOnePRDiff(ctx context.Context, runner remote.Runner, prID string, f r } } - p := prompt.Build(loaded, app.Lang, diffText) + // The model sees the line-numbered diff so it copies line numbers + // instead of estimating them (see review.go); anchors above are built + // from the same parsed diff. + p := prompt.Build(loaded, app.Lang, parsed.NumberedString()) model := app.Config.Providers[app.Config.Provider].Model if model == "" { model = prov.DefaultModel() @@ -197,22 +204,25 @@ func reviewOnePRDiff(ctx context.Context, runner remote.Runner, prID string, f r } content, _, format, err := tryStructuredReview(ctx, prov, req, func() {}) if err != nil { - return nil, err + return nil, nil, err } if format != cache.FormatJSON { - return nil, errors.New(app.Catalog.T("remote.degraded")) + return nil, nil, errors.New(app.Catalog.T("remote.degraded")) } findings, err := render.ParseFindings(content) if err != nil { - return nil, errors.New(app.Catalog.T("remote.degraded")) + return nil, nil, errors.New(app.Catalog.T("remote.degraded")) } - return findings, nil + return findings, anchors, nil } // submitPRReview posts the selected inline comments and the review-level -// verdict. Per-comment failures are logged and counted but never abort -// the verdict submission (ADR-0016 §9). -func submitPRReview(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, meta remote.PRMeta, oid string, findings []render.Finding, threshold render.Severity, whoami string, app *appContext) error { +// verdict. Each finding is anchored to the side (RIGHT/LEFT) its line +// actually lives on; findings whose line is outside the diff — or whose +// POST is rejected — are not dropped but appended to the review summary +// so the signal survives (ADR-0016 §9). Per-comment failures never abort +// the verdict submission. +func submitPRReview(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, meta remote.PRMeta, oid string, findings []render.Finding, anchors map[string]diff.FileAnchors, threshold render.Severity, whoami string, app *appContext) error { cat := app.Catalog verdict := computeVerdict(findings, threshold) postable := selectPostable(findings, threshold) @@ -222,8 +232,17 @@ func submitPRReview(ctx context.Context, runner remote.Runner, prID string, f re } slug := meta.BaseSlug() posted, failed := 0, 0 + var unanchored []render.Finding for _, fnd := range postable { - if fnd.Line <= 0 { + fa, hasFile := anchors[fnd.File] + side, ok := "", false + if hasFile { + side, ok = fa.Resolve(fnd.Line, preferLeftSide(fnd)) + } + if !ok { + // Line is not a postable position in the diff — a GitHub POST + // would 422. Keep it for the summary instead of losing it. + unanchored = append(unanchored, fnd) continue } err := remote.PostComment(ctx, runner, remote.CommentRequest{ @@ -232,10 +251,12 @@ func submitPRReview(ctx context.Context, runner remote.Runner, prID string, f re CommitID: oid, Path: fnd.File, Line: fnd.Line, + Side: side, Body: remote.BuildCommentBody(fnd, whoami), }) if err != nil { failed++ + unanchored = append(unanchored, fnd) infof("%s", cat.T("remote.comment_failed", fnd.PathRef(), err.Error())) continue } @@ -244,8 +265,14 @@ func submitPRReview(ctx context.Context, runner remote.Runner, prID string, f re if posted+failed > 0 { infof("%s", cat.T("remote.posted_summary", posted, posted+failed, failed)) } + if len(unanchored) > 0 { + infof("%s", cat.T("remote.unanchored_appended", len(unanchored))) + } body := remote.BuildReviewBody(verdict, whoami) + if section := remote.BuildUnanchoredSection(unanchored); section != "" { + body += "\n\n---\n\n" + section + } if err := remote.SubmitReview(ctx, runner, prID, f.repo, verdict, body); err != nil { return err } @@ -260,6 +287,27 @@ func submitPRReview(ctx context.Context, runner remote.Runner, prID string, f re return nil } +// preferLeftSide reports whether a finding reads as being about removed +// code, so a line number that is valid on both diff sides resolves to +// LEFT instead of RIGHT. Heuristic: the snippet carries at least one +// removed ("-") line and no added ("+") line. With no snippet we keep +// the RIGHT-first default — the common case is a finding about new code. +func preferLeftSide(f render.Finding) bool { + if f.Snippet == "" { + return false + } + minus, plus := 0, 0 + for _, ln := range strings.Split(f.Snippet, "\n") { + switch { + case strings.HasPrefix(ln, "-"): + minus++ + case strings.HasPrefix(ln, "+"): + plus++ + } + } + return minus > 0 && plus == 0 +} + // computeVerdict maps findings + threshold to a GitHub review verdict // (ADR-0016 §5). Severity rank: lower = more severe (severityRank). func computeVerdict(findings []render.Finding, threshold render.Severity) remote.Verdict { diff --git a/internal/cli/remote_pr_test.go b/internal/cli/remote_pr_test.go index 2d2fd0c..99dc638 100644 --- a/internal/cli/remote_pr_test.go +++ b/internal/cli/remote_pr_test.go @@ -13,6 +13,9 @@ import ( "github.com/spf13/cobra" + "github.com/CommitBrief/commitbrief/internal/diff" + "github.com/CommitBrief/commitbrief/internal/git" + "github.com/CommitBrief/commitbrief/internal/i18n" "github.com/CommitBrief/commitbrief/internal/remote" "github.com/CommitBrief/commitbrief/internal/render" ) @@ -308,3 +311,47 @@ func TestRemotePRAbortsOnDoubleRace(t *testing.T) { t.Errorf("no verdict on double race; calls=%v", r.calls) } } + +func TestSubmitPRReviewAnchorsAndFallsBack(t *testing.T) { + cat, err := i18n.Load("en") + if err != nil { + t.Fatal(err) + } + app := &appContext{Catalog: cat} + parsed, err := diff.Parse(git.Diff{Content: sampleDiff, Origin: git.OriginDiff}) + if err != nil { + t.Fatal(err) + } + anchors := parsed.Anchors() + + findings := []render.Finding{ + // Line 1 (context `package mock`) is a valid RIGHT position → posted. + {Severity: render.SeverityInfo, File: "mock.go", Line: 1, Title: "anchored", Description: "d", Suggestion: "s"}, + // Line 999 is outside every hunk → falls back to the summary body. + {Severity: render.SeverityInfo, File: "mock.go", Line: 999, Title: "floating", Description: "d", Suggestion: "s"}, + } + + r := &fakeGH{} + meta := remote.PRMeta{Number: 42, URL: "https://github.com/o/r/pull/42"} + if err := submitPRReview(context.Background(), r, "42", remotePRFlags{}, meta, "oid", + findings, anchors, render.SeverityCritical, "tester", app); err != nil { + t.Fatalf("submitPRReview: %v", err) + } + + if got := r.callCount("/comments"); got != 1 { + t.Errorf("want exactly 1 inline comment (only the anchored finding), got %d; calls=%v", got, r.calls) + } + // The unanchored finding must survive in the review body. + var reviewBody string + for _, c := range r.calls { + if strings.Contains(c, "review") { + reviewBody = c + } + } + if !strings.Contains(reviewBody, "Findings that could not be attached") { + t.Errorf("unanchored finding not appended to review body; review call=%q", reviewBody) + } + if !strings.Contains(reviewBody, "floating") { + t.Errorf("unanchored finding title missing from body; review call=%q", reviewBody) + } +} diff --git a/internal/cli/review.go b/internal/cli/review.go index 6031da1..eb293d7 100644 --- a/internal/cli/review.go +++ b/internal/cli/review.go @@ -191,11 +191,16 @@ func runReview(cmd *cobra.Command, scope reviewScopeFlags, diffArgs []string) er // guarantees unreliable. See ADR-0009 supersession note and the // clireview package. _, plainText := prov.(provider.PlainTextEmitter) + // The model sees the line-numbered diff so it can copy line numbers + // instead of counting them; the cache key and secret scan keep using + // the plain diffText (numberedDiff is a deterministic function of it, + // so the cache identity is unchanged). + numberedDiff := parsed.NumberedString() var p prompt.Prompt if plainText { - p = prompt.BuildPlainText(loaded, app.Lang, diffText) + p = prompt.BuildPlainText(loaded, app.Lang, numberedDiff) } else { - p = prompt.Build(loaded, app.Lang, diffText) + p = prompt.Build(loaded, app.Lang, numberedDiff) } model := app.Config.Providers[app.Config.Provider].Model diff --git a/internal/diff/anchor.go b/internal/diff/anchor.go new file mode 100644 index 0000000..f5bce17 --- /dev/null +++ b/internal/diff/anchor.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package diff + +// GitHub inline-review-comment `side` values. RIGHT is the post-image +// (new file), LEFT is the pre-image (old file). +const ( + SideRight = "RIGHT" + SideLeft = "LEFT" +) + +// FileAnchors indexes the line numbers in one file that a GitHub inline +// review comment can legally attach to. GitHub accepts a comment on any +// line that appears in the PR diff: on the RIGHT side that is every +// context and added line (numbered in the new file); on the LEFT side +// every context and removed line (numbered in the old file). Indexing +// both lets a finding be pinned to the side it actually lives on instead +// of unconditionally guessing RIGHT — and lets a finding whose line is +// outside the diff be detected before the POST 422s. +type FileAnchors struct { + right map[int]struct{} + left map[int]struct{} +} + +// Anchors builds a per-file index of postable comment positions. Keyed +// by the new-file path (FileDiff.Path), falling back to OldPath for pure +// deletions where Path is empty — the same key submitPRReview looks up +// with a finding's File. +func (d Diff) Anchors() map[string]FileAnchors { + out := make(map[string]FileAnchors, len(d.Files)) + for _, f := range d.Files { + key := f.Path + if key == "" { + key = f.OldPath + } + fa := FileAnchors{right: map[int]struct{}{}, left: map[int]struct{}{}} + for _, h := range f.Hunks { + oldNo, newNo := h.OldStart, h.NewStart + for _, l := range h.Lines { + switch l.Kind { + case LineContext: + fa.right[newNo] = struct{}{} + fa.left[oldNo] = struct{}{} + oldNo++ + newNo++ + case LineAdd: + fa.right[newNo] = struct{}{} + newNo++ + case LineDel: + fa.left[oldNo] = struct{}{} + oldNo++ + } + } + } + out[key] = fa + } + return out +} + +// Resolve maps a finding's reported line number to a postable GitHub +// comment side. preferLeft flips the lookup order so a line valid on +// both sides resolves to LEFT — used for findings about removed code. +// ok is false when the line matches no postable position; the caller +// must not POST it (it would 422) and should fall back to the review +// summary instead of dropping it. +func (fa FileAnchors) Resolve(line int, preferLeft bool) (side string, ok bool) { + if line <= 0 { + return "", false + } + order := [2]string{SideRight, SideLeft} + if preferLeft { + order = [2]string{SideLeft, SideRight} + } + for _, s := range order { + set := fa.right + if s == SideLeft { + set = fa.left + } + if _, found := set[line]; found { + return s, true + } + } + return "", false +} diff --git a/internal/diff/anchor_test.go b/internal/diff/anchor_test.go new file mode 100644 index 0000000..ddd035e --- /dev/null +++ b/internal/diff/anchor_test.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package diff + +import ( + "strings" + "testing" + + "github.com/CommitBrief/commitbrief/internal/git" +) + +func parseOne(t *testing.T, raw string) Diff { + t.Helper() + d, err := Parse(git.Diff{Content: raw, Origin: git.OriginDiff}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + return d +} + +func TestNumberedStringPrefixesLines(t *testing.T) { + // sampleModify hunk header: @@ -10,7 +10,9 @@ + got := parseOne(t, sampleModify).NumberedString() + wants := []string{ + "10| \tif creds.Username == \"\" {", // context keeps new number 10 + "13| -\tif creds.Password == \"\" {", // removed line keeps OLD number 13 + "13| +\tif creds.Password == \"\" || len(creds.Password) < 8 {", // added line, NEW number 13 + "16| +\tlogger.Info(\"login attempt\", \"user\", creds.Username)", + } + for _, w := range wants { + // tolerate the leading tab in the fixture's code text + needle := strings.ReplaceAll(w, "\t", "") + hay := strings.ReplaceAll(got, "\t", "") + if !strings.Contains(hay, needle) { + t.Errorf("NumberedString missing %q\n--- got ---\n%s", needle, got) + } + } + // The @@ header itself is untouched. + if !strings.Contains(got, "@@ -10,7 +10,9 @@") { + t.Errorf("hunk header lost:\n%s", got) + } +} + +func TestNumberedStringIsPureFunctionOfString(t *testing.T) { + // Stripping the "| " prefix from each numbered hunk line must + // reproduce the plain String() output — the cache keys on String(), + // so the two must stay in lock-step. + d := parseOne(t, sampleModify) + plain, numbered := d.String(), d.NumberedString() + if strings.Count(plain, "\n") != strings.Count(numbered, "\n") { + t.Fatalf("line count drift: plain=%d numbered=%d", + strings.Count(plain, "\n"), strings.Count(numbered, "\n")) + } +} + +func TestAnchorsResolveSides(t *testing.T) { + anchors := parseOne(t, sampleModify).Anchors() + fa, ok := anchors["internal/auth/login.go"] + if !ok { + t.Fatalf("no anchors for the file; keys=%v", anchors) + } + + // New line 13 is the added password-length check → RIGHT. + if side, ok := fa.Resolve(13, false); !ok || side != SideRight { + t.Errorf("line 13 RIGHT-first: side=%q ok=%v, want RIGHT/true", side, ok) + } + // Old line 13 is the removed line → reachable on LEFT when preferred. + if side, ok := fa.Resolve(13, true); !ok || side != SideLeft { + t.Errorf("line 13 LEFT-first: side=%q ok=%v, want LEFT/true", side, ok) + } + // New line 18 exists only on the RIGHT side (old numbering stops at + // 17), so it resolves RIGHT even when LEFT is preferred. + if side, ok := fa.Resolve(18, true); !ok || side != SideRight { + t.Errorf("line 18: side=%q ok=%v, want RIGHT/true (no LEFT match)", side, ok) + } + // A line outside every hunk is unanchorable. + if _, ok := fa.Resolve(9999, false); ok { + t.Errorf("line 9999 should not anchor") + } + // Zero / negative lines never anchor. + if _, ok := fa.Resolve(0, false); ok { + t.Errorf("line 0 should not anchor") + } +} + +func TestAnchorsAddedFileHasNoLeftSide(t *testing.T) { + fa := parseOne(t, sampleAdded).Anchors()["internal/feature/new.go"] + if side, ok := fa.Resolve(1, true); !ok || side != SideRight { + t.Errorf("added file line 1 prefer-left: side=%q ok=%v, want RIGHT/true", side, ok) + } +} diff --git a/internal/diff/format.go b/internal/diff/format.go index 61c7416..388e29e 100644 --- a/internal/diff/format.go +++ b/internal/diff/format.go @@ -10,12 +10,31 @@ import ( func (d Diff) String() string { var sb strings.Builder for _, f := range d.Files { - sb.WriteString(f.String()) + sb.WriteString(f.render(false)) } return sb.String() } -func (f FileDiff) String() string { +// NumberedString renders the diff like String, but prefixes every hunk +// line with the line number a GitHub inline comment would anchor to: +// the new-file number for added and context lines, the old-file number +// for removed lines. The format is `| ` (marker is +// `+`/`-`/space). LLMs count poorly across long hunks and tend to echo +// the `@@` header's start line; handing them the number per line turns +// the `line` field of a finding from an estimate into a copy. The cache +// key still keys off String() — NumberedString is a deterministic +// function of the same diff, so it carries no extra cache identity. +func (d Diff) NumberedString() string { + var sb strings.Builder + for _, f := range d.Files { + sb.WriteString(f.render(true)) + } + return sb.String() +} + +func (f FileDiff) String() string { return f.render(false) } + +func (f FileDiff) render(numbered bool) string { var sb strings.Builder oldPath := f.OldPath if oldPath == "" { @@ -57,12 +76,14 @@ func (f FileDiff) String() string { } for _, h := range f.Hunks { - sb.WriteString(h.String()) + sb.WriteString(h.render(numbered)) } return sb.String() } -func (h Hunk) String() string { +func (h Hunk) String() string { return h.render(false) } + +func (h Hunk) render(numbered bool) string { var sb strings.Builder fmt.Fprintf(&sb, "@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines) if h.Header != "" { @@ -70,17 +91,28 @@ func (h Hunk) String() string { sb.WriteString(h.Header) } sb.WriteString("\n") + + oldNo, newNo := h.OldStart, h.NewStart for _, l := range h.Lines { + var marker byte + var num int switch l.Kind { case LineAdd: - sb.WriteString("+") + marker, num = '+', newNo + newNo++ case LineDel: - sb.WriteString("-") + marker, num = '-', oldNo + oldNo++ case LineContext: - sb.WriteString(" ") + marker, num = ' ', newNo + oldNo++ + newNo++ + } + if numbered { + fmt.Fprintf(&sb, "%d| %c%s\n", num, marker, l.Text) + } else { + fmt.Fprintf(&sb, "%c%s\n", marker, l.Text) } - sb.WriteString(l.Text) - sb.WriteString("\n") } return sb.String() } diff --git a/internal/i18n/messages.en.yml b/internal/i18n/messages.en.yml index 89727be..69b4f5f 100644 --- a/internal/i18n/messages.en.yml +++ b/internal/i18n/messages.en.yml @@ -142,6 +142,7 @@ remote.race_retry: "PR head changed during review; retrying once…" remote.posting_comments: "Posting %d inline comment(s)…" remote.comment_failed: "⚠ failed to post comment for %s (%s)" remote.posted_summary: "Posted %d/%d comments, %d failed." +remote.unanchored_appended: "%d finding(s) could not be anchored to a diff line; appended to the review summary." remote.action_approve: "Approved PR #%d." remote.action_comment: "Submitted as comment-only on PR #%d." remote.action_request_changes: "Requested changes on PR #%d." diff --git a/internal/i18n/messages.tr.yml b/internal/i18n/messages.tr.yml index b013442..68f1d55 100644 --- a/internal/i18n/messages.tr.yml +++ b/internal/i18n/messages.tr.yml @@ -140,6 +140,7 @@ remote.race_retry: "Review sırasında PR head değişti; bir kez yeniden deneni remote.posting_comments: "%d satır içi yorum gönderiliyor…" remote.comment_failed: "⚠ %s için yorum gönderilemedi (%s)" remote.posted_summary: "%d/%d yorum gönderildi, %d başarısız." +remote.unanchored_appended: "%d bulgu bir diff satırına sabitlenemedi; review özetine eklendi." remote.action_approve: "PR #%d onaylandı." remote.action_comment: "PR #%d yalnızca yorum olarak gönderildi." remote.action_request_changes: "PR #%d için değişiklik talep edildi." diff --git a/internal/remote/comment.go b/internal/remote/comment.go index 89ae0c1..387a174 100644 --- a/internal/remote/comment.go +++ b/internal/remote/comment.go @@ -32,21 +32,29 @@ func BuildCommentBody(f render.Finding, whoami string) string { // CommentRequest is one inline comment to POST. RepoSlug is the PR's // baseRepository ("owner/name", cross-fork correctness); CommitID is the -// head OID the diff was fetched at. +// head OID the diff was fetched at. Side is "RIGHT" (new file) or "LEFT" +// (old file); empty defaults to RIGHT. type CommentRequest struct { RepoSlug string PRNumber int CommitID string Path string Line int + Side string Body string } // PostComment posts a single inline review comment via the REST API. -// `side=RIGHT` is unconditional (the LLM reviews newly-added code); a -// finding pinned to a deleted line may return 422, which the caller -// swallows per-comment (ADR-0016 §9). +// Side is chosen by the caller from the parsed diff (RIGHT for added / +// context lines, LEFT for removed ones); a finding whose line is outside +// the diff is filtered out upstream and never reaches here, so a 422 is +// now an unexpected GitHub error rather than the routine hallucinated-line +// case (ADR-0016 §9). func PostComment(ctx context.Context, r Runner, c CommentRequest) error { + side := c.Side + if side == "" { + side = "RIGHT" + } endpoint := fmt.Sprintf("/repos/%s/pulls/%d/comments", c.RepoSlug, c.PRNumber) _, err := r.Run(ctx, "api", "--method", "POST", @@ -57,7 +65,33 @@ func PostComment(ctx context.Context, r Runner, c CommentRequest) error { "-f", "commit_id="+c.CommitID, "-f", "path="+c.Path, "-F", "line="+strconv.Itoa(c.Line), - "-f", "side=RIGHT", + "-f", "side="+side, ) return err } + +// unanchoredHeading introduces findings that could not be attached to a +// diff line (the LLM referenced a line outside the diff, or the POST was +// rejected). They are appended to the review summary so the signal is +// not silently lost (ADR-0016 §9). Fixed English like the rest of the +// GitHub-posted text (ADR-0016 §10). +const unanchoredHeading = "Findings that could not be attached to a specific line:" + +// BuildUnanchoredSection renders the findings that fell back to the +// review body. Returns "" when there are none so callers can append it +// unconditionally. +func BuildUnanchoredSection(findings []render.Finding) string { + if len(findings) == 0 { + return "" + } + var b strings.Builder + b.WriteString(unanchoredHeading) + for _, f := range findings { + b.WriteString("\n\n") + fmt.Fprintf(&b, "[%s] %s\n", strings.ToUpper(string(f.Severity)), f.PathRef()) + fmt.Fprintf(&b, "%s\n", f.Title) + fmt.Fprintf(&b, "%s\n", f.Description) + b.WriteString(f.Suggestion) + } + return b.String() +} diff --git a/internal/remote/remote_test.go b/internal/remote/remote_test.go index 7c500cd..02f9289 100644 --- a/internal/remote/remote_test.go +++ b/internal/remote/remote_test.go @@ -138,6 +138,46 @@ func TestPostCommentSendsSideRight(t *testing.T) { } } +func TestPostCommentSendsSideLeft(t *testing.T) { + fr := &fakeRunner{} + c := CommentRequest{ + RepoSlug: "octo/demo", PRNumber: 7, + CommitID: "deadbeef", Path: "main.go", Line: 12, Side: "LEFT", Body: "x", + } + if err := PostComment(context.Background(), fr, c); err != nil { + t.Fatalf("PostComment: %v", err) + } + if !argsContain(fr.lastCall(), "side=LEFT") { + t.Errorf("explicit Side=LEFT not propagated in %v", fr.lastCall()) + } +} + +func TestBuildUnanchoredSection(t *testing.T) { + if got := BuildUnanchoredSection(nil); got != "" { + t.Errorf("empty input must yield empty section, got %q", got) + } + findings := []render.Finding{{ + Severity: render.SeverityCritical, + File: "app/x.go", + Line: 42, + Title: "Hardcoded secret", + Description: "A token is committed in source.", + Suggestion: "Move it to an env var.", + }} + got := BuildUnanchoredSection(findings) + for _, want := range []string{ + unanchoredHeading, + "[CRITICAL] app/x.go:42", + "Hardcoded secret", + "A token is committed in source.", + "Move it to an env var.", + } { + if !strings.Contains(got, want) { + t.Errorf("section missing %q\n--- got ---\n%s", want, got) + } + } +} + func TestPostCommentPropagatesError(t *testing.T) { fr := &fakeRunner{errs: []error{errors.New("422 Unprocessable Entity")}} err := PostComment(context.Background(), fr, CommentRequest{RepoSlug: "o/r", PRNumber: 1, Path: "f", Line: 1}) diff --git a/internal/rules/prompt.go b/internal/rules/prompt.go index d1a6c42..9e6f812 100644 --- a/internal/rules/prompt.go +++ b/internal/rules/prompt.go @@ -9,7 +9,10 @@ import ( "github.com/CommitBrief/commitbrief/internal/lang" ) -const userTemplate = "Diff to review:\n```diff\n%s\n```" +const userTemplate = "Diff to review: each changed line is prefixed with " + + "`| ` and then the usual diff marker (`+` added, `-` removed, " + + "space for context). Use that leading number as the `line` value of any " + + "finding on that line.\n```diff\n%s\n```" // severityRubric is the fixed severity vocabulary the LLM must use in every // finding. It lives in the prompt builder (not in default.md) so that a @@ -38,8 +41,8 @@ const jsonContract = `Return a single JSON object matching this exact schema. Ou { "severity": "critical | high | medium | low | info", "file": "", - "line": , - "line_end": , + "line": , + "line_end": , "title": "", "description": "<1-3 sentences explaining the issue and its impact>", "suggestion": "<2-3 sentence concrete fix recommendation>", @@ -51,6 +54,11 @@ const jsonContract = `Return a single JSON object matching this exact schema. Ou Required fields per finding: severity, file, line, title, description, suggestion. +The "line" field MUST be the number printed before "|" at the start of the +relevant diff line — copy it, do not count or estimate. For findings about +removed code, use the number shown on the "-" line; for added or context +code, the number on the "+" or unmarked line. + The "suggestion" field is REQUIRED and carries the actionable remediation: - 2-3 sentences explaining what the developer should change and why. - Concrete and specific to this finding — name functions, parameters, or @@ -70,9 +78,10 @@ Optional fields: 1. Copy lines VERBATIM from the diff supplied above — do not paraphrase, summarise, edit, or invent code. 2. Max 6 lines. - 3. Use exactly the diff prefixes: "- " for removed, "+ " for added, - two spaces for context. No other prefixes. - 4. NO hunk headers ("@@ ..."), NO line numbers, NO file headers. + 3. Strip the leading "| " prefix from each line you copy. + Then use exactly the diff prefixes: "- " for removed, "+ " for + added, two spaces for context. No other prefixes. + 4. NO hunk headers ("@@ ..."), NO line-number prefixes, NO file headers. 5. Include snippet ONLY when a code excerpt materially clarifies the finding. When in doubt, omit — an unhelpful snippet is worse than no snippet. @@ -112,7 +121,8 @@ Rules: - icon is one of: 💥 (critical), 🚨 (high), ⚡ (medium), 📌 (low), 💡 (info). - SEVERITY is the uppercase severity name (CRITICAL, HIGH, MEDIUM, LOW, INFO). - path is the file path relative to repo root. -- line is the 1-based line number where the finding starts. For multi-line +- line is the number printed before "|" at the start of the diff line where + the finding starts — copy it, do not count or estimate. For multi-line findings spanning multiple lines, write "line-end_line" (e.g. "142-158") instead of a single number. Single-line findings use just "line". - Title is a one-sentence summary of the issue. From 73ca3cb5da91e43e3c61d0f566de04b44df353dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 07:38:26 +0300 Subject: [PATCH 2/7] =?UTF-8?q?Prefix=20remediation=20lines=20with=20`?= =?UTF-8?q?=F0=9F=92=A1`=20for=20GitHub=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 +++ internal/remote/comment.go | 10 ++++++++-- internal/remote/remote_test.go | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ef5cf..bcab97e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v cache is rebuilt once on upgrade because the system prompt changed; the diff component of the cache key is unaffected (the numbered form is a deterministic function of the plain diff). +- **`remote pr` suggestion lines are prefixed with `💡`.** The remediation + line in both inline comments and the review-summary fallback now starts + with `💡 ` so it reads distinctly from the description. ## [1.2.0] diff --git a/internal/remote/comment.go b/internal/remote/comment.go index 387a174..8c21487 100644 --- a/internal/remote/comment.go +++ b/internal/remote/comment.go @@ -16,17 +16,22 @@ import ( // auto-link it to an issue (ADR-0016 §10). const signature = "by #CommitBrief" +// suggestionPrefix marks the remediation line in GitHub-posted text so it +// stands out from the description. Kept here (not in i18n) because all +// GitHub text is fixed English (ADR-0016 §10). +const suggestionPrefix = "💡 " + // BuildCommentBody renders one finding as an inline review comment body. // Fixed English (ADR-0016 §10): // // [SEVERITY] - Title // Description -// Suggestion @whoami by #CommitBrief +// 💡 Suggestion @whoami by #CommitBrief func BuildCommentBody(f render.Finding, whoami string) string { var b strings.Builder fmt.Fprintf(&b, "[%s] - %s\n", strings.ToUpper(string(f.Severity)), f.Title) fmt.Fprintf(&b, "%s\n", f.Description) - fmt.Fprintf(&b, "%s @%s %s", f.Suggestion, whoami, signature) + fmt.Fprintf(&b, "%s%s @%s %s", suggestionPrefix, f.Suggestion, whoami, signature) return b.String() } @@ -91,6 +96,7 @@ func BuildUnanchoredSection(findings []render.Finding) string { fmt.Fprintf(&b, "[%s] %s\n", strings.ToUpper(string(f.Severity)), f.PathRef()) fmt.Fprintf(&b, "%s\n", f.Title) fmt.Fprintf(&b, "%s\n", f.Description) + b.WriteString(suggestionPrefix) b.WriteString(f.Suggestion) } return b.String() diff --git a/internal/remote/remote_test.go b/internal/remote/remote_test.go index 02f9289..bf51f64 100644 --- a/internal/remote/remote_test.go +++ b/internal/remote/remote_test.go @@ -70,7 +70,7 @@ func TestBuildCommentBody(t *testing.T) { got := BuildCommentBody(f, "octocat") want := "[HIGH] - Unvalidated input\n" + "The handler trusts the query param.\n" + - "Validate and bound the id before use. @octocat by #CommitBrief" + "💡 Validate and bound the id before use. @octocat by #CommitBrief" if got != want { t.Fatalf("comment body mismatch:\n got: %q\nwant: %q", got, want) } @@ -170,7 +170,7 @@ func TestBuildUnanchoredSection(t *testing.T) { "[CRITICAL] app/x.go:42", "Hardcoded secret", "A token is committed in source.", - "Move it to an env var.", + "💡 Move it to an env var.", } { if !strings.Contains(got, want) { t.Errorf("section missing %q\n--- got ---\n%s", want, got) From 978671fbef95f58274f37ebaf0ea750ccf846fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 07:42:08 +0300 Subject: [PATCH 3/7] Lowercase signature to `by #commitbrief` in comment and test functions --- CHANGELOG.md | 3 ++- internal/remote/comment.go | 6 +++--- internal/remote/remote_test.go | 8 ++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcab97e..52e2c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,8 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v deterministic function of the plain diff). - **`remote pr` suggestion lines are prefixed with `💡`.** The remediation line in both inline comments and the review-summary fallback now starts - with `💡 ` so it reads distinctly from the description. + with `💡 ` so it reads distinctly from the description. The signature was + also lowercased to `by #commitbrief`. ## [1.2.0] diff --git a/internal/remote/comment.go b/internal/remote/comment.go index 8c21487..0d093e6 100644 --- a/internal/remote/comment.go +++ b/internal/remote/comment.go @@ -11,10 +11,10 @@ import ( "github.com/CommitBrief/commitbrief/internal/render" ) -// signature is appended to every GitHub-posted message. `#CommitBrief` +// signature is appended to every GitHub-posted message. `#commitbrief` // is literal text — the leading `#` is non-numeric so GitHub does not // auto-link it to an issue (ADR-0016 §10). -const signature = "by #CommitBrief" +const signature = "by #commitbrief" // suggestionPrefix marks the remediation line in GitHub-posted text so it // stands out from the description. Kept here (not in i18n) because all @@ -26,7 +26,7 @@ const suggestionPrefix = "💡 " // // [SEVERITY] - Title // Description -// 💡 Suggestion @whoami by #CommitBrief +// 💡 Suggestion @whoami by #commitbrief func BuildCommentBody(f render.Finding, whoami string) string { var b strings.Builder fmt.Fprintf(&b, "[%s] - %s\n", strings.ToUpper(string(f.Severity)), f.Title) diff --git a/internal/remote/remote_test.go b/internal/remote/remote_test.go index bf51f64..7ff5ab0 100644 --- a/internal/remote/remote_test.go +++ b/internal/remote/remote_test.go @@ -70,7 +70,7 @@ func TestBuildCommentBody(t *testing.T) { got := BuildCommentBody(f, "octocat") want := "[HIGH] - Unvalidated input\n" + "The handler trusts the query param.\n" + - "💡 Validate and bound the id before use. @octocat by #CommitBrief" + "💡 Validate and bound the id before use. @octocat by #commitbrief" if got != want { t.Fatalf("comment body mismatch:\n got: %q\nwant: %q", got, want) } @@ -78,9 +78,9 @@ func TestBuildCommentBody(t *testing.T) { func TestBuildReviewBody(t *testing.T) { cases := map[Verdict]string{ - VerdictApprove: "@octocat by #CommitBrief", - VerdictComment: "It must be checked by the human eye. @octocat by #CommitBrief", - VerdictRequestChanges: "We can revisit it after we've solved the problems. @octocat by #CommitBrief", + VerdictApprove: "@octocat by #commitbrief", + VerdictComment: "It must be checked by the human eye. @octocat by #commitbrief", + VerdictRequestChanges: "We can revisit it after we've solved the problems. @octocat by #commitbrief", } for v, want := range cases { if got := BuildReviewBody(v, "octocat"); got != want { From cfea2b374822183ac3ac28d2ce8948a897424176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 07:50:05 +0300 Subject: [PATCH 4/7] Add staged-tree progress display to remote PR pipeline --- CHANGELOG.md | 4 ++ internal/cli/remote_pr.go | 79 +++++++++++++++++++++++++--------- internal/cli/remote_pr_test.go | 5 ++- internal/i18n/messages.en.yml | 1 + internal/i18n/messages.tr.yml | 1 + 5 files changed, 68 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e2c6b..b2b751d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v cache is rebuilt once on upgrade because the system prompt changed; the diff component of the cache key is unaffected (the numbered form is a deterministic function of the plain diff). +- **`remote pr` uses the staged-tree progress display.** Pipeline stages + (fetch → review → post → submit) now render through the same animated + tree the local `review` command uses (one line per stage in non-TTY/CI; + suppressed by `--quiet`) instead of flat stderr lines. - **`remote pr` suggestion lines are prefixed with `💡`.** The remediation line in both inline comments and the review-summary fallback now starts with `💡 ` so it reads distinctly from the description. The signature was diff --git a/internal/cli/remote_pr.go b/internal/cli/remote_pr.go index ad87d99..df8ff3e 100644 --- a/internal/cli/remote_pr.go +++ b/internal/cli/remote_pr.go @@ -19,6 +19,7 @@ import ( "github.com/CommitBrief/commitbrief/internal/remote" "github.com/CommitBrief/commitbrief/internal/render" "github.com/CommitBrief/commitbrief/internal/rules" + "github.com/CommitBrief/commitbrief/internal/ui" ) type remotePRFlags struct { @@ -103,35 +104,53 @@ func runRemotePR(cmd *cobra.Command, prID string, f remotePRFlags, runner remote return errors.New(cat.T("remote.plain_text_provider")) } + // Same staged-tree progress display the local review uses, so the + // remote pipeline reads identically (stages animate on a TTY, degrade + // to one line per transition in CI). All progress/warning output goes + // through prog while it is live — a raw stderr write would corrupt the + // animated redraw. + prog := ui.NewProgress(cmd.ErrOrStderr(), ui.ParseColorMode(global.color), global.quiet) + defer prog.Close() + if global.failOn != "" { - infof("%s", cat.T("remote.fail_on_ignored")) + prog.Info(cat.T("remote.fail_on_ignored")) } whoami, err := remote.Whoami(ctx, runner) if err != nil { + prog.Fail(err) return err } - infof("%s", cat.T("remote.fetching_pr", prID)) + prog.Start(cat.T("remote.fetching_pr", prID)) meta, err := remote.FetchPRMeta(ctx, runner, prID, f.repo) if err != nil { + prog.Fail(err) return err } if strings.EqualFold(meta.AuthorLogin(), whoami) { - return errors.New(cat.T("remote.self_pr_blocked")) + err := errors.New(cat.T("remote.self_pr_blocked")) + prog.Fail(err) + return err } loaded, err := rules.Load(app.RepoRoot) if err != nil { + prog.Fail(err) return err } - findings, anchors, oid, err := reviewPRDiff(ctx, runner, prID, f, app, prov, loaded, meta.LastOID()) + findings, anchors, oid, err := reviewPRDiff(ctx, runner, prID, f, app, prov, loaded, meta.LastOID(), prog) if err != nil { + prog.Fail(err) return err } - return submitPRReview(ctx, runner, prID, f, meta, oid, findings, anchors, threshold, whoami, app) + if err := submitPRReview(ctx, runner, prID, f, meta, oid, findings, anchors, threshold, whoami, app, prog); err != nil { + prog.Fail(err) + return err + } + return nil } // reviewPRDiff fetches the PR diff, runs one review, and guards against a @@ -139,10 +158,9 @@ func runRemotePR(cmd *cobra.Command, prID string, f remotePRFlags, runner remote // then aborts (ADR-0016 §7). Returns the findings, the per-file anchor // index they map onto, and the OID they were produced against (both used // to place inline comments). -func reviewPRDiff(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, app *appContext, prov provider.Provider, loaded rules.Loaded, lastOID string) ([]render.Finding, map[string]diff.FileAnchors, string, error) { +func reviewPRDiff(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, app *appContext, prov provider.Provider, loaded rules.Loaded, lastOID string, prog *ui.Progress) ([]render.Finding, map[string]diff.FileAnchors, string, error) { for attempt := 0; ; attempt++ { - infof("%s", app.Catalog.T("remote.reviewing")) - findings, anchors, err := reviewOnePRDiff(ctx, runner, prID, f, app, prov, loaded) + findings, anchors, err := reviewOnePRDiff(ctx, runner, prID, f, app, prov, loaded, prog) if err != nil { return nil, nil, "", err } @@ -156,7 +174,10 @@ func reviewPRDiff(ctx context.Context, runner remote.Runner, prID string, f remo if attempt >= 1 { return nil, nil, "", errors.New(app.Catalog.T("remote.too_volatile")) } - infof("%s", app.Catalog.T("remote.race_retry")) + // Head moved: neutralize the just-finished review stage (it was + // valid work, just superseded) and note the retry before looping. + prog.Soft() + prog.Info(app.Catalog.T("remote.race_retry")) lastOID = newOID } } @@ -166,7 +187,7 @@ func reviewPRDiff(ctx context.Context, runner remote.Runner, prID string, f remo // (ADR-0016 §3); the local-config guard and cost preflight are skipped. // Returns the findings plus the anchor index of the (filtered) diff the // model reviewed, so comments can be pinned to the correct side. -func reviewOnePRDiff(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, app *appContext, prov provider.Provider, loaded rules.Loaded) ([]render.Finding, map[string]diff.FileAnchors, error) { +func reviewOnePRDiff(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, app *appContext, prov provider.Provider, loaded rules.Loaded, prog *ui.Progress) ([]render.Finding, map[string]diff.FileAnchors, error) { rawDiff, err := remote.FetchDiff(ctx, runner, prID, f.repo) if err != nil { return nil, nil, err @@ -182,12 +203,17 @@ func reviewOnePRDiff(ctx context.Context, runner remote.Runner, prID string, f r diffText := parsed.String() anchors := parsed.Anchors() + // Emit the secret warning before starting the review stage, so it lands + // as a note under the (now finished) fetch stage rather than prematurely + // terminating the review stage. if app.Config.Guard.SecretScan && !global.allowSecrets { if hits := guard.ScanForSecrets(diffText); len(hits) > 0 { - infof("%s", app.Catalog.T("remote.secret_warn", len(hits))) + prog.Info(app.Catalog.T("remote.secret_warn", len(hits))) } } + prog.Start(app.Catalog.T("remote.reviewing")) + // The model sees the line-numbered diff so it copies line numbers // instead of estimating them (see review.go); anchors above are built // from the same parsed diff. @@ -222,17 +248,18 @@ func reviewOnePRDiff(ctx context.Context, runner remote.Runner, prID string, f r // POST is rejected — are not dropped but appended to the review summary // so the signal survives (ADR-0016 §9). Per-comment failures never abort // the verdict submission. -func submitPRReview(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, meta remote.PRMeta, oid string, findings []render.Finding, anchors map[string]diff.FileAnchors, threshold render.Severity, whoami string, app *appContext) error { +func submitPRReview(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, meta remote.PRMeta, oid string, findings []render.Finding, anchors map[string]diff.FileAnchors, threshold render.Severity, whoami string, app *appContext, prog *ui.Progress) error { cat := app.Catalog verdict := computeVerdict(findings, threshold) postable := selectPostable(findings, threshold) - if len(postable) > 0 { - infof("%s", cat.T("remote.posting_comments", len(postable))) - } slug := meta.BaseSlug() posted, failed := 0, 0 var unanchored []render.Finding + var failures []string + if len(postable) > 0 { + prog.Start(cat.T("remote.posting_comments", len(postable))) + } for _, fnd := range postable { fa, hasFile := anchors[fnd.File] side, ok := "", false @@ -257,32 +284,42 @@ func submitPRReview(ctx context.Context, runner remote.Runner, prID string, f re if err != nil { failed++ unanchored = append(unanchored, fnd) - infof("%s", cat.T("remote.comment_failed", fnd.PathRef(), err.Error())) + // Collect rather than emit mid-loop: prog.Info would terminate + // the active posting stage on the first failure. + failures = append(failures, cat.T("remote.comment_failed", fnd.PathRef(), err.Error())) continue } posted++ } + if len(postable) > 0 { + prog.Finish() // posting stage done + } + for _, msg := range failures { + prog.Info(msg) + } if posted+failed > 0 { - infof("%s", cat.T("remote.posted_summary", posted, posted+failed, failed)) + prog.Info(cat.T("remote.posted_summary", posted, posted+failed, failed)) } if len(unanchored) > 0 { - infof("%s", cat.T("remote.unanchored_appended", len(unanchored))) + prog.Info(cat.T("remote.unanchored_appended", len(unanchored))) } body := remote.BuildReviewBody(verdict, whoami) if section := remote.BuildUnanchoredSection(unanchored); section != "" { body += "\n\n---\n\n" + section } + prog.Start(cat.T("remote.submitting")) if err := remote.SubmitReview(ctx, runner, prID, f.repo, verdict, body); err != nil { return err } + prog.Finish() switch verdict { case remote.VerdictApprove: - infof("%s", cat.T("remote.action_approve", meta.Number)) + prog.Info(cat.T("remote.action_approve", meta.Number)) case remote.VerdictRequestChanges: - infof("%s", cat.T("remote.action_request_changes", meta.Number)) + prog.Info(cat.T("remote.action_request_changes", meta.Number)) default: - infof("%s", cat.T("remote.action_comment", meta.Number)) + prog.Info(cat.T("remote.action_comment", meta.Number)) } return nil } diff --git a/internal/cli/remote_pr_test.go b/internal/cli/remote_pr_test.go index 99dc638..9748e20 100644 --- a/internal/cli/remote_pr_test.go +++ b/internal/cli/remote_pr_test.go @@ -5,6 +5,7 @@ package cli import ( "context" "errors" + "io" "os" "path/filepath" "runtime" @@ -18,6 +19,7 @@ import ( "github.com/CommitBrief/commitbrief/internal/i18n" "github.com/CommitBrief/commitbrief/internal/remote" "github.com/CommitBrief/commitbrief/internal/render" + "github.com/CommitBrief/commitbrief/internal/ui" ) func TestParseRequestChangesOn(t *testing.T) { @@ -333,8 +335,9 @@ func TestSubmitPRReviewAnchorsAndFallsBack(t *testing.T) { r := &fakeGH{} meta := remote.PRMeta{Number: 42, URL: "https://github.com/o/r/pull/42"} + prog := ui.NewProgress(io.Discard, ui.ColorNever, true) // silent if err := submitPRReview(context.Background(), r, "42", remotePRFlags{}, meta, "oid", - findings, anchors, render.SeverityCritical, "tester", app); err != nil { + findings, anchors, render.SeverityCritical, "tester", app, prog); err != nil { t.Fatalf("submitPRReview: %v", err) } diff --git a/internal/i18n/messages.en.yml b/internal/i18n/messages.en.yml index 69b4f5f..1be6ef0 100644 --- a/internal/i18n/messages.en.yml +++ b/internal/i18n/messages.en.yml @@ -140,6 +140,7 @@ remote.fetching_pr: "Fetching PR %s…" remote.reviewing: "Running review…" remote.race_retry: "PR head changed during review; retrying once…" remote.posting_comments: "Posting %d inline comment(s)…" +remote.submitting: "Submitting verdict…" remote.comment_failed: "⚠ failed to post comment for %s (%s)" remote.posted_summary: "Posted %d/%d comments, %d failed." remote.unanchored_appended: "%d finding(s) could not be anchored to a diff line; appended to the review summary." diff --git a/internal/i18n/messages.tr.yml b/internal/i18n/messages.tr.yml index 68f1d55..49771a8 100644 --- a/internal/i18n/messages.tr.yml +++ b/internal/i18n/messages.tr.yml @@ -138,6 +138,7 @@ remote.fetching_pr: "PR %s getiriliyor…" remote.reviewing: "Review çalıştırılıyor…" remote.race_retry: "Review sırasında PR head değişti; bir kez yeniden deneniyor…" remote.posting_comments: "%d satır içi yorum gönderiliyor…" +remote.submitting: "Verdict gönderiliyor…" remote.comment_failed: "⚠ %s için yorum gönderilemedi (%s)" remote.posted_summary: "%d/%d yorum gönderildi, %d başarısız." remote.unanchored_appended: "%d bulgu bir diff satırına sabitlenemedi; review özetine eklendi." From eddf1103f454b6165ad7cb4142e2c16ec0c3021b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 08:00:21 +0300 Subject: [PATCH 5/7] Improve staged-tree progress for 'remote pr', 'compress', 'providers test'. --- CHANGELOG.md | 11 +++++++---- internal/cli/compress.go | 11 ++++++++++- internal/cli/providers.go | 13 ++++++++++++- internal/i18n/messages.en.yml | 1 + internal/i18n/messages.tr.yml | 1 + 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2b751d..ed49cc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,10 +28,13 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v cache is rebuilt once on upgrade because the system prompt changed; the diff component of the cache key is unaffected (the numbered form is a deterministic function of the plain diff). -- **`remote pr` uses the staged-tree progress display.** Pipeline stages - (fetch → review → post → submit) now render through the same animated - tree the local `review` command uses (one line per stage in non-TTY/CI; - suppressed by `--quiet`) instead of flat stderr lines. +- **Staged-tree progress display extended to `remote pr`, `compress`, and + `providers test`.** All long-running/stepped operations now render + through the same animated tree the local `review` command uses (one line + per stage in non-TTY/CI; suppressed by `--quiet`) instead of flat stderr + lines — `remote pr` shows fetch → review → post → submit. The finished + tree stays on screen (it is not cleared) for these commands since no + rich card output replaces it. - **`remote pr` suggestion lines are prefixed with `💡`.** The remediation line in both inline comments and the review-summary fallback now starts with `💡 ` so it reads distinctly from the description. The signature was diff --git a/internal/cli/compress.go b/internal/cli/compress.go index 4138e8a..ec7b83b 100644 --- a/internal/cli/compress.go +++ b/internal/cli/compress.go @@ -63,7 +63,14 @@ func newCompressCmd() *cobra.Command { model = prov.DefaultModel() } - infof("%s", app.Catalog.T("compress.compressing", rulesPath, prov.Name(), model, level)) + // Same staged-tree progress as review / remote pr. compress + // is a single long provider call, so it is one stage; we Close + // (not Clear) to keep the finished stage line on screen above + // the result block, and stop the animation before the stdout + // report and the interactive replace prompt take the terminal. + prog := ui.NewProgress(cmd.ErrOrStderr(), ui.ParseColorMode(global.color), global.quiet) + defer prog.Close() + prog.Start(app.Catalog.T("compress.compressing", rulesPath, prov.Name(), model, level)) start := time.Now() result, err := compress.Run(cmd.Context(), prov, compress.Request{ @@ -72,9 +79,11 @@ func newCompressCmd() *cobra.Command { Model: model, }) if err != nil { + prog.Fail(err) return err } latency := time.Since(start) + prog.Close() percent, deltaTokens := result.Savings() pricing := resolvePricing(app.Config, prov, model) diff --git a/internal/cli/providers.go b/internal/cli/providers.go index 2072772..46f83cf 100644 --- a/internal/cli/providers.go +++ b/internal/cli/providers.go @@ -13,6 +13,7 @@ import ( "github.com/CommitBrief/commitbrief/internal/config" "github.com/CommitBrief/commitbrief/internal/provider" "github.com/CommitBrief/commitbrief/internal/setup" + "github.com/CommitBrief/commitbrief/internal/ui" ) // newProvidersCmd is the `commitbrief providers` subtree. It exposes @@ -162,11 +163,21 @@ func newProvidersTestCmd() *cobra.Command { return errors.New(app.Catalog.T("providers.test.unknown", name, provider.Names())) } pc := app.Config.Providers[name] + // Single network step, shown through the shared staged-tree + // progress (a spinner while the ping is in flight). Close keeps + // the finished stage line above the stdout success summary. + prog := ui.NewProgress(cmd.ErrOrStderr(), ui.ParseColorMode(global.color), global.quiet) + defer prog.Close() + prog.Start(app.Catalog.T("providers.test.pinging", name)) start := time.Now() if err := setup.TestConnection(cmd.Context(), name, pc); err != nil { - return errors.New(app.Catalog.T("providers.test.failed", name, err.Error())) + e := errors.New(app.Catalog.T("providers.test.failed", name, err.Error())) + prog.Fail(e) + return e } elapsed := time.Since(start) + prog.Finish() + prog.Close() if _, err := fmt.Fprintln(cmd.OutOrStdout(), app.Catalog.T("providers.test.success", name, elapsed.Round(time.Millisecond).String())); err != nil { return err } diff --git a/internal/i18n/messages.en.yml b/internal/i18n/messages.en.yml index 1be6ef0..44bb8e9 100644 --- a/internal/i18n/messages.en.yml +++ b/internal/i18n/messages.en.yml @@ -94,6 +94,7 @@ providers.key.not_set: "(not set)" providers.use.success: "Active provider set to %q (written to %s)" providers.use.unknown: "unknown provider %q; known: %v" providers.use.no_key_warning: "ℹ %s has no API key configured; run 'commitbrief setup' before sending a review." +providers.test.pinging: "Testing connection to %s…" providers.test.success: "%s: ok (%s)" providers.test.failed: "%s test failed: %s" providers.test.unknown: "unknown provider %q; known: %v" diff --git a/internal/i18n/messages.tr.yml b/internal/i18n/messages.tr.yml index 49771a8..5019524 100644 --- a/internal/i18n/messages.tr.yml +++ b/internal/i18n/messages.tr.yml @@ -92,6 +92,7 @@ providers.key.not_set: "(ayarlanmadı)" providers.use.success: "Aktif sağlayıcı %q olarak ayarlandı (%s dosyasına yazıldı)" providers.use.unknown: "bilinmeyen sağlayıcı %q; bilinenler: %v" providers.use.no_key_warning: "ℹ %s için API anahtarı yapılandırılmamış; review göndermeden önce 'commitbrief setup' çalıştırın." +providers.test.pinging: "%s bağlantısı test ediliyor…" providers.test.success: "%s: ok (%s)" providers.test.failed: "%s testi başarısız: %s" providers.test.unknown: "bilinmeyen sağlayıcı %q; bilinenler: %v" From ead923e21ae0f404652ecc72b825ae05e8c4a091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 08:02:28 +0300 Subject: [PATCH 6/7] Update startup banner and footer links to Issues page, change license to "GNU GPL v3". --- CHANGELOG.md | 3 +++ internal/logo/logo.go | 7 +++---- internal/logo/logo_test.go | 17 ++++++++++++++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed49cc4..9d5b6a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,9 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v lines — `remote pr` shows fetch → review → post → submit. The finished tree stays on screen (it is not cleared) for these commands since no rich card output replaces it. +- **Startup banner tweaks.** The footer links now point to the repo + **Issues** page (replacing the GitHub link) and drop the Author link; + the license tag reads `GNU GPL v3` instead of `GNU-GPL3.0`. - **`remote pr` suggestion lines are prefixed with `💡`.** The remediation line in both inline comments and the review-summary fallback now starts with `💡 ` so it reads distinctly from the description. The signature was diff --git a/internal/logo/logo.go b/internal/logo/logo.go index 9ee3740..aa3b518 100644 --- a/internal/logo/logo.go +++ b/internal/logo/logo.go @@ -144,15 +144,14 @@ func Print(w io.Writer, version string) { gap := " " word := bold + fg(ink50) + "commitbrief" + reset + " " + - fg(ink300) + version + " © GNU-GPL3.0" + reset + fg(ink300) + version + " © GNU GPL v3" + reset tagline := fg(ink300) + "LLM-driven code review for git diffs" + reset sep := fg(ink300) + " · " + reset links := fg(ink50) + link("https://commitbrief.com", "Home") + reset + sep + fg(ink50) + link("https://commitbrief.com/docs", "Docs") + reset + sep + - fg(ink50) + link("https://github.com/CommitBrief/commitbrief", "GitHub") + reset + sep + - fg(ink50) + link("https://github.com/sponsors/muhammetsafak", "Donation") + reset + sep + - fg(ink50) + link("https://www.muhammetsafak.com.tr", "Author") + reset + fg(ink50) + link("https://github.com/CommitBrief/commitbrief/issues", "Issues") + reset + sep + + fg(ink50) + link("https://github.com/sponsors/muhammetsafak", "Donation") + reset lines[2] += gap + word lines[4] += gap + tagline diff --git a/internal/logo/logo_test.go b/internal/logo/logo_test.go index f3e07f3..3666beb 100644 --- a/internal/logo/logo_test.go +++ b/internal/logo/logo_test.go @@ -23,7 +23,7 @@ func TestPrintEmbedsSuppliedVersion(t *testing.T) { if !strings.Contains(out, "commitbrief") { t.Errorf("output missing wordmark; got first 400 bytes:\n%s", truncate(out, 400)) } - if !strings.Contains(out, "© GNU-GPL3.0") { + if !strings.Contains(out, "© GNU GPL v3") { t.Errorf("output missing license tag; got first 400 bytes:\n%s", truncate(out, 400)) } } @@ -68,6 +68,21 @@ func TestPrintIncludesHomepageHyperlink(t *testing.T) { } } +func TestPrintLinksIssuesNotAuthor(t *testing.T) { + var buf bytes.Buffer + Print(&buf, "dev") + out := buf.String() + if !strings.Contains(out, "https://github.com/CommitBrief/commitbrief/issues") { + t.Errorf("expected the Issues link in the footer") + } + if !strings.Contains(out, "Issues") { + t.Errorf("expected the Issues label in the footer") + } + if strings.Contains(out, "muhammetsafak.com.tr") || strings.Contains(out, "Author") { + t.Errorf("Author link should have been removed from the footer") + } +} + func truncate(s string, n int) string { if len(s) <= n { return s From a43822dcf93d12aed6c4ea107f4bd2e02d18d035 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Fri, 29 May 2026 08:11:22 +0300 Subject: [PATCH 7/7] Add HeaderLine, StatusLine, and FooterLine for consistent review output --- CHANGELOG.md | 7 +++ internal/cli/remote_pr.go | 100 ++++++++++++++++++++++++--------- internal/cli/remote_pr_test.go | 33 +++++++++++ internal/render/cards.go | 14 +++++ internal/render/render_test.go | 24 ++++++++ 5 files changed, 150 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d5b6a4..5fdc472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,13 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v cache is rebuilt once on upgrade because the system prompt changed; the diff component of the cache key is unaffected (the numbered form is a deterministic function of the plain diff). +- **`remote pr` prints the standard review context lines.** The same + header (`commitbrief vX · provider · cache`), `analyzing N files · …` + status line, and `✓ Done in … · N findings · tokens · $cost` footer the + local `review` shows now surround the remote run too, so the + informational lines are consistent across every review command type. + They are exposed as reusable `render.HeaderLine` / `StatusLine` / + `FooterLine` to keep one implementation. - **Staged-tree progress display extended to `remote pr`, `compress`, and `providers test`.** All long-running/stepped operations now render through the same animated tree the local `review` command uses (one line diff --git a/internal/cli/remote_pr.go b/internal/cli/remote_pr.go index df8ff3e..37168b5 100644 --- a/internal/cli/remote_pr.go +++ b/internal/cli/remote_pr.go @@ -5,8 +5,10 @@ package cli import ( "context" "errors" + "fmt" "sort" "strings" + "time" "github.com/spf13/cobra" @@ -103,6 +105,18 @@ func runRemotePR(cmd *cobra.Command, prID string, f remotePRFlags, runner remote if _, plain := prov.(provider.PlainTextEmitter); plain { return errors.New(cat.T("remote.plain_text_provider")) } + model := app.Config.Providers[app.Config.Provider].Model + if model == "" { + model = prov.DefaultModel() + } + + // Standard review header line ("commitbrief vX · provider · cache"), + // printed above the progress tree exactly as the local review shows + // it. remote pr does not consult the local cache, so it reports a + // miss. Honors --quiet. + if !global.quiet { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), render.HeaderLine(render.Meta{Provider: prov.Name(), Model: model})) + } // Same staged-tree progress display the local review uses, so the // remote pipeline reads identically (stages animate on a TTY, degrade @@ -140,39 +154,63 @@ func runRemotePR(cmd *cobra.Command, prID string, f remotePRFlags, runner remote return err } - findings, anchors, oid, err := reviewPRDiff(ctx, runner, prID, f, app, prov, loaded, meta.LastOID(), prog) + res, oid, err := reviewPRDiff(ctx, runner, prID, f, app, prov, model, loaded, meta.LastOID(), prog) if err != nil { prog.Fail(err) return err } - if err := submitPRReview(ctx, runner, prID, f, meta, oid, findings, anchors, threshold, whoami, app, prog); err != nil { + if err := submitPRReview(ctx, runner, prID, f, meta, oid, res.findings, res.anchors, threshold, whoami, app, prog); err != nil { prog.Fail(err) return err } + + // Standard review footer line ("✓ Done in … · N findings · tokens · + // $cost"), printed below the now-finished tree (Close, not Clear, so the + // tree stays on screen). Honors --quiet. + prog.Close() + if !global.quiet { + footerMeta := render.Meta{ + Provider: prov.Name(), + Model: model, + Usage: res.usage, + Cost: resolvePricing(app.Config, prov, model).Cost(res.usage), + Latency: res.latency, + } + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), render.FooterLine(footerMeta, res.findings)) + } return nil } +// prReviewResult bundles everything one PR review produces that the +// caller needs downstream: the findings, the anchor index to place them, +// and the provider usage + latency that feed the terminal footer line. +type prReviewResult struct { + findings []render.Finding + anchors map[string]diff.FileAnchors + usage provider.Usage + latency time.Duration +} + // reviewPRDiff fetches the PR diff, runs one review, and guards against a // race: if the PR head OID changed during the review it retries once, -// then aborts (ADR-0016 §7). Returns the findings, the per-file anchor -// index they map onto, and the OID they were produced against (both used -// to place inline comments). -func reviewPRDiff(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, app *appContext, prov provider.Provider, loaded rules.Loaded, lastOID string, prog *ui.Progress) ([]render.Finding, map[string]diff.FileAnchors, string, error) { +// then aborts (ADR-0016 §7). Returns the review result plus the OID it +// was produced against (used to anchor inline comments). +func reviewPRDiff(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, app *appContext, prov provider.Provider, model string, loaded rules.Loaded, lastOID string, prog *ui.Progress) (prReviewResult, string, error) { for attempt := 0; ; attempt++ { - findings, anchors, err := reviewOnePRDiff(ctx, runner, prID, f, app, prov, loaded, prog) + res, err := reviewOnePRDiff(ctx, runner, prID, f, app, prov, model, loaded, prog) if err != nil { - return nil, nil, "", err + return prReviewResult{}, "", err } newOID, err := remote.FetchLastOID(ctx, runner, prID, f.repo) if err != nil { - return nil, nil, "", err + return prReviewResult{}, "", err } if newOID == lastOID { - return findings, anchors, lastOID, nil + return res, lastOID, nil } if attempt >= 1 { - return nil, nil, "", errors.New(app.Catalog.T("remote.too_volatile")) + return prReviewResult{}, "", errors.New(app.Catalog.T("remote.too_volatile")) } // Head moved: neutralize the just-finished review stage (it was // valid work, just superseded) and note the retry before looping. @@ -185,27 +223,35 @@ func reviewPRDiff(ctx context.Context, runner remote.Runner, prID string, f remo // reviewOnePRDiff runs the structured review pipeline once against the // PR's current diff. Bot-mode: the secret scanner warns but never aborts // (ADR-0016 §3); the local-config guard and cost preflight are skipped. -// Returns the findings plus the anchor index of the (filtered) diff the -// model reviewed, so comments can be pinned to the correct side. -func reviewOnePRDiff(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, app *appContext, prov provider.Provider, loaded rules.Loaded, prog *ui.Progress) ([]render.Finding, map[string]diff.FileAnchors, error) { +func reviewOnePRDiff(ctx context.Context, runner remote.Runner, prID string, f remotePRFlags, app *appContext, prov provider.Provider, model string, loaded rules.Loaded, prog *ui.Progress) (prReviewResult, error) { rawDiff, err := remote.FetchDiff(ctx, runner, prID, f.repo) if err != nil { - return nil, nil, err + return prReviewResult{}, err } parsed, err := diff.Parse(git.Diff{Content: rawDiff, Origin: git.OriginDiff}) if err != nil { - return nil, nil, err + return prReviewResult{}, err } parsed = diff.Filter(parsed, buildMatcher(app.RepoRoot)) if parsed.Empty() { - return []render.Finding{}, map[string]diff.FileAnchors{}, nil + return prReviewResult{findings: []render.Finding{}, anchors: map[string]diff.FileAnchors{}}, nil } diffText := parsed.String() anchors := parsed.Anchors() + // "analyzing N files · X added · Y removed [· COMMITBRIEF.md loaded]" — + // the same status line the local review renders, emitted here as a tree + // info note. It also finishes the active fetch stage (info auto-finishes + // the stage above it), mirroring local review's diff-stats line. + prog.Info(render.StatusLine(render.Meta{ + Files: parsed.FileCount(), + LinesAdded: parsed.AddedLines(), + LinesRemoved: parsed.DeletedLines(), + RulesLoaded: loaded.Source != rules.SourceDefault, + })) + // Emit the secret warning before starting the review stage, so it lands - // as a note under the (now finished) fetch stage rather than prematurely - // terminating the review stage. + // as a note rather than prematurely terminating the review stage. if app.Config.Guard.SecretScan && !global.allowSecrets { if hits := guard.ScanForSecrets(diffText); len(hits) > 0 { prog.Info(app.Catalog.T("remote.secret_warn", len(hits))) @@ -218,28 +264,26 @@ func reviewOnePRDiff(ctx context.Context, runner remote.Runner, prID string, f r // instead of estimating them (see review.go); anchors above are built // from the same parsed diff. p := prompt.Build(loaded, app.Lang, parsed.NumberedString()) - model := app.Config.Providers[app.Config.Provider].Model - if model == "" { - model = prov.DefaultModel() - } req := provider.Request{ Model: model, SystemPrompt: p.System, UserPrompt: p.User, Lang: app.Lang.Code, } - content, _, format, err := tryStructuredReview(ctx, prov, req, func() {}) + start := time.Now() + content, usage, format, err := tryStructuredReview(ctx, prov, req, func() {}) if err != nil { - return nil, nil, err + return prReviewResult{}, err } + latency := time.Since(start) if format != cache.FormatJSON { - return nil, nil, errors.New(app.Catalog.T("remote.degraded")) + return prReviewResult{}, errors.New(app.Catalog.T("remote.degraded")) } findings, err := render.ParseFindings(content) if err != nil { - return nil, nil, errors.New(app.Catalog.T("remote.degraded")) + return prReviewResult{}, errors.New(app.Catalog.T("remote.degraded")) } - return findings, anchors, nil + return prReviewResult{findings: findings, anchors: anchors, usage: usage, latency: latency}, nil } // submitPRReview posts the selected inline comments and the review-level diff --git a/internal/cli/remote_pr_test.go b/internal/cli/remote_pr_test.go index 9748e20..756454e 100644 --- a/internal/cli/remote_pr_test.go +++ b/internal/cli/remote_pr_test.go @@ -3,6 +3,7 @@ package cli import ( + "bytes" "context" "errors" "io" @@ -294,6 +295,38 @@ func TestRemotePRApproveFlowPostsCommentThenApproves(t *testing.T) { } } +func TestRemotePRPrintsHeaderAndFooter(t *testing.T) { + e := newCLIEnv(t) + stubGHOnPath(t) + r := &fakeGH{ + whoami: "tester", + prMeta: prMetaJSON("contributor", "stable"), + commitsSeq: []string{`{"commits":[{"oid":"stable"}]}`}, + diff: sampleDiff, + } + oldWd, _ := os.Getwd() + _ = os.Chdir(e.repoRoot) + t.Cleanup(func() { _ = os.Chdir(oldWd) }) + + var errBuf bytes.Buffer + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + cmd.SetErr(&errBuf) + if err := runRemotePR(cmd, "42", remotePRFlags{requestChangesOn: "critical"}, r); err != nil { + t.Fatalf("remote pr: %v", err) + } + + // Segments are contiguous inside any lipgloss ANSI wrappers, so a raw + // substring search is robust to the color profile. + out := errBuf.String() + // Header (above the tree), status (tree info line), footer (below). + for _, want := range []string{"commitbrief", "provider:", "analyzing 1 file", "Done in"} { + if !strings.Contains(out, want) { + t.Errorf("expected standard review line %q in remote output; got:\n%s", want, out) + } + } +} + func TestRemotePRAbortsOnDoubleRace(t *testing.T) { e := newCLIEnv(t) stubGHOnPath(t) diff --git a/internal/render/cards.go b/internal/render/cards.go index 72262ac..a162582 100644 --- a/internal/render/cards.go +++ b/internal/render/cards.go @@ -182,6 +182,20 @@ const clearEOL = "\x1b[0m\x1b[49m\x1b[K" // off-screen. const cardContentWidth = 96 +// HeaderLine, StatusLine, and FooterLine expose the three context lines +// the Cards renderer wraps around its body, so non-card review surfaces +// (e.g. `remote pr`, whose findings go to GitHub) can print the exact +// same informational lines to the terminal. Keeping one implementation +// guarantees they stay identical across every review command type. +func HeaderLine(m Meta) string { return cardsHeader(m) } + +// StatusLine returns the "analyzing N files · …" line, or "" when no +// diff stats are populated. +func StatusLine(m Meta) string { return cardsStatus(m) } + +// FooterLine returns the "✓ Done in … · … tokens · $cost" line. +func FooterLine(m Meta, findings []Finding) string { return cardsFooter(m, findings) } + // cardsHeader: "commitbrief vX.Y.Z · provider: name/model · cache: hit" // Each segment is colored independently; bullets stay quiet. func cardsHeader(m Meta) string { diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 462ec9b..6243753 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -294,6 +294,30 @@ func TestVerboseFooterOmitsEmptyFields(t *testing.T) { } } +func TestExportedLineWrappers(t *testing.T) { + // HeaderLine / StatusLine / FooterLine must produce the same content + // the Cards renderer embeds, so non-card surfaces (remote pr) print + // identical context lines. + m := Meta{ + Provider: "anthropic", Model: "claude-opus-4-7", + Files: 3, LinesAdded: 42, LinesRemoved: 7, + Usage: provider.Usage{InputTokens: 1000, OutputTokens: 840}, + Cost: 0.0042, + } + if got := stripANSI(HeaderLine(m)); !strings.Contains(got, "commitbrief") || !strings.Contains(got, "anthropic/claude-opus-4-7") { + t.Errorf("HeaderLine missing expected segments; got %q", got) + } + if got := stripANSI(StatusLine(m)); !strings.Contains(got, "3 files") || !strings.Contains(got, "42 added") { + t.Errorf("StatusLine missing expected segments; got %q", got) + } + if got := stripANSI(StatusLine(Meta{})); got != "" { + t.Errorf("StatusLine with no stats should be empty; got %q", got) + } + if got := stripANSI(FooterLine(m, []Finding{{Severity: SeverityHigh}})); !strings.Contains(got, "Done in") || !strings.Contains(got, "1 finding") { + t.Errorf("FooterLine missing expected segments; got %q", got) + } +} + func TestCardsHeader(t *testing.T) { var w bytes.Buffer if err := Cards(&w, samplePayload()); err != nil {