From e64d286b771659c544ed16187c1f2f2d97662c9f Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 04:35:16 +0200 Subject: [PATCH 01/31] Add the co-reviewer dialect registry and per-round CoBots state Step 1+2 of the co-reviewer abstraction (Ref #34): a static registry in dialect (Codex, Cursor Bugbot, Macroscope) with generic co-reviewer event kinds (For/Approved on BotEvent, Codex kinds aliased), Bugbot/Macroscope wording helpers pinned by a live corpus from pingdotgg/t3code, check-run classification (the only place Bugbot reports a clean round), and the Round.CoBots map with Codex dual-write into the legacy per-round fields. --- internal/dialect/bugbot.go | 87 +++++++++ internal/dialect/coreviewer.go | 156 ++++++++++++++++ internal/dialect/event.go | 89 ++++++--- internal/dialect/golden_test.go | 176 ++++++++++++++++++ internal/dialect/macroscope.go | 104 +++++++++++ .../dialect/testdata/bugbot/check-clean.json | 129 +++++++++++++ .../testdata/bugbot/check-in-progress.json | 129 +++++++++++++ .../dialect/testdata/bugbot/check-issues.json | 105 +++++++++++ .../testdata/bugbot/inline-finding-high.md | 17 ++ .../testdata/bugbot/inline-finding-medium.md | 17 ++ .../testdata/bugbot/review-summary-issues.md | 14 ++ .../testdata/bugbot/trigger-command-alt.md | 1 + .../testdata/bugbot/trigger-command.md | 1 + .../macroscope/approvability-approved.md | 8 + .../macroscope/approvability-needs-human.md | 8 + .../check-approvability-approved.json | 94 ++++++++++ .../check-approvability-not-eligible.json | 118 ++++++++++++ .../macroscope/check-correctness-clean.json | 118 ++++++++++++ .../macroscope/check-correctness-issues.json | 94 ++++++++++ .../testdata/macroscope/check-custom.json | 118 ++++++++++++ .../macroscope/inline-finding-high.md | 29 +++ .../macroscope/inline-finding-medium.md | 34 ++++ .../inline-finding-no-longer-relevant.md | 6 + .../macroscope/inline-finding-resolved.md | 6 + .../testdata/macroscope/trigger-command.md | 1 + internal/state/state.go | 112 +++++++++++ internal/state/state_test.go | 94 ++++++++++ 27 files changed, 1842 insertions(+), 23 deletions(-) create mode 100644 internal/dialect/bugbot.go create mode 100644 internal/dialect/coreviewer.go create mode 100644 internal/dialect/macroscope.go create mode 100644 internal/dialect/testdata/bugbot/check-clean.json create mode 100644 internal/dialect/testdata/bugbot/check-in-progress.json create mode 100644 internal/dialect/testdata/bugbot/check-issues.json create mode 100644 internal/dialect/testdata/bugbot/inline-finding-high.md create mode 100644 internal/dialect/testdata/bugbot/inline-finding-medium.md create mode 100644 internal/dialect/testdata/bugbot/review-summary-issues.md create mode 100644 internal/dialect/testdata/bugbot/trigger-command-alt.md create mode 100644 internal/dialect/testdata/bugbot/trigger-command.md create mode 100644 internal/dialect/testdata/macroscope/approvability-approved.md create mode 100644 internal/dialect/testdata/macroscope/approvability-needs-human.md create mode 100644 internal/dialect/testdata/macroscope/check-approvability-approved.json create mode 100644 internal/dialect/testdata/macroscope/check-approvability-not-eligible.json create mode 100644 internal/dialect/testdata/macroscope/check-correctness-clean.json create mode 100644 internal/dialect/testdata/macroscope/check-correctness-issues.json create mode 100644 internal/dialect/testdata/macroscope/check-custom.json create mode 100644 internal/dialect/testdata/macroscope/inline-finding-high.md create mode 100644 internal/dialect/testdata/macroscope/inline-finding-medium.md create mode 100644 internal/dialect/testdata/macroscope/inline-finding-no-longer-relevant.md create mode 100644 internal/dialect/testdata/macroscope/inline-finding-resolved.md create mode 100644 internal/dialect/testdata/macroscope/trigger-command.md diff --git a/internal/dialect/bugbot.go b/internal/dialect/bugbot.go new file mode 100644 index 0000000..d02585b --- /dev/null +++ b/internal/dialect/bugbot.go @@ -0,0 +1,87 @@ +package dialect + +import ( + "regexp" + "strings" +) + +// Cursor Bugbot wording. Verified against the live corpus in +// testdata/bugbot/ (pingdotgg/t3code PRs): it auto-reviews every push, posts a +// COMMENTED review summary only when it FOUND issues, carries each finding as +// an inline comment with a stable BUG_ID, and reports a clean round only via +// its "Cursor Bugbot" check run (app slug "cursor") — never on the timeline. + +// BugbotLogin is the canonical Cursor Bugbot GitHub app login. +const BugbotLogin = "cursor[bot]" + +const bugbotCheckName = "Cursor Bugbot" + +var ( + // bugbotReviewMarkerRE matches the hidden marker Bugbot opens its review + // summary body with. + bugbotReviewMarkerRE = regexp.MustCompile(``) + // bugbotFooterRE matches the "Reviewed by [Cursor Bugbot](…) for commit + // " footer line on inline finding comments. + bugbotFooterRE = regexp.MustCompile(`(?im)^.*Reviewed by \[Cursor Bugbot\]\([^)]*\) for commit ([0-9a-fA-F]{7,40}).*$`) + // bugbotBugIDRE matches the stable per-bug identity marker. + bugbotBugIDRE = regexp.MustCompile(``) +) + +func IsBugbotBot(login string) bool { + return NormalizeBotName(login) == NormalizeBotName(BugbotLogin) +} + +// IsBugbotReviewSummary reports whether a review body is Bugbot's summary +// ("Cursor Bugbot has reviewed your changes … found N potential issues"). +// The summary itself carries ZERO findings — they arrive as inline comments — +// so the review-body finding parsers must never extract from it. +func IsBugbotReviewSummary(body string) bool { + return bugbotReviewMarkerRE.MatchString(body) +} + +// BugbotReviewedCommitSHA extracts the commit hash from the "Reviewed by +// [Cursor Bugbot](…) for commit " footer, or "" when absent. +func BugbotReviewedCommitSHA(text string) string { + match := bugbotFooterRE.FindStringSubmatch(text) + if len(match) == 2 { + return strings.ToLower(match[1]) + } + return "" +} + +// BugbotFindingDedupeKey extracts the BUG_ID marker that keeps a Bugbot +// finding's identity stable when it re-reports the same bug in a new thread +// after a push. +func BugbotFindingDedupeKey(body string) (string, bool) { + match := bugbotBugIDRE.FindStringSubmatch(body) + if len(match) == 2 { + return strings.ToLower(match[1]), true + } + return "", false +} + +// CleanBugbotCommentText strips Bugbot's footer line from a finding body +// (htmlCommentRE in CompactReviewBody already strips the marker comments). +func CleanBugbotCommentText(text string) string { + return strings.TrimSpace(bugbotFooterRE.ReplaceAllString(text, "")) +} + +// ClassifyBugbotCheck classifies one check run owned by the "cursor" app. +// Only the check named "Cursor Bugbot" is its review: clean rounds conclude +// `success` with "no issues found" in the summary, findings rounds conclude +// `neutral` (verified live). Classification never reads the conclusion — +// summary wording decides clean, so a conclusion-vocabulary change degrades to +// CheckDone (findings still gate via threads) instead of misreading clean. +func ClassifyBugbotCheck(name, title, summary, status, conclusion string) CheckVerdict { + if name != bugbotCheckName { + return CheckUnrelated + } + if !strings.EqualFold(status, "completed") { + return CheckInProgress + } + if strings.Contains(NormalizeReviewText(summary), "no issues found") || + strings.Contains(NormalizeReviewText(title), "no issues found") { + return CheckDoneClean + } + return CheckDone +} diff --git a/internal/dialect/coreviewer.go b/internal/dialect/coreviewer.go new file mode 100644 index 0000000..8699bd6 --- /dev/null +++ b/internal/dialect/coreviewer.go @@ -0,0 +1,156 @@ +package dialect + +// A co-reviewer is a review bot that is not the configured primary reviewer +// and does not spend CodeRabbit account quota: Codex, Cursor Bugbot, +// Macroscope. This file is the static registry of the co-reviewers crq knows — +// deliberately not a plugin system. Adding a bot means one entry here, its +// wording helpers in its own file, corpus files, and golden rows; engine/state/ +// crq consume only the registry's structure, never the wording. + +// CheckVerdict classifies one check run for co-reviewer evidence purposes. +type CheckVerdict int + +const ( + CheckUnrelated CheckVerdict = iota // not this bot's review check + CheckInProgress // review still running — evidence it is active, not done + CheckDone // review finished (findings, if any, gate via threads) + CheckDoneClean // review finished and explicitly found nothing +) + +// CoEvent is the classification a co-reviewer gives one of its own issue +// comments: the dominant kind plus the fields specific kinds carry. +type CoEvent struct { + Kind EventKind + SHA string // EvCoClean: reviewed-commit SHA, "" if absent + Approved *bool // EvCoVerdict: approvability verdict +} + +// CoReviewer is one registry entry: the bot's identity plus the wording +// hooks the rest of crq calls without knowing any bot's phrasing. Function +// fields are nil when the bot has no such concept. +type CoReviewer struct { + Login string // GitHub login as REST reports it ("cursor[bot]") + Name string // config key ("bugbot") — CRQ_COBOT__* env vars + AppSlug string // check-run app ownership; "" = bot posts no check runs + Command string // default manual trigger comment; "" = bot has none + // TriggerAliases are alternate command spellings recognized as this bot's + // trigger in addition to the (config-resolved) Command. + TriggerAliases []string + + // ClassifyComment classifies an issue comment authored by this bot. + // Kind EvOther means "no special meaning" (may still be actionable text). + ClassifyComment func(body string) CoEvent + // ClassifyCheck classifies a check run owned by this bot's app. + ClassifyCheck func(name, title, summary, status, conclusion string) CheckVerdict + // ReviewBodyFindings extracts findings only representable in a review body + // (Codex blob-link items); nil for bots that keep findings in threads. + ReviewBodyFindings func(body string, review ReviewMeta) []Finding + // ReviewedCommitSHA extracts the commit the bot says it reviewed from a + // review/comment body ("" when the text names none). + ReviewedCommitSHA func(text string) string + // ResolvedInSHA extracts the commit an edited finding comment says resolved + // it ("" when the body carries no such marker). + ResolvedInSHA func(body string) string + // FindingDedupeKey extracts a bot-stable finding identity (Bugbot BUG_ID) + // so the same bug re-reported in a new thread dedupes to one finding. + FindingDedupeKey func(body string) (string, bool) +} + +// Is reports whether login names this co-reviewer, tolerating the "[bot]" +// suffix difference between REST and GraphQL logins. +func (c CoReviewer) Is(login string) bool { + return NormalizeBotName(login) == NormalizeBotName(c.Login) +} + +// matchesCommand reports whether a trimmed comment body is this bot's trigger +// command: the (config-resolved) Command or one of the registry's aliases. +func (c CoReviewer) matchesCommand(trimmed string) bool { + if c.Command != "" && trimmed == c.Command { + return true + } + for _, alias := range c.TriggerAliases { + if trimmed == alias { + return true + } + } + return false +} + +// KnownCoReviewers returns the registry entries with their default commands. +// Callers that resolve per-bot config (trigger command overrides) mutate their +// own copy — the returned slice is fresh on every call. +func KnownCoReviewers() []CoReviewer { + return []CoReviewer{ + { + Login: CodexBotLogin, + Name: "codex", + Command: "@codex review", + ClassifyComment: func(body string) CoEvent { + switch { + case IsCodexNoActionReviewCompletion(body): + return CoEvent{Kind: EvCoClean, SHA: CodexReviewedCommitSHA(body)} + case IsCodexUsageLimit(body): + // The usage-limit exhaustion notice is distinct from other acks: + // the dynamic completion gate reads it to stop waiting on a Codex + // that cannot finish this round. + return CoEvent{Kind: EvCoUnable} + case IsNonActionableText(body): + return CoEvent{Kind: EvCoNotice} + } + return CoEvent{Kind: EvOther} + }, + ReviewBodyFindings: func(body string, review ReviewMeta) []Finding { + return ParseCodexReviewFindings(body, review, CodexBotLogin) + }, + ReviewedCommitSHA: CodexReviewedCommitSHA, + }, + { + Login: BugbotLogin, + Name: "bugbot", + AppSlug: "cursor", + Command: "bugbot run", + TriggerAliases: []string{"bugbot run", "cursor review"}, + // Bugbot posts no classifiable issue comments: its findings live in + // review threads and its clean verdict only in the check run. + ClassifyComment: func(string) CoEvent { return CoEvent{Kind: EvOther} }, + ClassifyCheck: ClassifyBugbotCheck, + ReviewedCommitSHA: BugbotReviewedCommitSHA, + FindingDedupeKey: BugbotFindingDedupeKey, + }, + { + Login: MacroscopeLogin, + Name: "macroscope", + AppSlug: "macroscopeapp", + Command: "@macroscope-app review", + ClassifyComment: ClassifyMacroscopeComment, + ClassifyCheck: ClassifyMacroscopeCheck, + ResolvedInSHA: MacroscopeResolvedInSHA, + }, + } +} + +// CoReviewerByName resolves a registry entry by config name ("bugbot") or by +// login ("cursor[bot]", "cursor"). +func CoReviewerByName(nameOrLogin string) (CoReviewer, bool) { + for _, c := range KnownCoReviewers() { + if c.Name == nameOrLogin || c.Is(nameOrLogin) { + return c, true + } + } + return CoReviewer{}, false +} + +// ClassifyCheckRun dispatches one check run to the co-reviewer that owns it +// (by app slug) and returns that bot's login with its verdict. Checks owned by +// no known co-reviewer come back ("", CheckUnrelated). +func ClassifyCheckRun(appSlug, name, title, summary, status, conclusion string) (string, CheckVerdict) { + for _, c := range KnownCoReviewers() { + if c.AppSlug == "" || c.AppSlug != appSlug || c.ClassifyCheck == nil { + continue + } + if v := c.ClassifyCheck(name, title, summary, status, conclusion); v != CheckUnrelated { + return c.Login, v + } + } + return "", CheckUnrelated +} diff --git a/internal/dialect/event.go b/internal/dialect/event.go index eb5961e..8aad02a 100644 --- a/internal/dialect/event.go +++ b/internal/dialect/event.go @@ -10,12 +10,18 @@ import ( // over the completion marker it may also contain (a rate-limited reply must // never converge a round), and the already-reviewed ack is only reported when // the body is not itself a rate limit. +// +// Co-reviewer kinds are generic — the bot's identity travels as data +// (BotEvent.For), never as a per-bot kind, so the engine's rules stay +// bot-shape-generic ("participated", "clean at SHA", "cannot finish", "was +// commanded") without enumerating bots. Kind values are in-memory only (never +// persisted), so renumbering between versions is safe. type EventKind int const ( EvOther EventKind = iota EvCommand // the review trigger command, posted by a human/agent - EvCodexCommand // the Codex review trigger command, posted by a human/agent + EvCoCommand // a co-reviewer's trigger command, posted by a human/agent EvCompletion // "Review finished." auto-reply (and not rate-limited) EvRateLimited // CodeRabbit account-quota notice EvPaused // "Reviews paused" auto-pause notice @@ -23,9 +29,19 @@ const ( EvFailed // editable top summary: review failed EvAlreadyReviewed // "does not re-review already reviewed commits" claim EvNoAction // CodeRabbit clean-review summary (no actionable comments) - EvCodexClean // Codex clean-summary issue comment - EvCodexUsageLimit // Codex "usage limits for code reviews" exhaustion notice - EvCodexNotice // other non-actionable Codex notice (acks, lgtm) + EvCoClean // co-reviewer clean-summary issue comment + EvCoUnable // co-reviewer cannot finish this round (Codex usage limits) + EvCoNotice // other non-actionable co-reviewer notice (acks) + EvCoVerdict // Macroscope approvability verdict (informational only) +) + +// Deprecated: Codex-named aliases for the generic co-reviewer kinds, kept +// while engine/crq migrate. In-memory only — never persisted. +const ( + EvCodexCommand = EvCoCommand + EvCodexClean = EvCoClean + EvCodexUsageLimit = EvCoUnable + EvCodexNotice = EvCoNotice ) // BotEvent is one classified issue comment. CreatedAt orders command↔reply @@ -40,7 +56,13 @@ type BotEvent struct { AutoReply bool // body carries the auto-reply (calibration) marker Window *time.Time // EvRateLimited: parsed "available in" deadline Remaining *int // EvRateLimited: parsed remaining reviews - SHA string // EvCodexClean: reviewed-commit sha, "" if absent + SHA string // EvCoClean: reviewed-commit sha, "" if absent + // For is the canonical co-reviewer login the event concerns: the commanded + // bot for EvCoCommand, the authoring bot for a co-reviewer's own comments. + // "" for primary-bot and human events. + For string + // Approved is the EvCoVerdict approvability verdict. + Approved *bool } // PairTime is the timestamp used for command↔reply pairing (CreatedAt, with @@ -63,13 +85,32 @@ func (e BotEvent) ObservedTime() time.Time { } // Classifier classifies issue comments into BotEvents. Bot is the configured -// CodeRabbit login; ReviewCommand is the exact trigger comment body; CodexCommand -// is the exact Codex trigger comment body ("" disables Codex-command matching). +// CodeRabbit login; ReviewCommand is the exact trigger comment body; +// CoReviewers are the enabled co-reviewer entries with their config-resolved +// trigger commands (a nil slice falls back to the CodexCommand shim). type Classifier struct { CodeRabbit CodeRabbit Bot string ReviewCommand string - CodexCommand string + CoReviewers []CoReviewer + // CodexCommand is the exact Codex trigger comment body ("" disables + // Codex-command matching). + // + // Deprecated: shim for callers that predate CoReviewers — when CoReviewers + // is nil, Classify synthesizes the Codex registry entry from this field. + CodexCommand string +} + +// coReviewers resolves the co-reviewer set: the explicit list when set, +// otherwise the legacy Codex-only shim (preserving that Codex comments +// classify even with command matching disabled). +func (c Classifier) coReviewers() []CoReviewer { + if c.CoReviewers != nil { + return c.CoReviewers + } + codex, _ := CoReviewerByName("codex") + codex.Command = strings.TrimSpace(c.CodexCommand) + return []CoReviewer{codex} } // Classify maps one issue comment to its BotEvent. Unrecognized comments @@ -83,22 +124,24 @@ func (c Classifier) Classify(author, body string, id int64, createdAt, updatedAt ev.Kind = EvCommand return ev } - if codexCmd := strings.TrimSpace(c.CodexCommand); codexCmd != "" && trimmed == codexCmd && !IsCodexBot(author) { - ev.Kind = EvCodexCommand - return ev + coReviewers := c.coReviewers() + for _, co := range coReviewers { + if co.matchesCommand(trimmed) && !co.Is(author) { + ev.Kind = EvCoCommand + ev.For = co.Login + return ev + } } - if IsCodexBot(author) { - switch { - case IsCodexNoActionReviewCompletion(body): - ev.Kind = EvCodexClean - ev.SHA = CodexReviewedCommitSHA(body) - case IsCodexUsageLimit(body): - // The usage-limit exhaustion notice is distinct from other Codex acks: - // the dynamic completion gate reads it to stop waiting on a Codex that - // cannot finish this round. - ev.Kind = EvCodexUsageLimit - case IsNonActionableText(body): - ev.Kind = EvCodexNotice + for _, co := range coReviewers { + if !co.Is(author) { + continue + } + ev.For = co.Login + if co.ClassifyComment != nil { + coEv := co.ClassifyComment(body) + ev.Kind = coEv.Kind + ev.SHA = coEv.SHA + ev.Approved = coEv.Approved } return ev } diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index 1fe94e8..7f7c049 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -1,8 +1,10 @@ package dialect import ( + "encoding/json" "os" "path/filepath" + "strings" "testing" "time" ) @@ -225,3 +227,177 @@ func TestGoldenReplyVerdict(t *testing.T) { }) } } + +// TestGoldenCoReviewers pins the Bugbot/Macroscope corpus: comment +// classification through the registry-backed Classifier, the reviewed/resolved +// SHA extractors, the BUG_ID dedupe key, and that carrier/summary bodies parse +// as ZERO review-body findings (their findings live in inline threads). +func TestGoldenCoReviewers(t *testing.T) { + classifier := Classifier{ + CodeRabbit: goldenCR, + Bot: "coderabbitai[bot]", + ReviewCommand: "@coderabbitai review", + CoReviewers: KnownCoReviewers(), + } + base := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) + truth, falsehood := true, false + cases := []struct { + file string + author string + wantKind EventKind + wantFor string + wantApproved *bool // EvCoVerdict only + reviewedSHA string // BugbotReviewedCommitSHA + resolvedSHA string // MacroscopeResolvedInSHA + dedupeKey string // BugbotFindingDedupeKey ("" = none) + bodyFindings int // ParseReviewBodyFindings count as this bot + summaryReview bool // IsBugbotReviewSummary + }{ + { + file: "bugbot/review-summary-issues.md", author: BugbotLogin, + wantKind: EvOther, wantFor: BugbotLogin, summaryReview: true, + reviewedSHA: "2218b91213dd6303e65cf14faea4af55587342e5", + }, + { + file: "bugbot/inline-finding-high.md", author: BugbotLogin, + wantKind: EvOther, wantFor: BugbotLogin, + reviewedSHA: "299d961f670337e6c10d020a489380ddcb69ad1e", + dedupeKey: "c76cc5f6-52df-4e72-8076-e2535882a772", + }, + { + file: "bugbot/inline-finding-medium.md", author: BugbotLogin, + wantKind: EvOther, wantFor: BugbotLogin, + reviewedSHA: "f222834e847b66f8389a9b35e1bd0ce1dbb10ba8", + dedupeKey: "d228c05b-14a4-4184-81ea-44242ad98ce2", + }, + {file: "bugbot/trigger-command.md", author: "kristofferR", wantKind: EvCoCommand, wantFor: BugbotLogin}, + {file: "bugbot/trigger-command-alt.md", author: "kristofferR", wantKind: EvCoCommand, wantFor: BugbotLogin}, + {file: "macroscope/trigger-command.md", author: "kristofferR", wantKind: EvCoCommand, wantFor: MacroscopeLogin}, + { + file: "macroscope/approvability-approved.md", author: MacroscopeLogin, + wantKind: EvCoVerdict, wantFor: MacroscopeLogin, wantApproved: &truth, + }, + { + file: "macroscope/approvability-needs-human.md", author: MacroscopeLogin, + wantKind: EvCoVerdict, wantFor: MacroscopeLogin, wantApproved: &falsehood, + }, + // Open findings carry no MURMUR_IGNORE marker and no resolved line. + {file: "macroscope/inline-finding-high.md", author: MacroscopeLogin, wantKind: EvOther, wantFor: MacroscopeLogin}, + {file: "macroscope/inline-finding-medium.md", author: MacroscopeLogin, wantKind: EvOther, wantFor: MacroscopeLogin}, + // Macroscope EDITS a finding to append its settled marker — the edit IS + // its resolution, in either wording. + { + file: "macroscope/inline-finding-resolved.md", author: MacroscopeLogin, + wantKind: EvCoNotice, wantFor: MacroscopeLogin, + resolvedSHA: "148c355df49cc1692434f4d6689f53666523cadc", + }, + { + file: "macroscope/inline-finding-no-longer-relevant.md", author: MacroscopeLogin, + wantKind: EvCoNotice, wantFor: MacroscopeLogin, + resolvedSHA: "6a06232237270dfc6d1e39af9611ce2ac3349ce5", + }, + } + for _, tc := range cases { + t.Run(tc.file, func(t *testing.T) { + body := readGolden(t, tc.file) + ev := classifier.Classify(tc.author, body, 1, base, base) + if ev.Kind != tc.wantKind { + t.Errorf("Classify kind = %v, want %v", ev.Kind, tc.wantKind) + } + if ev.For != tc.wantFor { + t.Errorf("Classify For = %q, want %q", ev.For, tc.wantFor) + } + if tc.wantApproved != nil && (ev.Approved == nil || *ev.Approved != *tc.wantApproved) { + t.Errorf("Classify Approved = %v, want %v", ev.Approved, *tc.wantApproved) + } + if got := IsBugbotReviewSummary(body); got != tc.summaryReview { + t.Errorf("IsBugbotReviewSummary = %v, want %v", got, tc.summaryReview) + } + if strings.HasPrefix(tc.file, "bugbot/") { + if got := BugbotReviewedCommitSHA(body); got != tc.reviewedSHA { + t.Errorf("BugbotReviewedCommitSHA = %q, want %q", got, tc.reviewedSHA) + } + key, ok := BugbotFindingDedupeKey(body) + if ok != (tc.dedupeKey != "") || key != tc.dedupeKey { + t.Errorf("BugbotFindingDedupeKey = %q,%v, want %q", key, ok, tc.dedupeKey) + } + } + if strings.HasPrefix(tc.file, "macroscope/") { + if got := MacroscopeResolvedInSHA(body); got != tc.resolvedSHA { + t.Errorf("MacroscopeResolvedInSHA = %q, want %q", got, tc.resolvedSHA) + } + } + meta := ReviewMeta{ID: 7, SubmittedAt: base} + if got := ParseReviewBodyFindings(body, meta, tc.author); len(got) != tc.bodyFindings { + t.Errorf("ParseReviewBodyFindings = %d findings, want %d: %#v", len(got), tc.bodyFindings, got) + } + }) + } + + // The Bugbot footer must strip from surfaced finding bodies. + high := readGolden(t, "bugbot/inline-finding-high.md") + if cleaned := CleanBugbotCommentText(high); strings.Contains(cleaned, "Reviewed by [Cursor Bugbot]") { + t.Errorf("CleanBugbotCommentText left the footer: %q", cleaned) + } + // Severity vocabulary maps through the shared SeverityOf. + if got := SeverityOf("**High Severity**"); got != "major" { + t.Errorf("SeverityOf(High) = %q", got) + } + if got := SeverityOf("🟡 **Medium** `a.ts:1`"); got != "potential" { + t.Errorf("SeverityOf(Medium) = %q", got) + } +} + +// TestGoldenCheckRuns pins check-run classification against the captured +// check-run objects — the only place Bugbot reports a clean round, and the +// name-prefix-only rule for Macroscope's custom checks (whose output titles +// can be garbled — check-custom.json's is literally "O"). +func TestGoldenCheckRuns(t *testing.T) { + type run struct { + Name string `json:"name"` + Status string `json:"status"` + Concl string `json:"conclusion"` + App struct { + Slug string `json:"slug"` + } `json:"app"` + Output struct { + Title string `json:"title"` + Summary string `json:"summary"` + } `json:"output"` + } + cases := []struct { + file string + wantLogin string + wantVerdict CheckVerdict + }{ + {"bugbot/check-clean.json", BugbotLogin, CheckDoneClean}, + {"bugbot/check-issues.json", BugbotLogin, CheckDone}, + {"bugbot/check-in-progress.json", BugbotLogin, CheckInProgress}, + {"macroscope/check-correctness-clean.json", MacroscopeLogin, CheckDoneClean}, + {"macroscope/check-correctness-issues.json", MacroscopeLogin, CheckDone}, + // Approvability is informational: its checks never read as clean. + {"macroscope/check-approvability-approved.json", MacroscopeLogin, CheckDone}, + {"macroscope/check-approvability-not-eligible.json", MacroscopeLogin, CheckDone}, + {"macroscope/check-custom.json", MacroscopeLogin, CheckDone}, + } + for _, tc := range cases { + t.Run(tc.file, func(t *testing.T) { + var r run + if err := json.Unmarshal([]byte(readGolden(t, tc.file)), &r); err != nil { + t.Fatal(err) + } + login, verdict := ClassifyCheckRun(r.App.Slug, r.Name, r.Output.Title, r.Output.Summary, r.Status, r.Concl) + if login != tc.wantLogin || verdict != tc.wantVerdict { + t.Errorf("ClassifyCheckRun = %q,%v, want %q,%v", login, verdict, tc.wantLogin, tc.wantVerdict) + } + }) + } + // A check from an unrelated app never binds to a co-reviewer. + if login, verdict := ClassifyCheckRun("github-actions", "CI", "ok", "", "completed", "success"); login != "" || verdict != CheckUnrelated { + t.Errorf("unrelated check classified as %q,%v", login, verdict) + } + // Another cursor-app check that is not the Bugbot review stays unrelated. + if login, verdict := ClassifyCheckRun("cursor", "Cursor Something Else", "", "", "completed", "success"); login != "" || verdict != CheckUnrelated { + t.Errorf("non-review cursor check classified as %q,%v", login, verdict) + } +} diff --git a/internal/dialect/macroscope.go b/internal/dialect/macroscope.go new file mode 100644 index 0000000..a42fb2f --- /dev/null +++ b/internal/dialect/macroscope.go @@ -0,0 +1,104 @@ +package dialect + +import ( + "regexp" + "strings" +) + +// Macroscope wording. Verified against the live corpus in +// testdata/macroscope/ (pingdotgg/t3code PRs): it auto-reviews every push, +// carries findings as inline comments on empty-body COMMENTED reviews, EDITS a +// finding comment to append "✅ Resolved in " when it sees a fix (it never +// resolves threads — the edit IS its resolution), posts a per-round +// "Approvability" verdict issue comment, and reports through check runs (app +// slug "macroscopeapp") of which only the Correctness Check can mean clean. + +// MacroscopeLogin is the canonical Macroscope GitHub app login. +const MacroscopeLogin = "macroscopeapp[bot]" + +const ( + macroscopeCheckPrefix = "Macroscope - " + macroscopeCorrectnessCheck = "Macroscope - Correctness Check" +) + +var ( + // macroscopeIgnoreRE matches the hidden marker Macroscope opens every + // comment with. + macroscopeIgnoreRE = regexp.MustCompile(``) + // macroscopeResolvedRE matches the settled-marker line Macroscope edits + // into a finding comment once it considers it addressed: "✅ Resolved in + // " for a fix, "No longer relevant as of " when the + // code moved on. Both mean the finding is settled. + macroscopeResolvedRE = regexp.MustCompile(`(?i)(?:✅\s*Resolved in|No longer relevant as of)\s*\x60?([0-9a-fA-F]{7,40})\x60?`) + // macroscopeVerdictRE captures the Approvability verdict text. + macroscopeVerdictRE = regexp.MustCompile(`(?i)\*\*Verdict:\*\*\s*([^\n]+)`) +) + +func IsMacroscopeBot(login string) bool { + return NormalizeBotName(login) == NormalizeBotName(MacroscopeLogin) +} + +// IsMacroscopeComment reports whether a body carries Macroscope's hidden +// marker (every comment it posts does). +func IsMacroscopeComment(body string) bool { + return macroscopeIgnoreRE.MatchString(body) +} + +// MacroscopeResolvedInSHA extracts the commit from an edited finding comment's +// "✅ Resolved in " marker, or "" when the finding is still open. +func MacroscopeResolvedInSHA(body string) string { + match := macroscopeResolvedRE.FindStringSubmatch(body) + if len(match) == 2 { + return strings.ToLower(match[1]) + } + return "" +} + +// MacroscopeApproved parses the Approvability verdict: true for "Approved", +// false for "Needs human review" (or any other non-approved verdict), nil when +// the text carries no verdict at all. +func MacroscopeApproved(text string) *bool { + if !strings.Contains(text, "#### Approvability") { + return nil + } + match := macroscopeVerdictRE.FindStringSubmatch(text) + if match == nil { + return nil + } + approved := strings.HasPrefix(strings.ToLower(strings.TrimSpace(match[1])), "approved") + return &approved +} + +// ClassifyMacroscopeComment classifies one of Macroscope's own issue comments: +// the per-round Approvability verdict (EvCoVerdict, informational only — it +// never gates convergence), or any other MURMUR_IGNORE-marked notice +// (EvCoNotice). Unmarked comments stay EvOther. +func ClassifyMacroscopeComment(body string) CoEvent { + if !IsMacroscopeComment(body) { + return CoEvent{Kind: EvOther} + } + if approved := MacroscopeApproved(body); approved != nil { + return CoEvent{Kind: EvCoVerdict, Approved: approved} + } + return CoEvent{Kind: EvCoNotice} +} + +// ClassifyMacroscopeCheck classifies one check run owned by the +// "macroscopeapp" app. Any "Macroscope - " check is Macroscope's (repos +// add custom ones whose output titles can be garbled — classify by name prefix +// only, never title), but only the Correctness Check may read as clean, via +// its "No issues identified" title/summary. +func ClassifyMacroscopeCheck(name, title, summary, status, conclusion string) CheckVerdict { + if !strings.HasPrefix(name, macroscopeCheckPrefix) { + return CheckUnrelated + } + if !strings.EqualFold(status, "completed") { + return CheckInProgress + } + if name == macroscopeCorrectnessCheck && + (strings.Contains(NormalizeReviewText(title), "no issues identified") || + strings.Contains(NormalizeReviewText(summary), "no issues identified")) { + return CheckDoneClean + } + return CheckDone +} diff --git a/internal/dialect/testdata/bugbot/check-clean.json b/internal/dialect/testdata/bugbot/check-clean.json new file mode 100644 index 0000000..45b5e3a --- /dev/null +++ b/internal/dialect/testdata/bugbot/check-clean.json @@ -0,0 +1,129 @@ +{ + "id": 89178126702, + "name": "Cursor Bugbot", + "node_id": "CR_kwDORLtfbc8AAAAUw245bg", + "head_sha": "6be48eb238cf310a60cc9571240601e774c7d381", + "external_id": "", + "url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89178126702", + "html_url": "https://github.com/pingdotgg/t3code/runs/89178126702", + "details_url": "https://cursor.com/docs/bugbot", + "status": "completed", + "conclusion": "success", + "started_at": "2026-07-23T10:15:22Z", + "completed_at": "2026-07-23T10:17:51Z", + "output": { + "title": "Bugbot Review", + "summary": "Bugbot Analysis Progress (2m 36s elapsed)\n\n✅ Gathered PR context (6s)\n✅ Completed bug detection (2m 29s)\n✅ Posted analysis results (1s)\n\n**Final Result:** Bugbot completed review - no issues found! ✅\n\nRequest ID: serverGenReqId_eef87cf4-2995-454b-a7c7-139017f05608", + "text": "", + "annotations_count": 0, + "annotations_url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89178126702/annotations" + }, + "check_suite": { + "id": 81274898144 + }, + "app": { + "id": 1210556, + "client_id": "Iv23liGzp2hacMWjAyXc", + "slug": "cursor", + "node_id": "A_kwHOB44z8s4AEni8", + "owner": { + "login": "cursor", + "id": 126759922, + "node_id": "O_kgDOB44z8g", + "avatar_url": "https://avatars.githubusercontent.com/u/126759922?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/cursor", + "html_url": "https://github.com/cursor", + "followers_url": "https://api.github.com/users/cursor/followers", + "following_url": "https://api.github.com/users/cursor/following{/other_user}", + "gists_url": "https://api.github.com/users/cursor/gists{/gist_id}", + "starred_url": "https://api.github.com/users/cursor/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/cursor/subscriptions", + "organizations_url": "https://api.github.com/users/cursor/orgs", + "repos_url": "https://api.github.com/users/cursor/repos", + "events_url": "https://api.github.com/users/cursor/events{/privacy}", + "received_events_url": "https://api.github.com/users/cursor/received_events", + "type": "Organization", + "user_view_type": "public", + "site_admin": false + }, + "name": "Cursor", + "description": "Connect Cursor to GitHub to allow agents to do work for you in the background.", + "external_url": "https://cursor.com", + "html_url": "https://github.com/apps/cursor", + "created_at": "2025-04-09T22:42:17Z", + "updated_at": "2026-05-19T18:23:59Z", + "permissions": { + "actions": "write", + "administration": "read", + "checks": "write", + "contents": "write", + "deployments": "read", + "discussions": "write", + "emails": "read", + "issues": "write", + "members": "read", + "metadata": "read", + "organization_custom_org_roles": "read", + "organization_custom_properties": "read", + "organization_custom_roles": "read", + "packages": "read", + "pages": "read", + "pull_requests": "write", + "statuses": "read", + "workflows": "write" + }, + "events": [ + "branch_protection_configuration", + "branch_protection_rule", + "check_run", + "check_suite", + "commit_comment", + "deployment", + "deployment_status", + "issue_comment", + "label", + "member", + "membership", + "merge_queue_entry", + "organization", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_review_thread", + "push", + "repository", + "repository_ruleset", + "status", + "team", + "team_add", + "workflow_job", + "workflow_run" + ] + }, + "pull_requests": [ + { + "url": "https://api.github.com/repos/pingdotgg/t3code/pulls/4245", + "id": 4104736375, + "number": 4245, + "head": { + "ref": "t3code/queue-steer-feature", + "sha": "6be48eb238cf310a60cc9571240601e774c7d381", + "repo": { + "id": 1153130349, + "url": "https://api.github.com/repos/pingdotgg/t3code", + "name": "t3code" + } + }, + "base": { + "ref": "main", + "sha": "78a0ea55c1d9edce8bcd2b3caff9510b4093e6d3", + "repo": { + "id": 1153130349, + "url": "https://api.github.com/repos/pingdotgg/t3code", + "name": "t3code" + } + } + } + ] +} diff --git a/internal/dialect/testdata/bugbot/check-in-progress.json b/internal/dialect/testdata/bugbot/check-in-progress.json new file mode 100644 index 0000000..7cfda8a --- /dev/null +++ b/internal/dialect/testdata/bugbot/check-in-progress.json @@ -0,0 +1,129 @@ +{ + "id": 89384497822, + "name": "Cursor Bugbot", + "node_id": "CR_kwDORLtfbc8AAAAUz7syng", + "head_sha": "0e34a71298b8856e03461afb1c3e120a52d8008e", + "external_id": "", + "url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89384497822", + "html_url": "https://github.com/pingdotgg/t3code/runs/89384497822", + "details_url": "https://cursor.com/docs/bugbot", + "status": "in_progress", + "conclusion": null, + "started_at": "2026-07-24T02:28:15Z", + "completed_at": null, + "output": { + "title": "Bugbot Review", + "summary": "Bugbot Analysis Progress (7s elapsed)\n\n✅ Gathered PR context (6s)\n\nRequest ID: serverGenReqId_b273c0c2-9c90-4bde-847f-41fec8747745", + "text": "Reviewing code changes for potential bugs...", + "annotations_count": 0, + "annotations_url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89384497822/annotations" + }, + "check_suite": { + "id": 81458815469 + }, + "app": { + "id": 1210556, + "client_id": "Iv23liGzp2hacMWjAyXc", + "slug": "cursor", + "node_id": "A_kwHOB44z8s4AEni8", + "owner": { + "login": "cursor", + "id": 126759922, + "node_id": "O_kgDOB44z8g", + "avatar_url": "https://avatars.githubusercontent.com/u/126759922?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/cursor", + "html_url": "https://github.com/cursor", + "followers_url": "https://api.github.com/users/cursor/followers", + "following_url": "https://api.github.com/users/cursor/following{/other_user}", + "gists_url": "https://api.github.com/users/cursor/gists{/gist_id}", + "starred_url": "https://api.github.com/users/cursor/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/cursor/subscriptions", + "organizations_url": "https://api.github.com/users/cursor/orgs", + "repos_url": "https://api.github.com/users/cursor/repos", + "events_url": "https://api.github.com/users/cursor/events{/privacy}", + "received_events_url": "https://api.github.com/users/cursor/received_events", + "type": "Organization", + "user_view_type": "public", + "site_admin": false + }, + "name": "Cursor", + "description": "Connect Cursor to GitHub to allow agents to do work for you in the background.", + "external_url": "https://cursor.com", + "html_url": "https://github.com/apps/cursor", + "created_at": "2025-04-09T22:42:17Z", + "updated_at": "2026-05-19T18:23:59Z", + "permissions": { + "actions": "write", + "administration": "read", + "checks": "write", + "contents": "write", + "deployments": "read", + "discussions": "write", + "emails": "read", + "issues": "write", + "members": "read", + "metadata": "read", + "organization_custom_org_roles": "read", + "organization_custom_properties": "read", + "organization_custom_roles": "read", + "packages": "read", + "pages": "read", + "pull_requests": "write", + "statuses": "read", + "workflows": "write" + }, + "events": [ + "branch_protection_configuration", + "branch_protection_rule", + "check_run", + "check_suite", + "commit_comment", + "deployment", + "deployment_status", + "issue_comment", + "label", + "member", + "membership", + "merge_queue_entry", + "organization", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_review_thread", + "push", + "repository", + "repository_ruleset", + "status", + "team", + "team_add", + "workflow_job", + "workflow_run" + ] + }, + "pull_requests": [ + { + "url": "https://api.github.com/repos/pingdotgg/t3code/pulls/4311", + "id": 4112134977, + "number": 4311, + "head": { + "ref": "t3/sidebar-v2-snooze", + "sha": "00e4e6492ab59b2e4921eda1bd458defe0b2be0e", + "repo": { + "id": 1153130349, + "url": "https://api.github.com/repos/pingdotgg/t3code", + "name": "t3code" + } + }, + "base": { + "ref": "main", + "sha": "bb38c3320d0a4eec4e9097fdc400970d881ff559", + "repo": { + "id": 1153130349, + "url": "https://api.github.com/repos/pingdotgg/t3code", + "name": "t3code" + } + } + } + ] +} diff --git a/internal/dialect/testdata/bugbot/check-issues.json b/internal/dialect/testdata/bugbot/check-issues.json new file mode 100644 index 0000000..9f435d7 --- /dev/null +++ b/internal/dialect/testdata/bugbot/check-issues.json @@ -0,0 +1,105 @@ +{ + "id": 89045584498, + "name": "Cursor Bugbot", + "node_id": "CR_kwDORLtfbc8AAAAUu4fKcg", + "head_sha": "7ae04963d6225e1410621364c891eaf5297f0403", + "external_id": "", + "url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89045584498", + "html_url": "https://github.com/pingdotgg/t3code/runs/89045584498", + "details_url": "https://cursor.com/docs/bugbot", + "status": "completed", + "conclusion": "neutral", + "started_at": "2026-07-22T20:41:02Z", + "completed_at": "2026-07-22T20:45:03Z", + "output": { + "title": "Bugbot Review", + "summary": "Bugbot Analysis Progress (4m 5s elapsed)\n\n✅ Gathered PR context (4s)\n✅ Completed bug detection (3m 57s)\n✅ Posted analysis results (4s)\n\n**Final Result:** Bugbot completed review and found 2 potential issues.\n\nRequest ID: serverGenReqId_29e2851f-db43-40ec-9553-f4645fc9e8b8", + "text": "\nCursor Bugbot has reviewed your changes using high effort and found 2 potential issues.\n\n\n\n\n\"Fix\n\n\n\n❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the [Cursor dashboard](https://www.cursor.com/dashboard/bugbot).\n\n\nReviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7ae04963d6225e1410621364c891eaf5297f0403. Configure [here](https://www.cursor.com/dashboard/bugbot).", + "annotations_count": 0, + "annotations_url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89045584498/annotations" + }, + "check_suite": { + "id": 81155539646 + }, + "app": { + "id": 1210556, + "client_id": "Iv23liGzp2hacMWjAyXc", + "slug": "cursor", + "node_id": "A_kwHOB44z8s4AEni8", + "owner": { + "login": "cursor", + "id": 126759922, + "node_id": "O_kgDOB44z8g", + "avatar_url": "https://avatars.githubusercontent.com/u/126759922?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/cursor", + "html_url": "https://github.com/cursor", + "followers_url": "https://api.github.com/users/cursor/followers", + "following_url": "https://api.github.com/users/cursor/following{/other_user}", + "gists_url": "https://api.github.com/users/cursor/gists{/gist_id}", + "starred_url": "https://api.github.com/users/cursor/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/cursor/subscriptions", + "organizations_url": "https://api.github.com/users/cursor/orgs", + "repos_url": "https://api.github.com/users/cursor/repos", + "events_url": "https://api.github.com/users/cursor/events{/privacy}", + "received_events_url": "https://api.github.com/users/cursor/received_events", + "type": "Organization", + "user_view_type": "public", + "site_admin": false + }, + "name": "Cursor", + "description": "Connect Cursor to GitHub to allow agents to do work for you in the background.", + "external_url": "https://cursor.com", + "html_url": "https://github.com/apps/cursor", + "created_at": "2025-04-09T22:42:17Z", + "updated_at": "2026-05-19T18:23:59Z", + "permissions": { + "actions": "write", + "administration": "read", + "checks": "write", + "contents": "write", + "deployments": "read", + "discussions": "write", + "emails": "read", + "issues": "write", + "members": "read", + "metadata": "read", + "organization_custom_org_roles": "read", + "organization_custom_properties": "read", + "organization_custom_roles": "read", + "packages": "read", + "pages": "read", + "pull_requests": "write", + "statuses": "read", + "workflows": "write" + }, + "events": [ + "branch_protection_configuration", + "branch_protection_rule", + "check_run", + "check_suite", + "commit_comment", + "deployment", + "deployment_status", + "issue_comment", + "label", + "member", + "membership", + "merge_queue_entry", + "organization", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_review_thread", + "push", + "repository", + "repository_ruleset", + "status", + "team", + "team_add", + "workflow_job", + "workflow_run" + ] + }, + "pull_requests": [] +} diff --git a/internal/dialect/testdata/bugbot/inline-finding-high.md b/internal/dialect/testdata/bugbot/inline-finding-high.md new file mode 100644 index 0000000..a998fe4 --- /dev/null +++ b/internal/dialect/testdata/bugbot/inline-finding-high.md @@ -0,0 +1,17 @@ +### Queue cap desyncs stores + +**High Severity** + + +The 50-message queue limit is only enforced in the projector's in-memory state. The decider and SQL persistence still accept and store more messages, causing a divergence between the capped read model and the uncapped persisted data. This can lead to auto-drain dispatching incorrect messages and leaves older, undrainable messages in persistence and the UI. + + + + + +
Fix in Cursor Fix in Web
+ + +Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 299d961f670337e6c10d020a489380ddcb69ad1e. Configure [here](https://www.cursor.com/dashboard/bugbot). diff --git a/internal/dialect/testdata/bugbot/inline-finding-medium.md b/internal/dialect/testdata/bugbot/inline-finding-medium.md new file mode 100644 index 0000000..f07857b --- /dev/null +++ b/internal/dialect/testdata/bugbot/inline-finding-medium.md @@ -0,0 +1,17 @@ +### Composer acks unrelated queue changes + +**Medium Severity** + + +While a turn is running, local “Sending” clears when either the latest timeline user message id or the latest queued message id changes. Steering or removing another queued item can satisfy that check before the in-flight send is queued, so the composer stops showing Sending even though that message was never acknowledged. + + + + + +
Fix in Cursor Fix in Web
+ + +Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f222834e847b66f8389a9b35e1bd0ce1dbb10ba8. Configure [here](https://www.cursor.com/dashboard/bugbot). diff --git a/internal/dialect/testdata/bugbot/review-summary-issues.md b/internal/dialect/testdata/bugbot/review-summary-issues.md new file mode 100644 index 0000000..1e5c91b --- /dev/null +++ b/internal/dialect/testdata/bugbot/review-summary-issues.md @@ -0,0 +1,14 @@ + +Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues. + + + + +Fix All in Cursor + + + +❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the [Cursor dashboard](https://www.cursor.com/dashboard/bugbot). + + +Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2218b91213dd6303e65cf14faea4af55587342e5. Configure [here](https://www.cursor.com/dashboard/bugbot). \ No newline at end of file diff --git a/internal/dialect/testdata/bugbot/trigger-command-alt.md b/internal/dialect/testdata/bugbot/trigger-command-alt.md new file mode 100644 index 0000000..6d391e1 --- /dev/null +++ b/internal/dialect/testdata/bugbot/trigger-command-alt.md @@ -0,0 +1 @@ +cursor review \ No newline at end of file diff --git a/internal/dialect/testdata/bugbot/trigger-command.md b/internal/dialect/testdata/bugbot/trigger-command.md new file mode 100644 index 0000000..49fb6dc --- /dev/null +++ b/internal/dialect/testdata/bugbot/trigger-command.md @@ -0,0 +1 @@ +bugbot run \ No newline at end of file diff --git a/internal/dialect/testdata/macroscope/approvability-approved.md b/internal/dialect/testdata/macroscope/approvability-approved.md new file mode 100644 index 0000000..ee34122 --- /dev/null +++ b/internal/dialect/testdata/macroscope/approvability-approved.md @@ -0,0 +1,8 @@ + +#### Approvability + +**Verdict:** Approved + +This is a targeted bug fix for thread settling logic on merged/closed PRs. The change adds an idle guard to prevent threads from immediately re-settling after follow-up activity. The author owns this code, the scope is limited, and comprehensive tests are included. + +You can customize Macroscope's approvability policy. [Learn more](https://docs.macroscope.com/approvability#example-custom-eligibility-rules). \ No newline at end of file diff --git a/internal/dialect/testdata/macroscope/approvability-needs-human.md b/internal/dialect/testdata/macroscope/approvability-needs-human.md new file mode 100644 index 0000000..4d63a3a --- /dev/null +++ b/internal/dialect/testdata/macroscope/approvability-needs-human.md @@ -0,0 +1,8 @@ + +#### Approvability + +**Verdict:** Needs human review + +This PR introduces a substantial new feature (message queueing with steer capability) including a new database table, persistence layer, orchestration commands/events, complex decider logic, provider protocol integration, and new UI components. The scope and complexity warrant human review, and unresolved comments identify potential data integrity and stability issues. + +You can customize Macroscope's approvability policy. [Learn more](https://docs.macroscope.com/approvability#example-custom-eligibility-rules). \ No newline at end of file diff --git a/internal/dialect/testdata/macroscope/check-approvability-approved.json b/internal/dialect/testdata/macroscope/check-approvability-approved.json new file mode 100644 index 0000000..4283da7 --- /dev/null +++ b/internal/dialect/testdata/macroscope/check-approvability-approved.json @@ -0,0 +1,94 @@ +{ + "id": 89319570302, + "name": "Macroscope - Approvability Check", + "node_id": "CR_kwDORLtfbc8AAAAUy9x7fg", + "head_sha": "3cea3f93648344c4a02cc022b357a3f8297321ac", + "external_id": "", + "url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89319570302", + "html_url": "https://github.com/pingdotgg/t3code/runs/89319570302", + "details_url": "https://github.com/pingdotgg/t3code/pull/4309/checks?check_run_id=89319570302", + "status": "completed", + "conclusion": "success", + "started_at": "2026-07-23T20:06:03Z", + "completed_at": "2026-07-23T20:07:32Z", + "output": { + "title": "Approved (click on check for details)", + "summary": "- [x] Eligibility\n- [x] Correctness\n\n> [!NOTE]\n> More information about how approvability works can be found in our [Help Center](https://docs.macroscope.com/approvability).", + "text": "**Eligibility**\nThis is a targeted bug fix for thread settling logic on merged/closed PRs. The change adds an idle guard to prevent threads from immediately re-settling after follow-up activity. The author owns this code, the scope is limited, and comprehensive tests are included.\n\nNotes:\nNo code owners found in target merge base 32c6012", + "annotations_count": 0, + "annotations_url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89319570302/annotations" + }, + "check_suite": { + "id": 81400627244 + }, + "app": { + "id": 900172, + "client_id": "Iv23liK7cN4LHJJrxDDB", + "slug": "macroscopeapp", + "node_id": "A_kwHOCEtgjs4ADbxM", + "owner": { + "login": "prassoai", + "id": 139157646, + "node_id": "O_kgDOCEtgjg", + "avatar_url": "https://avatars.githubusercontent.com/u/139157646?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/prassoai", + "html_url": "https://github.com/prassoai", + "followers_url": "https://api.github.com/users/prassoai/followers", + "following_url": "https://api.github.com/users/prassoai/following{/other_user}", + "gists_url": "https://api.github.com/users/prassoai/gists{/gist_id}", + "starred_url": "https://api.github.com/users/prassoai/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/prassoai/subscriptions", + "organizations_url": "https://api.github.com/users/prassoai/orgs", + "repos_url": "https://api.github.com/users/prassoai/repos", + "events_url": "https://api.github.com/users/prassoai/events{/privacy}", + "received_events_url": "https://api.github.com/users/prassoai/received_events", + "type": "Organization", + "user_view_type": "public", + "site_admin": false + }, + "name": "MacroscopeApp", + "description": "[Macroscope](https://www.macroscope.com/) is an automated changelog for software development teams. It helps teams:\r\n- Stay informed about what’s happening in their code, product and team— all using the codebase as a source of truth.\r\n- Reduce the pain of code reviews by automatically summarizing Pull Requests and taking a first pass at reviewing code changes.", + "external_url": "https://app.macroscope.com", + "html_url": "https://github.com/apps/macroscopeapp", + "created_at": "2024-05-16T18:59:43Z", + "updated_at": "2026-07-16T19:51:56Z", + "permissions": { + "actions": "read", + "checks": "write", + "contents": "write", + "emails": "read", + "issues": "write", + "members": "read", + "metadata": "read", + "pull_requests": "write", + "workflows": "write" + }, + "events": [ + "check_run", + "check_suite", + "commit_comment", + "create", + "delete", + "fork", + "issues", + "issue_comment", + "member", + "membership", + "organization", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_review_thread", + "push", + "release", + "repository", + "sub_issues", + "team", + "team_add", + "workflow_run" + ] + }, + "pull_requests": [] +} diff --git a/internal/dialect/testdata/macroscope/check-approvability-not-eligible.json b/internal/dialect/testdata/macroscope/check-approvability-not-eligible.json new file mode 100644 index 0000000..9077f48 --- /dev/null +++ b/internal/dialect/testdata/macroscope/check-approvability-not-eligible.json @@ -0,0 +1,118 @@ +{ + "id": 89178103160, + "name": "Macroscope - Approvability Check", + "node_id": "CR_kwDORLtfbc8AAAAUw23deA", + "head_sha": "6be48eb238cf310a60cc9571240601e774c7d381", + "external_id": "", + "url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89178103160", + "html_url": "https://github.com/pingdotgg/t3code/runs/89178103160", + "details_url": "https://github.com/pingdotgg/t3code/pull/4245/checks?check_run_id=89178103160", + "status": "completed", + "conclusion": "neutral", + "started_at": "2026-07-23T10:15:15Z", + "completed_at": "2026-07-23T10:26:05Z", + "output": { + "title": "Not eligible (click on check for details)", + "summary": "- [ ] Eligibility\n- [x] Correctness\n\n> [!NOTE]\n> More information about how approvability works can be found in our [Help Center](https://docs.macroscope.com/approvability).", + "text": "**Eligibility**\nThis PR introduces a substantial new feature (message queueing with steer capability) including a new database table, persistence layer, orchestration commands/events, complex decider logic, provider protocol integration, and new UI components. The scope and complexity warrant human review, and unresolved comments identify potential data integrity and stability issues.\n\nNotes:\nNo code owners found in target merge base 9a0a071", + "annotations_count": 0, + "annotations_url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89178103160/annotations" + }, + "check_suite": { + "id": 81274899503 + }, + "app": { + "id": 900172, + "client_id": "Iv23liK7cN4LHJJrxDDB", + "slug": "macroscopeapp", + "node_id": "A_kwHOCEtgjs4ADbxM", + "owner": { + "login": "prassoai", + "id": 139157646, + "node_id": "O_kgDOCEtgjg", + "avatar_url": "https://avatars.githubusercontent.com/u/139157646?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/prassoai", + "html_url": "https://github.com/prassoai", + "followers_url": "https://api.github.com/users/prassoai/followers", + "following_url": "https://api.github.com/users/prassoai/following{/other_user}", + "gists_url": "https://api.github.com/users/prassoai/gists{/gist_id}", + "starred_url": "https://api.github.com/users/prassoai/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/prassoai/subscriptions", + "organizations_url": "https://api.github.com/users/prassoai/orgs", + "repos_url": "https://api.github.com/users/prassoai/repos", + "events_url": "https://api.github.com/users/prassoai/events{/privacy}", + "received_events_url": "https://api.github.com/users/prassoai/received_events", + "type": "Organization", + "user_view_type": "public", + "site_admin": false + }, + "name": "MacroscopeApp", + "description": "[Macroscope](https://www.macroscope.com/) is an automated changelog for software development teams. It helps teams:\r\n- Stay informed about what’s happening in their code, product and team— all using the codebase as a source of truth.\r\n- Reduce the pain of code reviews by automatically summarizing Pull Requests and taking a first pass at reviewing code changes.", + "external_url": "https://app.macroscope.com", + "html_url": "https://github.com/apps/macroscopeapp", + "created_at": "2024-05-16T18:59:43Z", + "updated_at": "2026-07-16T19:51:56Z", + "permissions": { + "actions": "read", + "checks": "write", + "contents": "write", + "emails": "read", + "issues": "write", + "members": "read", + "metadata": "read", + "pull_requests": "write", + "workflows": "write" + }, + "events": [ + "check_run", + "check_suite", + "commit_comment", + "create", + "delete", + "fork", + "issues", + "issue_comment", + "member", + "membership", + "organization", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_review_thread", + "push", + "release", + "repository", + "sub_issues", + "team", + "team_add", + "workflow_run" + ] + }, + "pull_requests": [ + { + "url": "https://api.github.com/repos/pingdotgg/t3code/pulls/4245", + "id": 4104736375, + "number": 4245, + "head": { + "ref": "t3code/queue-steer-feature", + "sha": "6be48eb238cf310a60cc9571240601e774c7d381", + "repo": { + "id": 1153130349, + "url": "https://api.github.com/repos/pingdotgg/t3code", + "name": "t3code" + } + }, + "base": { + "ref": "main", + "sha": "78a0ea55c1d9edce8bcd2b3caff9510b4093e6d3", + "repo": { + "id": 1153130349, + "url": "https://api.github.com/repos/pingdotgg/t3code", + "name": "t3code" + } + } + } + ] +} diff --git a/internal/dialect/testdata/macroscope/check-correctness-clean.json b/internal/dialect/testdata/macroscope/check-correctness-clean.json new file mode 100644 index 0000000..6aa8261 --- /dev/null +++ b/internal/dialect/testdata/macroscope/check-correctness-clean.json @@ -0,0 +1,118 @@ +{ + "id": 89178101424, + "name": "Macroscope - Correctness Check", + "node_id": "CR_kwDORLtfbc8AAAAUw23WsA", + "head_sha": "6be48eb238cf310a60cc9571240601e774c7d381", + "external_id": "", + "url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89178101424", + "html_url": "https://github.com/pingdotgg/t3code/runs/89178101424", + "details_url": "https://github.com/pingdotgg/t3code/pull/4245/checks?check_run_id=89178101424", + "status": "completed", + "conclusion": "success", + "started_at": "2026-07-23T10:15:15Z", + "completed_at": "2026-07-23T10:16:27Z", + "output": { + "title": "No issues identified (3 code objects reviewed).", + "summary": "> • Merge Base: `9a0a071`\n> • Head: `6be48eb`", + "text": "| ✅ | File Path | Comments Posted | Reason |\n|----|-----------|-----------------|--------|\n| ➖ | `apps/server/src/orchestration/decider.queue.test.ts` | | Excluded by default ignore patterns |\n| ✅ | `apps/server/src/orchestration/projector.ts` | 0 | |\n| ✅ | `apps/server/src/orchestration/Layers/ProjectionPipeline.ts` | 0 | |\n| ✅ | `apps/server/src/orchestration/decider.ts` | 0 | |\n| ➖ | `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts` | | No code objects identified |\n", + "annotations_count": 0, + "annotations_url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89178101424/annotations" + }, + "check_suite": { + "id": 81274899503 + }, + "app": { + "id": 900172, + "client_id": "Iv23liK7cN4LHJJrxDDB", + "slug": "macroscopeapp", + "node_id": "A_kwHOCEtgjs4ADbxM", + "owner": { + "login": "prassoai", + "id": 139157646, + "node_id": "O_kgDOCEtgjg", + "avatar_url": "https://avatars.githubusercontent.com/u/139157646?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/prassoai", + "html_url": "https://github.com/prassoai", + "followers_url": "https://api.github.com/users/prassoai/followers", + "following_url": "https://api.github.com/users/prassoai/following{/other_user}", + "gists_url": "https://api.github.com/users/prassoai/gists{/gist_id}", + "starred_url": "https://api.github.com/users/prassoai/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/prassoai/subscriptions", + "organizations_url": "https://api.github.com/users/prassoai/orgs", + "repos_url": "https://api.github.com/users/prassoai/repos", + "events_url": "https://api.github.com/users/prassoai/events{/privacy}", + "received_events_url": "https://api.github.com/users/prassoai/received_events", + "type": "Organization", + "user_view_type": "public", + "site_admin": false + }, + "name": "MacroscopeApp", + "description": "[Macroscope](https://www.macroscope.com/) is an automated changelog for software development teams. It helps teams:\r\n- Stay informed about what’s happening in their code, product and team— all using the codebase as a source of truth.\r\n- Reduce the pain of code reviews by automatically summarizing Pull Requests and taking a first pass at reviewing code changes.", + "external_url": "https://app.macroscope.com", + "html_url": "https://github.com/apps/macroscopeapp", + "created_at": "2024-05-16T18:59:43Z", + "updated_at": "2026-07-16T19:51:56Z", + "permissions": { + "actions": "read", + "checks": "write", + "contents": "write", + "emails": "read", + "issues": "write", + "members": "read", + "metadata": "read", + "pull_requests": "write", + "workflows": "write" + }, + "events": [ + "check_run", + "check_suite", + "commit_comment", + "create", + "delete", + "fork", + "issues", + "issue_comment", + "member", + "membership", + "organization", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_review_thread", + "push", + "release", + "repository", + "sub_issues", + "team", + "team_add", + "workflow_run" + ] + }, + "pull_requests": [ + { + "url": "https://api.github.com/repos/pingdotgg/t3code/pulls/4245", + "id": 4104736375, + "number": 4245, + "head": { + "ref": "t3code/queue-steer-feature", + "sha": "6be48eb238cf310a60cc9571240601e774c7d381", + "repo": { + "id": 1153130349, + "url": "https://api.github.com/repos/pingdotgg/t3code", + "name": "t3code" + } + }, + "base": { + "ref": "main", + "sha": "78a0ea55c1d9edce8bcd2b3caff9510b4093e6d3", + "repo": { + "id": 1153130349, + "url": "https://api.github.com/repos/pingdotgg/t3code", + "name": "t3code" + } + } + } + ] +} diff --git a/internal/dialect/testdata/macroscope/check-correctness-issues.json b/internal/dialect/testdata/macroscope/check-correctness-issues.json new file mode 100644 index 0000000..e91f255 --- /dev/null +++ b/internal/dialect/testdata/macroscope/check-correctness-issues.json @@ -0,0 +1,94 @@ +{ + "id": 89262682959, + "name": "Macroscope - Correctness Check", + "node_id": "CR_kwDORLtfbc8AAAAUyHhzTw", + "head_sha": "af1853b058cc7780f321eb4a57e028533622d00f", + "external_id": "", + "url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89262682959", + "html_url": "https://github.com/pingdotgg/t3code/runs/89262682959", + "details_url": "https://github.com/pingdotgg/t3code/pull/4312/checks?check_run_id=89262682959", + "status": "completed", + "conclusion": "neutral", + "started_at": "2026-07-23T16:07:32Z", + "completed_at": "2026-07-23T16:09:49Z", + "output": { + "title": "1 issue identified (5 code objects reviewed).", + "summary": "> • Merge Base: `3afb4a9`\n> • Head: `af1853b`", + "text": "| ✅ | File Path | Comments Posted | Reason |\n|----|-----------|-----------------|--------|\n| ✅ | `apps/web/src/components/CommandPalette.logic.ts` | 0 | |\n| ✅ | `apps/web/src/routes/_chat.tsx` | 0 | |\n| ❌ | `apps/web/src/components/SidebarV2.tsx` | 1 | |\n| ✅ | `apps/web/src/components/chat/DraftHeroHeadline.tsx` | 0 | |\n| ➖ | `apps/web/src/components/CommandPalette.tsx` | | Code objects exceed size limit; Not a review target |\n", + "annotations_count": 0, + "annotations_url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89262682959/annotations" + }, + "check_suite": { + "id": 81349688311 + }, + "app": { + "id": 900172, + "client_id": "Iv23liK7cN4LHJJrxDDB", + "slug": "macroscopeapp", + "node_id": "A_kwHOCEtgjs4ADbxM", + "owner": { + "login": "prassoai", + "id": 139157646, + "node_id": "O_kgDOCEtgjg", + "avatar_url": "https://avatars.githubusercontent.com/u/139157646?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/prassoai", + "html_url": "https://github.com/prassoai", + "followers_url": "https://api.github.com/users/prassoai/followers", + "following_url": "https://api.github.com/users/prassoai/following{/other_user}", + "gists_url": "https://api.github.com/users/prassoai/gists{/gist_id}", + "starred_url": "https://api.github.com/users/prassoai/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/prassoai/subscriptions", + "organizations_url": "https://api.github.com/users/prassoai/orgs", + "repos_url": "https://api.github.com/users/prassoai/repos", + "events_url": "https://api.github.com/users/prassoai/events{/privacy}", + "received_events_url": "https://api.github.com/users/prassoai/received_events", + "type": "Organization", + "user_view_type": "public", + "site_admin": false + }, + "name": "MacroscopeApp", + "description": "[Macroscope](https://www.macroscope.com/) is an automated changelog for software development teams. It helps teams:\r\n- Stay informed about what’s happening in their code, product and team— all using the codebase as a source of truth.\r\n- Reduce the pain of code reviews by automatically summarizing Pull Requests and taking a first pass at reviewing code changes.", + "external_url": "https://app.macroscope.com", + "html_url": "https://github.com/apps/macroscopeapp", + "created_at": "2024-05-16T18:59:43Z", + "updated_at": "2026-07-16T19:51:56Z", + "permissions": { + "actions": "read", + "checks": "write", + "contents": "write", + "emails": "read", + "issues": "write", + "members": "read", + "metadata": "read", + "pull_requests": "write", + "workflows": "write" + }, + "events": [ + "check_run", + "check_suite", + "commit_comment", + "create", + "delete", + "fork", + "issues", + "issue_comment", + "member", + "membership", + "organization", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_review_thread", + "push", + "release", + "repository", + "sub_issues", + "team", + "team_add", + "workflow_run" + ] + }, + "pull_requests": [] +} diff --git a/internal/dialect/testdata/macroscope/check-custom.json b/internal/dialect/testdata/macroscope/check-custom.json new file mode 100644 index 0000000..92d40b9 --- /dev/null +++ b/internal/dialect/testdata/macroscope/check-custom.json @@ -0,0 +1,118 @@ +{ + "id": 89353211326, + "name": "Macroscope - Effect Service Conventions", + "node_id": "CR_kwDORLtfbc8AAAAUzd3Nvg", + "head_sha": "9daa3fd789930a979c8a28c1206de4faec5ddca2", + "external_id": "", + "url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89353211326", + "html_url": "https://github.com/pingdotgg/t3code/runs/89353211326", + "details_url": "https://github.com/pingdotgg/t3code/pull/4321/checks?check_run_id=89353211326", + "status": "completed", + "conclusion": "success", + "started_at": "2026-07-23T22:48:02Z", + "completed_at": "2026-07-23T22:52:32Z", + "output": { + "title": "O", + "summary": "asactinformation details. I'now confident there PR conforms to Effect service conventions, and the prior finding (missing `cause`) has been addressed—the error class now carries `cause` and the handlers propagate it.**All clear**- New error class`AssetBrowserArtifactNotFoundError` and `PreviewAutomationScreenshotSaveError`: defined via `Schema.TaggedErrorClass` with structured attributes, messages derived from those attributes, constructed at the failure boundary.- The prior-run concern (no `cause` on `PreviewAutomationScreenshotSaveError`) is resolved: `cause: Schema.optional(Schema.Defect())` is present and every `mapError` that wraps a genuine underlying failure now passes `cause`, while pure validation failures correctly omit it.- Imports: Effect modules and local service modules use subpath namespace imports (`effect/FileSystem`, `effect/Path`, `../config.ts`, `WorkspacePaths`), with named imports retained appropriately for `@t3tools/contracts` and `effect/unstable/ai`.- Dependency acquisition: snapshot handlers obtain services via `yield* Foo.Foo` and declare them in `snapshotDependencies`; no `ManagedRuntime`/`runPromise` in domain code, and the test-only `Layer.succeed(ProjectionSnapshotQuery, …)` is a legitimate test seam.\n\nNo new convention violations in the changed scope.", + "text": "> [!NOTE]\n> Your check run agent prompt is: [`.macroscope/check-run-agents/effect-service-conventions.md`](https://github.com/pingdotgg/t3code/blob/main/.macroscope/check-run-agents/effect-service-conventions.md)\n> More information about how Check Run Agents work can be found in our [Help Center](https://docs.macroscope.com/check-run-agents).\n\n\n\n---\n\n
\n🔍 Agent activity — reasoning, no tool calls\n\n> 💭 I'm reviewing this PR against Effect service conventions, specifically checking whether the `cause` field issue that was flagged on `PreviewAutomationScreenshotSaveError` has been addressed in this diff. Looking at the updated error definition, it appears the field is now properly included with the optional Defect schema. Now I'm examining the broader code for convention violations, starting with how Effect modules are imported—they're correctly using namespace imports from subpaths. I'm also checking whether the error definitions are following the convention about not encoding the same distinction twice with both a specific error tag and a single-value `operation` field. Looking at the `reason` field more closely, it's being used to construct the user-facing error message, but it's a free-form string rather than a discriminator that chooses between distinct error classes. This is borderline—the convention suggests splitting semantically distinct failures into separate error classes when a discriminator drives control flow or message selection, but here the reason is more diagnostic in nature alongside the structured `savePath` field. Let me focus on the actual violations and examine the `PreviewAutomationScopeErrorFields` to see if `operation` is a redundant single-value discriminator. I need to adjust the URL formatting. I need to adjust the URL format to use the base repository path instead of the tree structure. I'm noticing a formatting issue with the parameter—the JSON might be getting corrupted somehow. Let me clean this up and try a fresh approach. Looking at the error contracts to understand how `operation` functions as a discriminator and checking what `PreviewAutomationScopeErrorFields` contains. I'm checking the `PreviewAutomationScopeErrorFields` definition to understand how the `operation` field works. Since `PreviewAutomationScreenshotSaveError` always sets `operation: \"snapshot\"`, it's functioning as a single-value discriminator, which violates the convention against encoding the same distinction twice with both a specific error tag and a single-value field like `operation`. However, looking more closely, `operation` is a shared field from `PreviewAutomationScopeErrorFields` that's multi-valued across the broader union of errors—it's not a redundant discriminator introduced just for this error. The message doesn't even reference `operation`, so this seems acceptable. The more pressing issue is the `reason` field, which contains semantically distinct failure messages like \"savePath must end with .png\", \"thread was not found\", \"savePath must be a relative path inside the workspace\", and several others about directory and file operations. These represent fundamentally different failure modes. The problem is that `reason` is a free-form string rather than a structured discriminator, and the message just concatenates it directly: `Failed to save... : ${this.reason}`. This violates the convention about storing unstructured messages as the only data—the failures should be split into separate error classes with structured attributes instead. Since this error class is entirely new in this PR, it's worth reconsidering whether these distinct failures should be broken out into their own error types. But I should also check whether the `fail` helper itself is problematic. The convention warns against helpers that just wrap error construction with `(...args) => new SomeError({ ...args })`, since errors should be constructed at the failure boundary rather than through a generic factory. However, this `fail` helper does add meaningful context — it captures the scope fields and constructs the error with them, avoiding repetition throughout the function. That's a legitimate use case for keeping a helper. The convention explicitly allows mappers that add reusable context or control flow, so this should be acceptable. Now I need to decide whether the `reason` field pattern is actually a violation worth flagging. The convention discourages unstructured message text, but here `reason` captures distinct error scenarios — validation failures, missing threads, boundary violations — so it's not just a generic catch-all message. The `reason` field is really just a sub-message layered on top of structured data like `savePath` and `operation`, so it's not quite the unstructured-message violation the convention warns against. This feels borderline as a finding—plausible but not ironclad. Let me dig into other services to see if there are clearer examples of the pattern violations. The `reason` field does drive the user-facing message through interpolation, but it's a free-form string rather than a set of literal discriminators that select among templated messages. That feels different from the convention's intent, which seems aimed at structured discriminators. Yet storing a free-form reason as the only data also conflicts with deriving messages from structured attributes. I'm weighing whether this actually violates the convention or if free-form diagnostic strings are acceptable here. Looking at other errors in the file, they use `operation` with multiple values plus templated messages, along with optional structured fields like `selectorKind` or `maximumBytes`. Nothing else uses a free-form `reason: Schema.String` to drive the message. So this PR introduces a pattern inconsistent with the file's conventions — collapsing distinct failure modes (validation versus IO failures) into one error class with a free-text reason. By the convention, these semantically different failures should either be separate error classes or use a structured discriminator instead. I need to decide if this is worth flagging. The instructions caution against failing on optional style preferences or unrelated legacy code, and to report only concrete violations in the PR's changed scope. The reason-string-as-message is discouraged by the conventions, but I should reconsider whether it's a clear enough violation to warrant failing the check. Looking at the specific bullets: storing an unstructured reason string as the primary message content is a moderate violation of the structured error design pattern. More importantly, wrapping an entire multi-step pipeline in a single generic error with a reason discriminator violates the principle of capturing stable domain context — the pipeline has distinct stages that should be modeled as separate errors instead. The convention suggests using a stage enum like \"validate-extension\" | \"resolve-thread\" | \"resolve-path\" | \"write\" rather than a free-form reason string, or alternatively defining separate error classes for each stage. This is a defensible finding, so I'll post an inline comment and fail the check, though I want to be careful not to over-flag this. The validation errors and IO failures are fundamentally different in nature—the former are pure domain violations while the latter wrap underlying causes—so mixing them into a single class with a free-form reason string goes against the convention's guidance about mapping known failures rather than bundling multi-stage pipelines into generic errors. This is a distinct issue from the missing `cause` that was flagged before, so it's worth calling out as a separate finding. But I should reconsider: the prior review accepted the reason-based model and only asked for `cause` to be added, which suggests the reviewer implicitly endorsed that design. That's a signal I shouldn't now flag the reason field itself—I'd be second-guessing a decision that was already made. Given that inconsistency would be problematic, I'm going to skip flagging the reason design and focus on verifying the rest of the diff. Let me examine the imports in McpHttpServer.ts and tools.ts, along with the snapshot dependencies. The imports follow standard conventions with namespace imports for services and named imports for the contracts and effect modules. I'm checking the new `AssetBrowserArtifactNotFoundError` class that's been introduced—it's properly structured with a `resource` attribute of type `AssetResource` and derives its message accordingly. Now I'm looking at how this error is being used in AssetAccess.ts. The browser-artifact case correctly constructs the error with the resource at the failure boundary. The helper functions like `statIsFile` and `normalizeBrowserArtifactFileName` are pure utilities, which is fine. Looking at the overall assessment, I've already addressed the `cause` propagation issue in resolveAsset and handlers. Now I need to verify whether `PreviewAutomationScreenshotSaveError` is correctly handling the `operation` field—it's set to \"snapshot\" which looks fixed, but I should double-check that `operation` comes from the shared `PreviewAutomationScopeErrorFields` type to ensure consistency. The `operation` field is a shared structural element across errors that's consistently set to \"snapshot\" here, and since the error message doesn't reference it, there's no violation of the convention. Looking at the service changes overall, the new `AssetBrowserArtifactNotFoundError` follows the pattern of being properly structured with a derived message and constructed at the boundary. The `PreviewAutomationScreenshotSaveError` includes a cause field and a reason string, with the operation as a shared field — the reason design is borderline but was implicitly accepted in the prior review. All imports are correctly namespaced, and the service consumption pattern using `yield* Foo.Foo` for dependency acquisition is sound. The context pattern with `Effect.context` and `Effect.provideContext` is unconventional but doesn't violate service injection rules. The test file's use of `Layer.succeed` as a stub for `ProjectionSnapshotQuery` is a legitimate testing seam. Now I'm reconsidering whether the `reason` field warrants a violation flag based on the convention's explicit guidance about splitting semantically distinct failures into separate error classes when a discriminator like `reason` is used to choose the user-facing message or drive control flow. The `reason` here is interpolated into the user-facing message, which matches the pattern, but it's a free-form string rather than an enum discriminator — arguably worse than a structured discriminator since it's unstructured. However, the prior review examined this error class and only flagged the missing `cause` (now fixed), treating `reason` and `savePath` as acceptable structural fields. Re-flagging `reason` would contradict that implicit acceptance and risk over-flagging a nuanced style point, so I'll treat the error modeling as acceptable now. Let me verify the `cause` is genuinely handled correctly — checking the `saveSnapshotScreenshot` function, all the `mapError` calls now pass `cause` at lines 129, 140, 151, 155, and 164, while pure validation failures (123, 131, 158) correctly don't pass `cause` since there's no underlying error. The `saveSnapshotScreenshotArtifact` function also passes `cause` properly, so the prior comment is fully addressed and there are no new violations. Now I need to reconcile the output format — the instructions say to respond with \"All clear\" when there are no findings, but the schema requires JSON with title and summary fields. I'll encode the clean result in the JSON structure with appropriate fields indicating no findings. Let me verify I haven't missed anything in the touched files. The MarkdownMedia component is React code, not an Effect service, so it's outside the scope of this check. I should also confirm whether the handlers and tools are using consolidated imports from \"effect\" correctly. I'm confident there are no new violations here. This is all clear.\n\n
\n\n---\n\n**Agent Credits:** 81 credits\n", + "annotations_count": 0, + "annotations_url": "https://api.github.com/repos/pingdotgg/t3code/check-runs/89353211326/annotations" + }, + "check_suite": { + "id": 81430684544 + }, + "app": { + "id": 900172, + "client_id": "Iv23liK7cN4LHJJrxDDB", + "slug": "macroscopeapp", + "node_id": "A_kwHOCEtgjs4ADbxM", + "owner": { + "login": "prassoai", + "id": 139157646, + "node_id": "O_kgDOCEtgjg", + "avatar_url": "https://avatars.githubusercontent.com/u/139157646?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/prassoai", + "html_url": "https://github.com/prassoai", + "followers_url": "https://api.github.com/users/prassoai/followers", + "following_url": "https://api.github.com/users/prassoai/following{/other_user}", + "gists_url": "https://api.github.com/users/prassoai/gists{/gist_id}", + "starred_url": "https://api.github.com/users/prassoai/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/prassoai/subscriptions", + "organizations_url": "https://api.github.com/users/prassoai/orgs", + "repos_url": "https://api.github.com/users/prassoai/repos", + "events_url": "https://api.github.com/users/prassoai/events{/privacy}", + "received_events_url": "https://api.github.com/users/prassoai/received_events", + "type": "Organization", + "user_view_type": "public", + "site_admin": false + }, + "name": "MacroscopeApp", + "description": "[Macroscope](https://www.macroscope.com/) is an automated changelog for software development teams. It helps teams:\r\n- Stay informed about what’s happening in their code, product and team— all using the codebase as a source of truth.\r\n- Reduce the pain of code reviews by automatically summarizing Pull Requests and taking a first pass at reviewing code changes.", + "external_url": "https://app.macroscope.com", + "html_url": "https://github.com/apps/macroscopeapp", + "created_at": "2024-05-16T18:59:43Z", + "updated_at": "2026-07-16T19:51:56Z", + "permissions": { + "actions": "read", + "checks": "write", + "contents": "write", + "emails": "read", + "issues": "write", + "members": "read", + "metadata": "read", + "pull_requests": "write", + "workflows": "write" + }, + "events": [ + "check_run", + "check_suite", + "commit_comment", + "create", + "delete", + "fork", + "issues", + "issue_comment", + "member", + "membership", + "organization", + "public", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "pull_request_review_thread", + "push", + "release", + "repository", + "sub_issues", + "team", + "team_add", + "workflow_run" + ] + }, + "pull_requests": [ + { + "url": "https://api.github.com/repos/pingdotgg/t3code/pulls/4321", + "id": 4112530883, + "number": 4321, + "head": { + "ref": "t3code/render-chat-media", + "sha": "a035fa093978ad0f157f0ddccecfbfc00b088cf2", + "repo": { + "id": 1153130349, + "url": "https://api.github.com/repos/pingdotgg/t3code", + "name": "t3code" + } + }, + "base": { + "ref": "main", + "sha": "2d31cb022dee43e5a729273a6936228f30077e29", + "repo": { + "id": 1153130349, + "url": "https://api.github.com/repos/pingdotgg/t3code", + "name": "t3code" + } + } + } + ] +} diff --git a/internal/dialect/testdata/macroscope/inline-finding-high.md b/internal/dialect/testdata/macroscope/inline-finding-high.md new file mode 100644 index 0000000..5ebb265 --- /dev/null +++ b/internal/dialect/testdata/macroscope/inline-finding-high.md @@ -0,0 +1,29 @@ +🟠 **High** `preview/handlers.ts:57` + +`writeScreenshotFile` writes directly to `absolutePath` after `WorkspacePaths.resolveRelativePathWithinRoot` validates it, but that validation only does lexical path resolution and does not resolve symlinks. If `/out` is a symlink to `/tmp`, saving to `out/victim.png` causes `writeScreenshotFile` to overwrite `/tmp/victim.png` — a file outside the workspace. The containment check needs to resolve symlinks (e.g., via `realpath` on the destination or nearest existing parent) or use an open strategy that refuses to follow symlinks. + +
+Also found in 1 other location(s) + +`apps/server/src/assets/AssetAccess.ts:453` + +> The `browser-artifact` resolver validates only the lexical basename and then uses `statIsFile` on `path.join(config.browserArtifactsDir, artifactFileName)`. Because `stat` and the subsequent file response follow symbolic links, a symlink such as `browser-artifacts/leak.png -> ../state.sqlite` is accepted and served by a valid signed artifact token, exposing arbitrary files readable by the server. Canonicalize both the artifacts directory and candidate and verify the candidate remains inside the directory (as the workspace-file path does), or reject symlinks. + + +
+ + + +
+🚀 Reply "fix it for me" or copy this AI Prompt for your agent: + +```text +In file @apps/server/src/mcp/toolkits/preview/handlers.ts around line 57: + +`writeScreenshotFile` writes directly to `absolutePath` after `WorkspacePaths.resolveRelativePathWithinRoot` validates it, but that validation only does lexical path resolution and does not resolve symlinks. If `/out` is a symlink to `/tmp`, saving to `out/victim.png` causes `writeScreenshotFile` to overwrite `/tmp/victim.png` — a file outside the workspace. The containment check needs to resolve symlinks (e.g., via `realpath` on the destination or nearest existing parent) or use an open strategy that refuses to follow symlinks. + +Also found in 1 other location(s): +- apps/server/src/assets/AssetAccess.ts:453 -- The `browser-artifact` resolver validates only the lexical basename and then uses `statIsFile` on `path.join(config.browserArtifactsDir, artifactFileName)`. Because `stat` and the subsequent file response follow symbolic links, a symlink such as `browser-artifacts/leak.png -> ../state.sqlite` is accepted and served by a valid signed artifact token, exposing arbitrary files readable by the server. Canonicalize both the artifacts directory and candidate and verify the candidate remains inside the directory (as the workspace-file path does), or reject symlinks. + +``` +
\ No newline at end of file diff --git a/internal/dialect/testdata/macroscope/inline-finding-medium.md b/internal/dialect/testdata/macroscope/inline-finding-medium.md new file mode 100644 index 0000000..9196a5d --- /dev/null +++ b/internal/dialect/testdata/macroscope/inline-finding-medium.md @@ -0,0 +1,34 @@ +🟡 **Medium** `chat/MarkdownMedia.tsx:10` + +`DIRECT_MEDIA_SRC_PATTERN` omits protocol-relative URLs, so a markdown image source like `//cdn.example.com/image.png` is misclassified as a workspace path instead of being loaded directly by the browser. The code then requests a signed workspace asset for a non-existent local file, the lookup fails, and the image renders as "Media unavailable" instead of displaying the remote image. Consider adding `//` to the pattern so protocol-relative URLs are treated as direct media sources. + +```suggestion ++/** Sources the browser can load directly without a signed workspace asset URL. */ ++const DIRECT_MEDIA_SRC_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; +``` + +
+Also found in 1 other location(s) + +`apps/web/src/components/ChatMarkdown.tsx:1498` + +> The new `img`/`video` renderers route every source through `MarkdownMedia`, whose direct-media check only recognizes strings beginning with `http:`, `https:`, `data:`, or `blob:`. Valid protocol-relative external media URLs such as `![image](//cdn.example.com/a.png)` are therefore misclassified as workspace files (or immediately shown as unavailable when `threadRef` is absent), so media that previously rendered normally no longer loads. + + +
+ + + +
+🚀 Reply "fix it for me" or copy this AI Prompt for your agent: + +```text +In file @apps/web/src/components/chat/MarkdownMedia.tsx around line 10: + +`DIRECT_MEDIA_SRC_PATTERN` omits protocol-relative URLs, so a markdown image source like `//cdn.example.com/image.png` is misclassified as a workspace path instead of being loaded directly by the browser. The code then requests a signed workspace asset for a non-existent local file, the lookup fails, and the image renders as "Media unavailable" instead of displaying the remote image. Consider adding `//` to the pattern so protocol-relative URLs are treated as direct media sources. + +Also found in 1 other location(s): +- apps/web/src/components/ChatMarkdown.tsx:1498 -- The new `img`/`video` renderers route every source through `MarkdownMedia`, whose direct-media check only recognizes strings beginning with `http:`, `https:`, `data:`, or `blob:`. Valid protocol-relative external media URLs such as `![image](//cdn.example.com/a.png)` are therefore misclassified as workspace files (or immediately shown as unavailable when `threadRef` is absent), so media that previously rendered normally no longer loads. + +``` +
\ No newline at end of file diff --git a/internal/dialect/testdata/macroscope/inline-finding-no-longer-relevant.md b/internal/dialect/testdata/macroscope/inline-finding-no-longer-relevant.md new file mode 100644 index 0000000..a019ee8 --- /dev/null +++ b/internal/dialect/testdata/macroscope/inline-finding-no-longer-relevant.md @@ -0,0 +1,6 @@ + +🟡 **Medium** `home/homeThreadList.ts:105` + +`buildHomeProjectScopes` picks `projects[0]` as the representative, so when a grouped project has no thread or pending activity, `sortHomeProjectScopes` sorts it by whichever member happened to appear first in the input array rather than by the newest `createdAt`/`updatedAt` across all members. A repo grouped across two machines where the older one is listed first will always sort below a peer whose representative is newer, even if the repo's other member was updated more recently. Consider selecting the member with the freshest `createdAt`/`updatedAt` timestamp (per `projectSortOrder`) as the representative, or have `sortHomeProjectScopes` compute its project-level fallback across all `scope.projects`. + +No longer relevant as of 6a06232237270dfc6d1e39af9611ce2ac3349ce5 \ No newline at end of file diff --git a/internal/dialect/testdata/macroscope/inline-finding-resolved.md b/internal/dialect/testdata/macroscope/inline-finding-resolved.md new file mode 100644 index 0000000..e6ddd94 --- /dev/null +++ b/internal/dialect/testdata/macroscope/inline-finding-resolved.md @@ -0,0 +1,6 @@ + +🟠 **High** `Layers/ProjectionPipeline.ts:1554` + +During bootstrap, the `threadMessages` projector runs before the `queuedMessages` projector. When the event stream contains a queued message followed by `thread.reverted`, the `threadMessages` projector's revert handler queries `projectionQueuedMessageRepository.listByThreadId` — but that projection table is still empty because `queuedMessages` hasn't replayed yet. As a result, `collectThreadAttachmentRelativePaths` omits queued-message attachments from the retained set, and the post-transaction prune deletes their files. The queued projector then restores only the database rows, leaving queued messages pointing at attachment files that are permanently missing after a restart or rebuild. Reorder the `projectors` array so `queuedMessages` runs before `threadMessages`, or derive retained queued attachments without depending on another projector's bootstrap state. + +✅ Resolved in 148c355df49cc1692434f4d6689f53666523cadc \ No newline at end of file diff --git a/internal/dialect/testdata/macroscope/trigger-command.md b/internal/dialect/testdata/macroscope/trigger-command.md new file mode 100644 index 0000000..7b5ed69 --- /dev/null +++ b/internal/dialect/testdata/macroscope/trigger-command.md @@ -0,0 +1 @@ +@macroscope-app review \ No newline at end of file diff --git a/internal/state/state.go b/internal/state/state.go index 7bfa97f..b8d5c1f 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -57,8 +57,19 @@ type Round struct { // CodexCommandID is the `@codex review` comment crq posted/adopted for this // round (0 when Codex was not fired). It suppresses re-posting the Codex // command on a retry of the same head. + // + // Legacy twin of CoBots["chatgpt-codex-connector"].CommandID: the fleet + // shares one state ref across binary versions, so Codex bookkeeping is + // dual-written (accessors mirror into these fields, Normalize folds them + // back into CoBots) — old binaries must keep seeing codex_command_id. CodexCommandID int64 `json:"codex_command_id,omitempty"` + // CoBots is the per-round co-reviewer bookkeeping, keyed by normalized + // login (no "[bot]" suffix). Bots other than Codex exist only here — old + // binaries never fired them, so ignoring the map is correct for them. + // Mutate only through Co/SetCoCommand/ClaimCo/ClearCoClaim. + CoBots map[string]CoBotRound `json:"cobots,omitempty"` + // RetryAt is the earliest time this head may fire again (awaiting_retry). RetryAt *time.Time `json:"retry_at,omitempty"` @@ -88,6 +99,100 @@ type Round struct { Note string `json:"note,omitempty"` // human-readable reason for the last transition } +// CoBotRound is one co-reviewer's bookkeeping inside a Round: the trigger +// comment crq posted/adopted for it, and the CAS claim that serializes the +// pending post (the per-round concurrency guard — co-reviewer fires never +// take the global FireSlot). +type CoBotRound struct { + CommandID int64 `json:"command_id,omitempty"` + CommandedAt *time.Time `json:"commanded_at,omitempty"` + ClaimedAt *time.Time `json:"claimed_at,omitempty"` +} + +func (c CoBotRound) empty() bool { + return c.CommandID == 0 && c.CommandedAt == nil && c.ClaimedAt == nil +} + +// codexCoBotKey is dialect.CodexBotLogin under coBotKey. The literal is +// repeated here because state stays stdlib-only; state_test pins the two in +// sync. +const codexCoBotKey = "chatgpt-codex-connector" + +// coBotKey mirrors dialect.NormalizeBotName: CoBots keys carry no "[bot]" +// suffix regardless of which login spelling the caller observed. +func coBotKey(login string) string { return strings.TrimSuffix(login, "[bot]") } + +// Co returns login's bookkeeping for this round (zero value when none). +func (r *Round) Co(login string) CoBotRound { + return r.CoBots[coBotKey(login)] +} + +// setCo stores login's entry copy-on-write (Rounds are copied by value while +// the map inside would otherwise be shared) and dual-writes Codex's entry +// into the legacy fields old binaries read. +func (r *Round) setCo(login string, c CoBotRound) { + key := coBotKey(login) + m := make(map[string]CoBotRound, len(r.CoBots)+1) + for k, v := range r.CoBots { + m[k] = v + } + if c.empty() { + delete(m, key) + } else { + m[key] = c + } + if len(m) == 0 { + m = nil + } + r.CoBots = m + if key == codexCoBotKey { + r.CodexCommandID = c.CommandID + r.CodexCommandedAt = c.CommandedAt + r.CodexClaimedAt = c.ClaimedAt + } +} + +// SetCoCommand records the trigger comment posted/adopted for login this +// round and releases its claim. +func (r *Round) SetCoCommand(login string, commandID int64, at time.Time) { + c := r.Co(login) + c.CommandID = commandID + t := at.UTC() + c.CommandedAt = &t + c.ClaimedAt = nil + r.setCo(login, c) +} + +// ClaimCo reserves the pending trigger post for login: CAS-set before the +// network post so two unserialized sweepers cannot both post the command for +// the same round. A stale claim (the poster died mid-flight) expires by age +// and may be re-claimed. +func (r *Round) ClaimCo(login string, now time.Time) { + c := r.Co(login) + t := now.UTC() + c.ClaimedAt = &t + r.setCo(login, c) +} + +// ClearCoClaim releases login's claim without recording a command. +func (r *Round) ClearCoClaim(login string) { + c := r.Co(login) + c.ClaimedAt = nil + r.setCo(login, c) +} + +// foldLegacyCodex folds the legacy per-round Codex fields into CoBots on +// load. Old binaries write ONLY the legacy fields while new ones dual-write +// both, so whenever any legacy field is set it is authoritative for Codex and +// overwrites the mirror entry. +func (r *Round) foldLegacyCodex() { + legacy := CoBotRound{CommandID: r.CodexCommandID, CommandedAt: r.CodexCommandedAt, ClaimedAt: r.CodexClaimedAt} + if legacy.empty() { + return + } + r.setCo(codexCoBotKey, legacy) +} + // FireSlot is the single global in-flight reservation: at most one review // command may be getting posted at a time, fleet-wide. type FireSlot struct { @@ -471,6 +576,13 @@ func (s *State) Normalize(now time.Time) { if s.FireSlot != nil && s.SlotRound() == nil { s.FireSlot = nil } + for key, r := range s.Rounds { + r.foldLegacyCodex() + s.Rounds[key] = r + } + for i := range s.Archive { + s.Archive[i].foldLegacyCodex() + } if len(s.Archive) > ArchiveMax { s.Archive = s.Archive[len(s.Archive)-ArchiveMax:] } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 27d5052..20b763f 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -1,9 +1,13 @@ package state import ( + "encoding/json" "errors" + "strings" "testing" "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" ) var t0 = time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) @@ -266,3 +270,93 @@ func TestArchiveBounded(t *testing.T) { t.Fatalf("archive = %d, want %d", len(s.Archive), ArchiveMax) } } + +// TestCoBotKeyMatchesDialect pins state's local login normalization (and the +// Codex fold key) to dialect's, since state stays stdlib-only by design. +func TestCoBotKeyMatchesDialect(t *testing.T) { + if got := coBotKey(dialect.CodexBotLogin); got != codexCoBotKey { + t.Fatalf("coBotKey(CodexBotLogin) = %q, want %q", got, codexCoBotKey) + } + if got := coBotKey("cursor[bot]"); got != dialect.NormalizeBotName("cursor[bot]") { + t.Fatalf("coBotKey = %q, want dialect normalization %q", got, dialect.NormalizeBotName("cursor[bot]")) + } +} + +// TestCoBotsDualWrite: Codex writes through the accessors must mirror into the +// legacy per-round fields (old binaries read codex_command_id from the shared +// state ref); other bots live only in the map. +func TestCoBotsDualWrite(t *testing.T) { + var r Round + r.ClaimCo(dialect.CodexBotLogin, t0) + if r.CodexClaimedAt == nil || !r.CodexClaimedAt.Equal(t0) { + t.Fatalf("ClaimCo did not mirror into CodexClaimedAt: %+v", r) + } + r.SetCoCommand(dialect.CodexBotLogin, 42, t0.Add(time.Second)) + if r.CodexCommandID != 42 || r.CodexCommandedAt == nil || r.CodexClaimedAt != nil { + t.Fatalf("SetCoCommand did not mirror legacy fields: %+v", r) + } + if co := r.Co("chatgpt-codex-connector"); co.CommandID != 42 || co.ClaimedAt != nil { + t.Fatalf("map entry = %+v, want command 42, no claim", co) + } + + data, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"codex_command_id":42`, `"cobots"`, `"chatgpt-codex-connector"`} { + if !strings.Contains(string(data), want) { + t.Fatalf("marshaled round missing %s: %s", want, data) + } + } + + var other Round + other.SetCoCommand("cursor[bot]", 7, t0) + if other.CodexCommandID != 0 || other.CodexCommandedAt != nil { + t.Fatalf("bugbot write leaked into legacy Codex fields: %+v", other) + } + if co := other.Co("cursor"); co.CommandID != 7 { + t.Fatalf("Co(cursor) = %+v, want command 7", co) + } +} + +// TestNormalizeFoldsLegacyCodex: a round written by an old binary (legacy +// fields only) gains its CoBots mirror on load, and legacy values win over a +// stale mirror (old binaries only write the legacy fields). +func TestNormalizeFoldsLegacyCodex(t *testing.T) { + payload := `{"v":3,"rounds":{"owner/repo#1":{"repo":"owner/repo","pr":1,"head":"abcdef123",` + + `"seq":1,"phase":"fired","enqueued_at":"2026-07-17T12:00:00Z",` + + `"codex_command_id":11,"codex_commanded_at":"2026-07-17T12:01:00Z"}}}` + var s State + if err := json.Unmarshal([]byte(payload), &s); err != nil { + t.Fatal(err) + } + s.Normalize(t0) + r := s.Round("owner/repo", 1) + co := r.Co(dialect.CodexBotLogin) + if co.CommandID != 11 || co.CommandedAt == nil { + t.Fatalf("fold produced %+v, want command 11", co) + } + + // Stale mirror: an old binary moved the legacy command on; fold overwrites. + stale := *r + stale.CoBots = map[string]CoBotRound{codexCoBotKey: {CommandID: 5}} + stale.foldLegacyCodex() + if got := stale.Co(dialect.CodexBotLogin).CommandID; got != 11 { + t.Fatalf("fold kept stale mirror %d, want legacy 11", got) + } + + // CoBots-only rounds (new bots) survive a marshal/unmarshal round-trip. + var b Round + b.SetCoCommand("cursor[bot]", 9, t0) + data, err := json.Marshal(b) + if err != nil { + t.Fatal(err) + } + var back Round + if err := json.Unmarshal(data, &back); err != nil { + t.Fatal(err) + } + if got := back.Co("cursor[bot]").CommandID; got != 9 { + t.Fatalf("round-trip lost bugbot entry: %+v", back) + } +} From 2d094fb33f6fc276cfeba4efff08ba3418112743 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 04:38:18 +0200 Subject: [PATCH 02/31] Generalize the engine's Codex helpers into co-reviewer algebra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 of the co-reviewer abstraction (Ref #34): coreview.go holds the login-keyed evidence/gate rules (cutoffs, head evidence, auto-activity, command windows, unable notices, CoOnlyEligible, DecideCoPost with the never/selfheal/always trigger modes), counting check runs as evidence because Bugbot's clean rounds exist only as a check run. codex.go shrinks to the thumbs-up quirk plus wrappers; Observation gains Checks and the per-bot Co map with a legacy-field fallback; Policy gains CoReviewers. engine_test.go passes unmodified — the behavior-preservation proof. The state fold turns fully legacy-authoritative for Codex so direct legacy writes cannot leave a stale CoBots mirror. --- internal/engine/codex.go | 244 ++++-------------------- internal/engine/coreview.go | 359 ++++++++++++++++++++++++++++++++++++ internal/engine/engine.go | 80 ++++++++ internal/state/state.go | 13 +- 4 files changed, 476 insertions(+), 220 deletions(-) create mode 100644 internal/engine/coreview.go diff --git a/internal/engine/codex.go b/internal/engine/codex.go index 44f12b1..5ab6ce3 100644 --- a/internal/engine/codex.go +++ b/internal/engine/codex.go @@ -1,13 +1,17 @@ package engine import ( - "strings" "time" "github.com/kristofferR/coderabbit-queue/internal/dialect" "github.com/kristofferR/coderabbit-queue/internal/state" ) +// Codex-specific layer over the generic co-reviewer algebra in coreview.go: +// the thumbs-up quirk (a Codex +1 stands in for its review) lives here, and +// the pre-migration entry points wrap their generic counterparts keyed by the +// Codex login. The wrappers go away once crq iterates Policy.CoReviewers. + // codexBot is the Codex GitHub app login the engine flips in ReviewedBy when // Codex gates a round. The dialect owns the literal and the normalization // (CodexBotLogin/IsCodexBot/HasCodexBot); this consumes the canonical constant. @@ -22,242 +26,58 @@ func roundCutoff(r state.Round) time.Time { return time.Time{} } -// codexCutoff is the evidence floor for Codex specifically: evidence produced -// in response to crq's own Codex command binds from the command time, which -// can precede a deferred CodeRabbit fire (the command posts while the round -// is still queued behind a rate-limit window or busy slot). -func codexCutoff(r state.Round) time.Time { - cut := roundCutoff(r) - if r.CodexCommandedAt != nil { - at := r.CodexCommandedAt.UTC() - if cut.IsZero() || at.Before(cut) { - return at - } +// codexPolicy synthesizes the Codex CoReviewerPolicy from the legacy Policy +// fields: trigger always iff Codex is configured-required (DecideCodexPost's +// historical "only when required" guard), never otherwise. +func codexPolicy(p Policy) CoReviewerPolicy { + cp := CoReviewerPolicy{Login: codexBot, Command: p.CodexCommand, Trigger: TriggerNever} + if dialect.HasCodexBot(p.RequiredBots) { + cp.Trigger = TriggerAlways } - return cut + return cp } -// codexReviewedRound reports whether a submitted Codex review binds to this -// round: one whose commit prefixes the head, or — SHA-less — one submitted -// at/after the fire. +func codexCutoff(r state.Round) time.Time { return coCutoff(r, codexBot) } + func codexReviewedRound(r state.Round, obs Observation, cutoff time.Time) bool { - for _, review := range obs.Reviews { - if !dialect.IsCodexBot(review.Bot) { - continue - } - if r.Head != "" && review.Commit != "" && strings.HasPrefix(review.Commit, r.Head) { - return true - } - if review.Commit == "" && !review.SubmittedAt.IsZero() && notBefore(review.SubmittedAt, cutoff) { - return true - } - } - return false + return coReviewedRound(r, obs, codexBot, cutoff) } -// codexCommentedRound reports whether Codex posted an actionable comment or a -// clean summary at/after the round's fire — the round-window evidence that means -// Codex is participating. Its notices (usage limits, acks) do not count. func codexCommentedRound(obs Observation, cutoff time.Time) bool { - for _, ev := range obs.Events { - if dialect.IsCodexBot(ev.Bot) && ev.Kind == dialect.EvOther && notBefore(ev.ObservedTime(), cutoff) { - return true - } - if ev.Kind == dialect.EvCodexClean && notBefore(ev.ObservedTime(), cutoff) { - return true - } - } - return false -} - -// codexReviewedHeadAt reports the newest Codex verdict explicitly bound to the -// observed head: either a submitted review or a clean summary naming that SHA. -// The timestamp is the evidence floor used to ignore older usage-limit notices. -func codexReviewedHeadAt(obs Observation) (time.Time, bool) { - var latest time.Time - matched := false - for _, review := range obs.Reviews { - if dialect.IsCodexBot(review.Bot) && obs.Head != "" && review.Commit != "" && strings.HasPrefix(review.Commit, obs.Head) { - matched = true - if review.SubmittedAt.After(latest) { - latest = review.SubmittedAt - } - } - } - for _, ev := range obs.Events { - if ev.Kind == dialect.EvCodexClean && obs.Head != "" && dialect.SHAPrefixMatch(ev.SHA, obs.Head) { - matched = true - if at := ev.ObservedTime(); at.After(latest) { - latest = at - } - } - } - return latest, matched + return coCommentedRound(obs, codexBot, cutoff) } // codexReviewedHead is the "Codex already reviewed this head" fire guard. -func codexReviewedHead(obs Observation) bool { - _, matched := codexReviewedHeadAt(obs) - return matched -} +func codexReviewedHead(obs Observation) bool { return coReviewedHead(obs, codexBot) } -// CodexActiveThisRound reports whether Codex shows any activity bound to this -// round — a head review, a round-window comment/clean summary, or a current -// thumbs-up. observe() stores it on the Observation so the dynamic completion -// gate requires Codex when it participates without being configured-required. +// CodexActiveThisRound is CoActiveThisRound for Codex plus its thumbs-up +// quirk: a current +1 on the PR or the fired command counts as participation. func CodexActiveThisRound(r state.Round, obs Observation) bool { - cutoff := codexCutoff(r) - return codexReviewedRound(r, obs, cutoff) || codexCommentedRound(obs, cutoff) || obs.CodexThumbsUp + return CoActiveThisRound(r, obs, codexBot) || obs.CodexThumbsUp } -// CodexAutoActive reports whether Codex reviews this PR on its own right now: its -// most recent evidence — a submitted review or a clean summary — was not preceded -// by an `@codex review` command. When true, crq must never post the Codex command -// (Codex reviews unprompted). Only the LATEST evidence decides, so an old -// unprompted review from an epoch when auto-review was on no longer suppresses -// posting once a later commanded review lands; conversely a command posted before -// the latest evidence marks that evidence as commanded, not automatic. -func CodexAutoActive(obs Observation) bool { - latest, prev, ok := latestCodexEvidence(obs) - if !ok { - return false - } - // The latest evidence is automatic unless a command plausibly triggered it: - // one posted in (prev, latest]. A command older than the previous evidence - // belongs to an earlier round and does not explain this review — otherwise a - // single manual `@codex review` from three heads ago would suppress posting - // forever even after Codex went back to reviewing on its own. - return !codexCommandInWindow(obs, prev, latest) -} - -// latestCodexEvidence returns the timestamps of the most recent and second-most -// recent Codex review-or-clean-summary events, and whether any exists. prev is -// zero when there is only one evidence item. -func latestCodexEvidence(obs Observation) (latest, prev time.Time, ok bool) { - consider := func(at time.Time) { - if at.IsZero() { - return - } - switch { - case !ok || at.After(latest): - prev, latest, ok = latest, at, true - case at.Equal(latest): - // prev must stay strictly older: co-timestamped evidence (a review and - // its clean summary in the same second) must not close the command - // window to a point, or a command at that instant reads as absent and - // a commanded review misclassifies as automatic. - case at.After(prev): - prev = at - } - } - for _, review := range obs.Reviews { - if dialect.IsCodexBot(review.Bot) { - consider(review.SubmittedAt) - } - } - for _, ev := range obs.Events { - if ev.Kind == dialect.EvCodexClean { - consider(ev.PairTime()) - } - } - return latest, prev, ok -} - -// codexCommandInWindow reports whether an `@codex review` command was posted -// after `after` and at or before `atOrBefore`. A zero `after` means no lower -// bound (the latest evidence is also the first — any command up to it counts). -func codexCommandInWindow(obs Observation, after, atOrBefore time.Time) bool { - for _, ev := range obs.Events { - if ev.Kind != dialect.EvCodexCommand { - continue - } - at := ev.PairTime() - if at.After(atOrBefore) { - continue - } - if !after.IsZero() && !at.After(after) { - continue - } - return true - } - return false -} +// CodexAutoActive reports whether Codex reviews this PR on its own right now. +func CodexAutoActive(obs Observation) bool { return CoAutoActive(obs, codexBot) } // CodexCommandSince reports whether an `@codex review` command comment exists -// at/after since. The self-heal retry uses it (with the round's fire time) to -// tell a fired round whose Codex command is already on the PR from one whose -// Codex post failed. +// at/after since. func CodexCommandSince(obs Observation, since time.Time) bool { - for _, ev := range obs.Events { - if ev.Kind == dialect.EvCodexCommand && notBefore(ev.PairTime(), since) { - return true - } - } - return false + return CoCommandSince(obs, codexBot, since) } // CodexOnlyEligible reports whether an account-blocked round may degrade to a -// Codex-only round: the block is live AND Codex has evidence bound to THIS -// work — a review of the current head, or round-window activity anchored by -// the fire or by crq's own (possibly pre-fire) Codex command — AND no -// usage-limit exhaustion notice inside that same window. Auto-activity on -// older heads, configuration, or a live unanswered command merely predict -// evidence; degradation waits for the evidence itself, since before Codex -// responds there is nothing to return early anyway, and marking a round -// deferred stops the loop from extending its deadline over the block. +// Codex-only round. func CodexOnlyEligible(r state.Round, obs Observation, blockedUntil *time.Time, now time.Time) bool { - if blockedUntil == nil || !blockedUntil.After(now) { - return false - } - headEvidenceAt, headReviewed := codexReviewedHeadAt(obs) - anchored := r.FiredAt != nil || r.CodexCommandedAt != nil - if !headReviewed && !(anchored && obs.CodexActiveThisRound) { - return false - } - // The usage-limit floor is the evidence window. For an unfired, - // uncommanded round the cutoff is zero — floor it at the head review that - // qualified the round instead, or any old exhaustion notice still on the - // PR would suppress the degrade until the window expires. - floor := codexCutoff(r) - if floor.IsZero() { - floor = headEvidenceAt - } - return !codexUsageLimitedSince(obs, floor) + return CoOnlyEligible(r, obs, codexBot, blockedUntil, now) } -// codexUsageLimitedSince reports whether Codex posted its usage-limit -// exhaustion notice at/after since — the round window it can no longer finish. func codexUsageLimitedSince(obs Observation, since time.Time) bool { - for _, ev := range obs.Events { - if ev.Kind == dialect.EvCodexUsageLimit && notBefore(ev.ObservedTime(), since) { - return true - } - } - return false + return coUnableSince(obs, codexBot, since) } -// DecideCodexPost reports whether crq should post its Codex review command while -// firing this round. crq only posts for a configured-required Codex that does -// not auto-review; if Codex reviews on its own, has already reviewed the head, -// has been asked already (round.CodexCommandID or a live command on the PR), or -// no command is configured, crq stays out of the way. commandPresent is supplied -// by the caller so the fire path (cutoff-filtered obs.CodexCommands) and the -// self-heal retry (a round-window CodexCommandSince scan) share this rule. +// DecideCodexPost reports whether crq should post its Codex review command +// while firing this round — DecideCoPost under the synthesized Codex policy +// (post at fire time, required-only, unless Codex auto-reviews). func DecideCodexPost(r state.Round, obs Observation, p Policy, commandPresent bool) bool { - if r.CodexCommandID != 0 { - return false - } - if strings.TrimSpace(p.CodexCommand) == "" { - return false - } - if !dialect.HasCodexBot(p.RequiredBots) { - return false - } - if obs.CodexAutoActive { - return false - } - if commandPresent { - return false - } - return !codexReviewedHead(obs) + return DecideCoPost(r, obs, codexPolicy(p), commandPresent, time.Time{}, time.Time{}) } diff --git a/internal/engine/coreview.go b/internal/engine/coreview.go new file mode 100644 index 0000000..1397b25 --- /dev/null +++ b/internal/engine/coreview.go @@ -0,0 +1,359 @@ +package engine + +import ( + "strings" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// Co-reviewer evidence and gate algebra, keyed by login. These are the +// bot-shape-generic rules ("participated", "clean at SHA", "cannot finish", +// "was commanded") the Codex-specific helpers in codex.go now wrap; check +// runs count as evidence/activity alongside reviews and clean summaries +// because Bugbot's clean rounds exist ONLY as a check run. + +// roundCoCommandID reads the trigger comment recorded for login this round, +// falling back to the legacy Codex fields for rounds built directly (tests) +// rather than loaded through state.Normalize's fold. +func roundCoCommandID(r state.Round, login string) int64 { + if c := r.Co(login); c.CommandID != 0 { + return c.CommandID + } + if dialect.IsCodexBot(login) { + return r.CodexCommandID + } + return 0 +} + +func roundCoCommandedAt(r state.Round, login string) *time.Time { + if c := r.Co(login); c.CommandedAt != nil { + return c.CommandedAt + } + if dialect.IsCodexBot(login) { + return r.CodexCommandedAt + } + return nil +} + +// eventConcerns reports whether a classified event concerns the co-reviewer +// login: by the classifier's For attribution when present, otherwise by +// author. A For-less co-command is attributed to Codex — the only bot whose +// commands existed before For did (migration shim for hand-built events). +func eventConcerns(ev dialect.BotEvent, login string) bool { + if ev.For != "" { + return sameBot(ev.For, login) + } + if ev.Kind == dialect.EvCoCommand { + return dialect.IsCodexBot(login) + } + return sameBot(ev.Bot, login) +} + +// coCutoff is the evidence floor for one co-reviewer: evidence produced in +// response to crq's own trigger command binds from the command time, which +// can precede a deferred CodeRabbit fire (the command posts while the round +// is still queued behind a rate-limit window or busy slot). +func coCutoff(r state.Round, login string) time.Time { + cut := roundCutoff(r) + if at := roundCoCommandedAt(r, login); at != nil { + t := at.UTC() + if cut.IsZero() || t.Before(cut) { + return t + } + } + return cut +} + +// coChecks yields login's check runs from the observation. +func coChecks(obs Observation, login string) []CheckSeen { + var out []CheckSeen + for _, c := range obs.Checks { + if sameBot(c.Bot, login) { + out = append(out, c) + } + } + return out +} + +// coCheckAny reports whether ANY check run of login's exists for the head, +// including an in-progress one — a running check will deliver evidence, so +// posting a trigger alongside it would double-review. +func coCheckAny(obs Observation, login string) bool { + return len(coChecks(obs, login)) > 0 +} + +// coCheckReviewedAt reports the newest COMPLETED check verdict for the head +// (Done or DoneClean — findings, if any, still gate via threads). +func coCheckReviewedAt(obs Observation, login string) (time.Time, bool) { + var latest time.Time + matched := false + for _, c := range coChecks(obs, login) { + if c.Verdict == dialect.CheckDone || c.Verdict == dialect.CheckDoneClean { + matched = true + if c.CompletedAt.After(latest) { + latest = c.CompletedAt + } + } + } + return latest, matched +} + +// coReviewedRound reports whether a submitted review by login binds to this +// round: one whose commit prefixes the head, or — SHA-less — one submitted +// at/after the fire. +func coReviewedRound(r state.Round, obs Observation, login string, cutoff time.Time) bool { + for _, review := range obs.Reviews { + if !sameBot(review.Bot, login) { + continue + } + if r.Head != "" && review.Commit != "" && strings.HasPrefix(review.Commit, r.Head) { + return true + } + if review.Commit == "" && !review.SubmittedAt.IsZero() && notBefore(review.SubmittedAt, cutoff) { + return true + } + } + return false +} + +// coCommentedRound reports whether login posted an actionable comment or a +// clean summary at/after the round's fire — the round-window evidence that +// means it is participating. Its notices (unable, acks, verdicts) do not +// count. +func coCommentedRound(obs Observation, login string, cutoff time.Time) bool { + for _, ev := range obs.Events { + if ev.Kind == dialect.EvOther && sameBot(ev.Bot, login) && notBefore(ev.ObservedTime(), cutoff) { + return true + } + if ev.Kind == dialect.EvCoClean && eventConcerns(ev, login) && notBefore(ev.ObservedTime(), cutoff) { + return true + } + } + return false +} + +// coReviewedHeadAt reports the newest verdict by login explicitly bound to +// the observed head: a submitted review, a clean summary naming that SHA, or +// a completed check run (head-scoped by construction). The timestamp is the +// evidence floor used to ignore older unable notices. +func coReviewedHeadAt(obs Observation, login string) (time.Time, bool) { + var latest time.Time + matched := false + for _, review := range obs.Reviews { + if sameBot(review.Bot, login) && obs.Head != "" && review.Commit != "" && strings.HasPrefix(review.Commit, obs.Head) { + matched = true + if review.SubmittedAt.After(latest) { + latest = review.SubmittedAt + } + } + } + for _, ev := range obs.Events { + if ev.Kind == dialect.EvCoClean && eventConcerns(ev, login) && obs.Head != "" && dialect.SHAPrefixMatch(ev.SHA, obs.Head) { + matched = true + if at := ev.ObservedTime(); at.After(latest) { + latest = at + } + } + } + if at, ok := coCheckReviewedAt(obs, login); ok { + matched = true + if at.After(latest) { + latest = at + } + } + return latest, matched +} + +// coReviewedHead is the "login already reviewed this head" fire guard. +func coReviewedHead(obs Observation, login string) bool { + _, matched := coReviewedHeadAt(obs, login) + return matched +} + +// CoActiveThisRound reports whether login shows activity bound to this round — +// a head review, a round-window comment/clean summary, or any head check run. +// observe() stores it on the Observation so the dynamic completion gate +// requires the bot when it participates without being configured-required. +// (Codex's thumbs-up quirk is layered on by CodexActiveThisRound.) +func CoActiveThisRound(r state.Round, obs Observation, login string) bool { + cutoff := coCutoff(r, login) + return coReviewedRound(r, obs, login, cutoff) || coCommentedRound(obs, login, cutoff) || coCheckAny(obs, login) +} + +// CoAutoActive reports whether login reviews this PR on its own right now: its +// most recent evidence — a submitted review, a clean summary, or a completed +// check — was not preceded by its trigger command. When true, crq must never +// post the trigger (the bot reviews unprompted). Only the LATEST evidence +// decides, so an old unprompted review from an epoch when auto-review was on +// no longer suppresses posting once a later commanded review lands; +// conversely a command posted before the latest evidence marks that evidence +// as commanded, not automatic. +func CoAutoActive(obs Observation, login string) bool { + latest, prev, ok := latestCoEvidence(obs, login) + if !ok { + return false + } + // The latest evidence is automatic unless a command plausibly triggered it: + // one posted in (prev, latest]. A command older than the previous evidence + // belongs to an earlier round and does not explain this review — otherwise a + // single manual trigger from three heads ago would suppress posting forever + // even after the bot went back to reviewing on its own. + return !coCommandInWindow(obs, login, prev, latest) +} + +// latestCoEvidence returns the timestamps of the most recent and second-most +// recent review-or-clean-summary-or-completed-check events for login, and +// whether any exists. prev is zero when there is only one evidence item. +func latestCoEvidence(obs Observation, login string) (latest, prev time.Time, ok bool) { + consider := func(at time.Time) { + if at.IsZero() { + return + } + switch { + case !ok || at.After(latest): + prev, latest, ok = latest, at, true + case at.Equal(latest): + // prev must stay strictly older: co-timestamped evidence (a review and + // its clean summary in the same second) must not close the command + // window to a point, or a command at that instant reads as absent and + // a commanded review misclassifies as automatic. + case at.After(prev): + prev = at + } + } + for _, review := range obs.Reviews { + if sameBot(review.Bot, login) { + consider(review.SubmittedAt) + } + } + for _, ev := range obs.Events { + if ev.Kind == dialect.EvCoClean && eventConcerns(ev, login) { + consider(ev.PairTime()) + } + } + for _, c := range coChecks(obs, login) { + if c.Verdict == dialect.CheckDone || c.Verdict == dialect.CheckDoneClean { + consider(c.CompletedAt) + } + } + return latest, prev, ok +} + +// coCommandInWindow reports whether login's trigger command was posted after +// `after` and at or before `atOrBefore`. A zero `after` means no lower bound +// (the latest evidence is also the first — any command up to it counts). +func coCommandInWindow(obs Observation, login string, after, atOrBefore time.Time) bool { + for _, ev := range obs.Events { + if ev.Kind != dialect.EvCoCommand || !eventConcerns(ev, login) { + continue + } + at := ev.PairTime() + if at.After(atOrBefore) { + continue + } + if !after.IsZero() && !at.After(after) { + continue + } + return true + } + return false +} + +// CoCommandSince reports whether login's trigger command comment exists +// at/after since. The self-heal retry uses it (with the round's fire time) to +// tell a fired round whose command is already on the PR from one whose post +// failed. +func CoCommandSince(obs Observation, login string, since time.Time) bool { + for _, ev := range obs.Events { + if ev.Kind == dialect.EvCoCommand && eventConcerns(ev, login) && notBefore(ev.PairTime(), since) { + return true + } + } + return false +} + +// coUnableSince reports whether login declared it cannot finish this round +// (EvCoUnable — Codex's usage-limit exhaustion) at/after since. +func coUnableSince(obs Observation, login string, since time.Time) bool { + for _, ev := range obs.Events { + if ev.Kind == dialect.EvCoUnable && eventConcerns(ev, login) && notBefore(ev.ObservedTime(), since) { + return true + } + } + return false +} + +// CoOnlyEligible reports whether an account-blocked round may degrade to a +// co-reviewer-only round: the block is live AND login has evidence bound to +// THIS work — a review of the current head, or round-window activity anchored +// by the fire or by crq's own (possibly pre-fire) trigger command — AND no +// unable notice inside that same window. Auto-activity on older heads, +// configuration, or a live unanswered command merely predict evidence; +// degradation waits for the evidence itself, since before the bot responds +// there is nothing to return early anyway, and marking a round deferred stops +// the loop from extending its deadline over the block. +func CoOnlyEligible(r state.Round, obs Observation, login string, blockedUntil *time.Time, now time.Time) bool { + if blockedUntil == nil || !blockedUntil.After(now) { + return false + } + headEvidenceAt, headReviewed := coReviewedHeadAt(obs, login) + anchored := r.FiredAt != nil || roundCoCommandedAt(r, login) != nil + if !headReviewed && !(anchored && obs.co(login).ActiveThisRound) { + return false + } + // The unable floor is the evidence window. For an unfired, uncommanded + // round the cutoff is zero — floor it at the head review that qualified + // the round instead, or any old exhaustion notice still on the PR would + // suppress the degrade until the window expires. + floor := coCutoff(r, login) + if floor.IsZero() { + floor = headEvidenceAt + } + return !coUnableSince(obs, login, floor) +} + +// DecideCoPost reports whether crq should post login's trigger command for +// this round. Common guards regardless of mode: a command is configured, the +// round has not already commanded this bot, no live command sits on the PR, +// the bot has not reviewed the head, and no check run of its exists for the +// head (including in-progress — a running check will deliver evidence). +// +// Modes: never — false. always — post unless the bot auto-reviews (today's +// Codex behavior; required-ness lives in the config default that picks the +// mode). selfheal — post only for a bot observed active that missed the head, +// once the anchor (the round's fire) is older than the grace period; the +// caller passes anchor and now so the fire path and the sweep share the rule. +func DecideCoPost(r state.Round, obs Observation, cp CoReviewerPolicy, commandPresent bool, anchor, now time.Time) bool { + if roundCoCommandID(r, cp.Login) != 0 { + return false + } + if strings.TrimSpace(cp.Command) == "" { + return false + } + if commandPresent { + return false + } + if coReviewedHead(obs, cp.Login) { + return false + } + if coCheckAny(obs, cp.Login) { + return false + } + switch cp.Trigger { + case TriggerAlways: + return !obs.co(cp.Login).AutoActive + case TriggerSelfHeal: + co := obs.co(cp.Login) + if !co.AutoActive && !co.ActiveThisRound { + return false + } + if anchor.IsZero() { + return false + } + return now.Sub(anchor) >= cp.selfHealGrace() + default: + return false + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 2123522..9c42e96 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -12,12 +12,44 @@ import ( "github.com/kristofferR/coderabbit-queue/internal/dialect" ) +// TriggerMode is how crq may post a co-reviewer's trigger command. +type TriggerMode string + +const ( + TriggerNever TriggerMode = "never" // crq never posts this bot's command + TriggerSelfHeal TriggerMode = "selfheal" // post only when an active bot missed the head past a grace period + TriggerAlways TriggerMode = "always" // post at fire time unless the bot auto-reviews +) + +// CoReviewerPolicy is the configured stance toward one co-reviewer: whether +// and how crq triggers it. Required-ness stays solely RequiredBots membership. +type CoReviewerPolicy struct { + Login string + Command string // trigger comment crq posts ("" disables posting) + Trigger TriggerMode // zero value ("") reads as never + // SelfHealGrace is how long a selfheal trigger waits past its anchor for + // the bot to show up on its own (<=0: a conservative default). + SelfHealGrace time.Duration +} + +func (cp CoReviewerPolicy) selfHealGrace() time.Duration { + if cp.SelfHealGrace > 0 { + return cp.SelfHealGrace + } + return 10 * time.Minute +} + // Policy carries the configured knobs the decisions depend on. type Policy struct { Bot string // configured CodeRabbit login RequiredBots []string // bots that gate round completion CodexCommand string // Codex review trigger crq posts ("" disables Codex firing) + // CoReviewers are the enabled co-reviewer bots with their trigger stances. + // When nil, the legacy Codex fields above synthesize the one entry + // (Trigger always iff Codex is in RequiredBots) — see codexPolicy. + CoReviewers []CoReviewerPolicy + MinInterval time.Duration // global pacing between fires InflightTimeout time.Duration // fired round with no bot response at all RateLimitFallback time.Duration // block window when "available in" is unparseable @@ -60,6 +92,34 @@ type CommandSeen struct { UpdatedAt time.Time } +// CheckSeen is one classified check run owned by a co-reviewer. observe() +// fetches check runs per-ref for the observed head, so every CheckSeen is +// head-scoped by construction — a completed one IS head review evidence +// (Bugbot's clean rounds exist ONLY as a check run). +type CheckSeen struct { + Bot string // co-reviewer login owning the check + Name string + Verdict dialect.CheckVerdict + CompletedAt time.Time +} + +// CoSeen is one co-reviewer's slice of the Observation, keyed in +// Observation.Co by normalized login. +type CoSeen struct { + // Commands are live adoptable trigger comments for this bot at the head + // (cutoff-filtered by observe like Observation.Commands). Non-empty means + // a trigger already exists, so crq must not post a duplicate. + Commands []CommandSeen + // AutoActive reports the bot reviews this PR on its own: its latest + // review/clean-summary/check evidence was not preceded by a trigger + // command. When true, crq never posts its command unprompted. + AutoActive bool + // ActiveThisRound reports activity bound to the current round (a head + // review, a round-window comment/clean summary, or a head check run). It + // drives the dynamic completion gate for non-required co-reviewers. + ActiveThisRound bool +} + // Observation is everything the engine may know about one PR at one moment. // crq's observe() builds it from GitHub exactly once per decision. type Observation struct { @@ -86,6 +146,26 @@ type Observation struct { // head review, a round-window comment/clean summary, or a thumbs-up). It // drives the dynamic completion gate when Codex is not configured-required. CodexActiveThisRound bool + + // Checks are the head's classified co-reviewer check runs. + Checks []CheckSeen + // Co carries each co-reviewer's per-bot observation slice, keyed by + // normalized login. The legacy Codex* fields above remain the fallback for + // callers that predate the map — see Observation.co. + Co map[string]CoSeen +} + +// co returns login's observation slice, falling back to the legacy Codex +// fields when the map has no entry (engine tests and pre-migration callers +// still populate those directly). +func (o Observation) co(login string) CoSeen { + if c, ok := o.Co[dialect.NormalizeBotName(login)]; ok { + return c + } + if dialect.IsCodexBot(login) { + return CoSeen{Commands: o.CodexCommands, AutoActive: o.CodexAutoActive, ActiveThisRound: o.CodexActiveThisRound} + } + return CoSeen{} } // notBefore mirrors v2: GitHub timestamps are second-granular, so a bot diff --git a/internal/state/state.go b/internal/state/state.go index b8d5c1f..4fc3708 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -182,15 +182,12 @@ func (r *Round) ClearCoClaim(login string) { } // foldLegacyCodex folds the legacy per-round Codex fields into CoBots on -// load. Old binaries write ONLY the legacy fields while new ones dual-write -// both, so whenever any legacy field is set it is authoritative for Codex and -// overwrites the mirror entry. +// load. Every writer keeps the legacy fields current for Codex (old binaries +// write only them, new ones dual-write), so they are authoritative in BOTH +// directions: they overwrite the mirror entry, and an empty legacy set clears +// a stale mirror (a writer zeroed the legacy fields directly). func (r *Round) foldLegacyCodex() { - legacy := CoBotRound{CommandID: r.CodexCommandID, CommandedAt: r.CodexCommandedAt, ClaimedAt: r.CodexClaimedAt} - if legacy.empty() { - return - } - r.setCo(codexCoBotKey, legacy) + r.setCo(codexCoBotKey, CoBotRound{CommandID: r.CodexCommandID, CommandedAt: r.CodexCommandedAt, ClaimedAt: r.CodexClaimedAt}) } // FireSlot is the single global in-flight reservation: at most one review From 51140343e4b7470d742665aee3715cd2e85c7922 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 04:43:36 +0200 Subject: [PATCH 03/31] Iterate co-reviewers in the engine's fire and completion surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of the co-reviewer abstraction (Ref #34): the dynamic completion gate, the clean-summary binding, and the CodeRabbit-clean convergence guard now run per registered co-reviewer; completed check runs count as head review evidence (step 2b — how a silent-clean Bugbot round converges). DecideFire collects PostCo across all always-mode bots, the deferred degrade and the reviewed-head dedupe resolve per bot, and the verdicts rename to FireCoOnly/FireCoDeferred with Codex-named aliases. An approvability verdict counts as round participation, never as completion evidence. New table tests cover the trigger-mode matrix, check-evidence convergence, and multi-bot PostCo. --- internal/engine/completion.go | 115 ++++++++----- internal/engine/coreview.go | 26 ++- internal/engine/coreview_test.go | 248 ++++++++++++++++++++++++++++ internal/engine/fire.go | 270 +++++++++++++++++++++---------- 4 files changed, 528 insertions(+), 131 deletions(-) create mode 100644 internal/engine/coreview_test.go diff --git a/internal/engine/completion.go b/internal/engine/completion.go index d95a65c..f01489c 100644 --- a/internal/engine/completion.go +++ b/internal/engine/completion.go @@ -22,12 +22,15 @@ type CompletionStatus struct { // // 1. A submitted review whose commit prefixes the head counts; with no head // to match, submission at/after the fire counts (reviewedByForRound). -// 2. Codex's clean summary counts when its "Reviewed commit" SHA matches the -// head, or — SHA-less legacy format — when posted at/after the fire. +// 2. A co-reviewer's clean summary counts when its "Reviewed commit" SHA +// matches the head, or — SHA-less legacy format — when posted at/after +// the fire. 2b: a completed co-reviewer check run for the head counts +// (Bugbot's clean rounds exist ONLY as a check run). // 3. CodeRabbit's clean-review summary counts when posted at/after the fire, -// gated on Codex being inactive or thumbed-up (codexInactiveOrThumbed): -// if Codex gates or participates in this round, its silence must not let -// the round converge on CodeRabbit's word alone. +// gated on every gating co-reviewer being satisfied +// (coReviewersSatisfied): if a co-bot gates or participates in this +// round, its silence must not let the round converge on CodeRabbit's +// word alone. // 4. The completion-reply fallback: a "Review finished." reply pairs to this // round's command and stands in for a no-findings re-review — only if the // bot has ANY prior submitted review, the pairing is chronologically @@ -46,15 +49,23 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { cutoff = r.FiredAt.UTC() } - // Dynamic Codex gate: a fired round that Codex participates in — reviewing the - // PR on its own (CodexAutoActive) or acting this round (CodexActiveThisRound) — - // waits for Codex too, even when Codex is not configured-required, so its - // findings are not skipped. A usage-limit exhaustion notice disengages this - // gate (Codex cannot finish this round); configured-required Codex is left to - // the wait deadline as before. - if r.FiredAt != nil && !dialect.HasCodexBot(p.RequiredBots) && - (obs.CodexAutoActive || obs.CodexActiveThisRound) && !codexUsageLimitedSince(obs, cutoff) { - reviewedBy[codexBot] = false + // Dynamic co-reviewer gate: a fired round that a co-bot participates in — + // reviewing the PR on its own (AutoActive) or acting this round + // (ActiveThisRound) — waits for that bot too, even when it is not + // configured-required, so its findings are not skipped. An unable notice + // (Codex's usage-limit exhaustion) disengages this gate (the bot cannot + // finish this round); configured-required bots are left to the wait + // deadline as before. + if r.FiredAt != nil { + for _, cp := range p.coReviewers() { + if requiredBot(p, cp.Login) { + continue + } + co := obs.co(cp.Login) + if (co.AutoActive || co.ActiveThisRound) && !coUnableSince(obs, cp.Login, cutoff) { + reviewedBy[cp.Login] = false + } + } } // 1. Submitted reviews. @@ -70,30 +81,44 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { } } - // 2. Codex clean-summary issue comments. + // 2. Co-reviewer clean-summary issue comments. for _, ev := range obs.Events { - if ev.Kind != dialect.EvCodexClean { + if ev.Kind != dialect.EvCoClean { continue } + login := ev.For + if login == "" { + login = ev.Bot + } if ev.SHA != "" { // The newer format names the reviewed commit — bind on that SHA // directly. A summary for another commit never counts, and a // matching one counts even when the round anchor was lost. if r.Head != "" && dialect.SHAPrefixMatch(ev.SHA, r.Head) { - markReviewed(reviewedBy, ev.Bot) + markReviewed(reviewedBy, login) } continue } - // SHA-less summaries bind from the Codex command time when crq posted - // it before the (deferred) CodeRabbit fire — see codexCutoff. - if (r.FiredAt != nil || r.CodexCommandedAt != nil) && - notBefore(ev.ObservedTime(), codexCutoff(r)) { - markReviewed(reviewedBy, ev.Bot) + // SHA-less summaries bind from the co-bot command time when crq posted + // it before the (deferred) CodeRabbit fire — see coCutoff. + if (r.FiredAt != nil || roundCoCommandedAt(r, login) != nil) && + notBefore(ev.ObservedTime(), coCutoff(r, login)) { + markReviewed(reviewedBy, login) + } + } + + // 2b. Completed co-reviewer check runs. Head-scoped by construction, so a + // Done/DoneClean verdict IS that bot's review of this head — this is how a + // silent-clean Bugbot round converges (findings, if any, gate via threads + // in the feedback layer, so counting Done here cannot skip them). + for _, c := range obs.Checks { + if c.Verdict == dialect.CheckDone || c.Verdict == dialect.CheckDoneClean { + markReviewed(reviewedBy, c.Bot) } } // A Codex thumbs-up stands in for its review whenever Codex gates the round. - // codexInactiveOrThumbed also consumes it on the CodeRabbit clean-summary path; + // coReviewersSatisfied also consumes it on the CodeRabbit clean-summary path; // marking it here covers the case where CodeRabbit submitted a real review, so // step 3 never runs — otherwise a thumbs-up would engage the dynamic gate // without being able to satisfy it. @@ -101,13 +126,13 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { markReviewed(reviewedBy, codexBot) } - // 3. CodeRabbit clean-review summary, Codex-gated. + // 3. CodeRabbit clean-review summary, co-reviewer-gated. if r.FiredAt != nil { for _, ev := range obs.Events { if ev.Kind != dialect.EvNoAction || !sameBot(ev.Bot, p.Bot) || !notBefore(ev.ObservedTime(), cutoff) { continue } - if codexInactiveOrThumbed(r, obs, p, cutoff, reviewedBy) { + if coReviewersSatisfied(r, obs, p, cutoff, reviewedBy) { markReviewed(reviewedBy, ev.Bot) } } @@ -122,24 +147,30 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { return CompletionStatus{ReviewedBy: reviewedBy, Done: allReviewed(reviewedBy)} } -// codexInactiveOrThumbed ports v2's rule: CodeRabbit's clean summary may only -// converge the round when Codex either has already reviewed, was never active -// on this round, or has thumbed the round up. A thumbs-up also counts as -// Codex's review. -func codexInactiveOrThumbed(r state.Round, obs Observation, p Policy, cutoff time.Time, reviewedBy map[string]bool) bool { - if reviewedByBot(reviewedBy, codexBot) || codexReviewedRound(r, obs, cutoff) { - return true - } - // Codex either gates by configuration, or participates via a round-window - // comment/clean summary; its notices (usage limits, acks) do not count. - if !dialect.HasCodexBot(p.RequiredBots) && !codexCommentedRound(obs, cutoff) { - return true - } - if obs.CodexThumbsUp { - markReviewed(reviewedBy, codexBot) - return true +// coReviewersSatisfied generalizes v2's codexInactiveOrThumbed rule to every +// registered co-reviewer: CodeRabbit's clean summary may only converge the +// round when each co-bot either has already reviewed, was never active on +// this round, or (Codex only) has thumbed the round up. A thumbs-up also +// counts as Codex's review. A completed check run already marked its bot +// reviewed in step 2b, so it satisfies the first clause here. +func coReviewersSatisfied(r state.Round, obs Observation, p Policy, cutoff time.Time, reviewedBy map[string]bool) bool { + for _, cp := range p.coReviewers() { + if reviewedByBot(reviewedBy, cp.Login) || coReviewedRound(r, obs, cp.Login, cutoff) { + continue + } + // The bot either gates by configuration, or participates via a + // round-window comment/clean summary; its notices (unable, acks, + // verdicts) do not count. + if !requiredBot(p, cp.Login) && !coCommentedRound(obs, cp.Login, cutoff) { + continue + } + if dialect.IsCodexBot(cp.Login) && obs.CodexThumbsUp { + markReviewed(reviewedBy, codexBot) + continue + } + return false } - return false + return true } // completionReplyForRound ports v2's completionReplyForFiredCommand: replies diff --git a/internal/engine/coreview.go b/internal/engine/coreview.go index 1397b25..f46cc92 100644 --- a/internal/engine/coreview.go +++ b/internal/engine/coreview.go @@ -173,13 +173,29 @@ func coReviewedHead(obs Observation, login string) bool { } // CoActiveThisRound reports whether login shows activity bound to this round — -// a head review, a round-window comment/clean summary, or any head check run. -// observe() stores it on the Observation so the dynamic completion gate -// requires the bot when it participates without being configured-required. -// (Codex's thumbs-up quirk is layered on by CodexActiveThisRound.) +// a head review, a round-window comment/clean summary, a per-round verdict +// comment, or any head check run. observe() stores it on the Observation so +// the dynamic completion gate requires the bot when it participates without +// being configured-required. (Codex's thumbs-up quirk is layered on by +// CodexActiveThisRound.) func CoActiveThisRound(r state.Round, obs Observation, login string) bool { cutoff := coCutoff(r, login) - return coReviewedRound(r, obs, login, cutoff) || coCommentedRound(obs, login, cutoff) || coCheckAny(obs, login) + return coReviewedRound(r, obs, login, cutoff) || coCommentedRound(obs, login, cutoff) || + coVerdictSince(obs, login, cutoff) || coCheckAny(obs, login) +} + +// coVerdictSince reports a per-round verdict comment (Macroscope's +// Approvability) by login at/after since. A verdict is round PARTICIPATION — +// it engages the dynamic gate so the bot's review is waited for — but never +// completion evidence: only reviews, clean summaries, and completed checks +// mark a bot reviewed. +func coVerdictSince(obs Observation, login string, since time.Time) bool { + for _, ev := range obs.Events { + if ev.Kind == dialect.EvCoVerdict && eventConcerns(ev, login) && notBefore(ev.ObservedTime(), since) { + return true + } + } + return false } // CoAutoActive reports whether login reviews this PR on its own right now: its diff --git a/internal/engine/coreview_test.go b/internal/engine/coreview_test.go new file mode 100644 index 0000000..9e12a24 --- /dev/null +++ b/internal/engine/coreview_test.go @@ -0,0 +1,248 @@ +package engine + +import ( + "testing" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +const ( + bugbotLogin = dialect.BugbotLogin + macroLogin = dialect.MacroscopeLogin +) + +// TestDecideCoPostTriggerMatrix is the trigger-mode decision matrix: +// never/selfheal/always crossed with the bot's observed activity, plus the +// mode-independent guards (already commanded, live command, head evidence, +// check run present including in-progress). +func TestDecideCoPostTriggerMatrix(t *testing.T) { + now := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + head := "abcdef123" + grace := 10 * time.Minute + freshAnchor := now.Add(-time.Minute) + staleAnchor := now.Add(-time.Hour) + + policy := func(mode TriggerMode) CoReviewerPolicy { + return CoReviewerPolicy{Login: bugbotLogin, Command: "bugbot run", Trigger: mode, SelfHealGrace: grace} + } + obsWith := func(co CoSeen, checks ...CheckSeen) Observation { + return Observation{Head: head, Open: true, Checks: checks, + Co: map[string]CoSeen{dialect.NormalizeBotName(bugbotLogin): co}} + } + commanded := state.Round{Head: head} + commanded.SetCoCommand(bugbotLogin, 42, now.Add(-time.Hour)) + + cases := []struct { + name string + cp CoReviewerPolicy + round state.Round + obs Observation + commandPresent bool + anchor time.Time + want bool + }{ + {name: "never posts nothing even when silent", cp: policy(TriggerNever), obs: obsWith(CoSeen{}), anchor: staleAnchor, want: false}, + {name: "always posts for a silent bot", cp: policy(TriggerAlways), obs: obsWith(CoSeen{}), want: true}, + {name: "always defers to auto-review", cp: policy(TriggerAlways), obs: obsWith(CoSeen{AutoActive: true}), want: false}, + {name: "always respects a live command", cp: policy(TriggerAlways), obs: obsWith(CoSeen{}), commandPresent: true, want: false}, + {name: "always respects the recorded round command", cp: policy(TriggerAlways), round: commanded, obs: obsWith(CoSeen{}), want: false}, + { + name: "always never double-reviews a completed check", + cp: policy(TriggerAlways), + obs: obsWith(CoSeen{}, CheckSeen{Bot: bugbotLogin, Verdict: dialect.CheckDoneClean, CompletedAt: now}), + want: false, + }, + { + name: "always waits out an in-progress check", + cp: policy(TriggerAlways), + obs: obsWith(CoSeen{}, CheckSeen{Bot: bugbotLogin, Verdict: dialect.CheckInProgress}), + want: false, + }, + { + name: "always ignores another bot's check", + cp: policy(TriggerAlways), + obs: obsWith(CoSeen{}, CheckSeen{Bot: macroLogin, Verdict: dialect.CheckDone, CompletedAt: now}), + want: true, + }, + {name: "selfheal stays quiet for an inactive bot", cp: policy(TriggerSelfHeal), obs: obsWith(CoSeen{}), anchor: staleAnchor, want: false}, + {name: "selfheal posts for an active bot that missed the head past grace", cp: policy(TriggerSelfHeal), obs: obsWith(CoSeen{AutoActive: true}), anchor: staleAnchor, want: true}, + {name: "selfheal counts round activity as active", cp: policy(TriggerSelfHeal), obs: obsWith(CoSeen{ActiveThisRound: true}), anchor: staleAnchor, want: true}, + {name: "selfheal waits out the grace period", cp: policy(TriggerSelfHeal), obs: obsWith(CoSeen{AutoActive: true}), anchor: freshAnchor, want: false}, + {name: "selfheal needs an anchor", cp: policy(TriggerSelfHeal), obs: obsWith(CoSeen{AutoActive: true}), want: false}, + { + name: "selfheal never re-triggers a bot with head evidence", + cp: policy(TriggerSelfHeal), + obs: Observation{Head: head, Open: true, + Reviews: []ReviewSeen{{Bot: bugbotLogin, Commit: "abcdef1234567890", SubmittedAt: now.Add(-time.Minute)}}, + Co: map[string]CoSeen{dialect.NormalizeBotName(bugbotLogin): {AutoActive: true}}}, + anchor: staleAnchor, want: false, + }, + { + name: "selfheal suppressed by an in-progress check", + cp: policy(TriggerSelfHeal), + obs: obsWith(CoSeen{AutoActive: true}, CheckSeen{Bot: bugbotLogin, Verdict: dialect.CheckInProgress}), + anchor: staleAnchor, want: false, + }, + {name: "no command configured never posts", cp: CoReviewerPolicy{Login: bugbotLogin, Trigger: TriggerAlways}, obs: obsWith(CoSeen{}), want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.round.Head == "" { + tc.round = state.Round{Head: head} + } + if got := DecideCoPost(tc.round, tc.obs, tc.cp, tc.commandPresent, tc.anchor, now); got != tc.want { + t.Fatalf("DecideCoPost = %v, want %v", got, tc.want) + } + }) + } +} + +// TestCompletionCheckEvidence pins step 2b and the generic dynamic gate: a +// required check-bearing bot converges on its completed check run alone (the +// silent-clean Bugbot round), an in-progress check does not, and a +// wanted-only bot gates exactly when observed active. +func TestCompletionCheckEvidence(t *testing.T) { + fired := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + head := "abcdef123" + round := state.Round{Head: head, FiredAt: &fired} + crReview := ReviewSeen{Bot: "coderabbitai[bot]", Commit: "abcdef1234567890", SubmittedAt: fired.Add(time.Minute)} + bugbotKey := dialect.NormalizeBotName(bugbotLogin) + + required := Policy{Bot: "coderabbitai[bot]", + RequiredBots: []string{"coderabbitai[bot]", bugbotLogin}, + CoReviewers: []CoReviewerPolicy{{Login: bugbotLogin, Command: "bugbot run", Trigger: TriggerSelfHeal}}} + + // Required Bugbot, no evidence: not done. + silent := Observation{Head: head, Open: true, Reviews: []ReviewSeen{crReview}} + if got := Completion(round, silent, required); got.Done { + t.Fatalf("required bugbot with no evidence must gate: %+v", got) + } + // Its clean check run alone converges the round. + clean := silent + clean.Checks = []CheckSeen{{Bot: bugbotLogin, Name: "Cursor Bugbot", Verdict: dialect.CheckDoneClean, CompletedAt: fired.Add(2 * time.Minute)}} + if got := Completion(round, clean, required); !got.Done { + t.Fatalf("a completed clean check must converge the silent-clean round: %+v", got) + } + // A completed check with findings converges too — the findings gate via + // threads in the feedback layer, not via Completion. + issues := silent + issues.Checks = []CheckSeen{{Bot: bugbotLogin, Verdict: dialect.CheckDone, CompletedAt: fired.Add(2 * time.Minute)}} + if got := Completion(round, issues, required); !got.Done { + t.Fatalf("a completed check with findings must still mark the bot reviewed: %+v", got) + } + // An in-progress check is not evidence. + running := silent + running.Checks = []CheckSeen{{Bot: bugbotLogin, Verdict: dialect.CheckInProgress}} + if got := Completion(round, running, required); got.Done { + t.Fatalf("an in-progress check must not converge the round: %+v", got) + } + + // Wanted-only (not required): an inactive bot never gates. + wanted := Policy{Bot: "coderabbitai[bot]", + RequiredBots: []string{"coderabbitai[bot]"}, + CoReviewers: []CoReviewerPolicy{{Login: bugbotLogin, Command: "bugbot run", Trigger: TriggerSelfHeal}}} + if got := Completion(round, silent, wanted); !got.Done { + t.Fatalf("a wanted-only inactive bot must not gate: %+v", got) + } + // Observed active this round (in-progress check), it gates dynamically… + activeRunning := running + activeRunning.Co = map[string]CoSeen{bugbotKey: {ActiveThisRound: true}} + if got := Completion(round, activeRunning, wanted); got.Done { + t.Fatalf("an active wanted bot must gate until its evidence lands: %+v", got) + } + // …and its completed check satisfies the dynamic gate. + activeDone := activeRunning + activeDone.Checks = []CheckSeen{{Bot: bugbotLogin, Verdict: dialect.CheckDoneClean, CompletedAt: fired.Add(2 * time.Minute)}} + if got := Completion(round, activeDone, wanted); !got.Done { + t.Fatalf("the active bot's completed check must satisfy the gate: %+v", got) + } +} + +// TestCompletionVerdictIsParticipationOnly: a Macroscope approvability verdict +// engages round participation (CoActiveThisRound) but never counts as review +// evidence. +func TestCompletionVerdictIsParticipationOnly(t *testing.T) { + fired := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + head := "abcdef123" + round := state.Round{Head: head, FiredAt: &fired} + approved := true + verdict := dialect.BotEvent{Kind: dialect.EvCoVerdict, Bot: macroLogin, For: macroLogin, + Approved: &approved, CommentID: 9, CreatedAt: fired.Add(time.Minute), UpdatedAt: fired.Add(time.Minute)} + obs := Observation{Head: head, Open: true, Events: []dialect.BotEvent{verdict}} + + if !CoActiveThisRound(round, obs, macroLogin) { + t.Fatal("a round-window verdict must count as participation") + } + if reviewed := coReviewedHead(obs, macroLogin); reviewed { + t.Fatal("a verdict must never count as head review evidence") + } +} + +// TestDecideFireMultiBotPostCo: multiple always-mode co-reviewers post in one +// fire step, ride the same dedupe resolution, and the deferred degrade carries +// all of them without touching the fire slot. +func TestDecideFireMultiBotPostCo(t *testing.T) { + now := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + head := "abcdef123" + queued := state.Round{Repo: "o/r", PR: 1, Head: head, Phase: state.PhaseQueued} + p := Policy{Bot: "coderabbitai[bot]", + RequiredBots: []string{"coderabbitai[bot]", dialect.CodexBotLogin, bugbotLogin}, + RateLimitCodexDegrade: true, + CoReviewers: []CoReviewerPolicy{ + {Login: dialect.CodexBotLogin, Command: "@codex review", Trigger: TriggerAlways}, + {Login: bugbotLogin, Command: "bugbot run", Trigger: TriggerAlways}, + }} + free := Global{SlotFree: true} + + wantBoth := func(t *testing.T, d FireDecision) { + t.Helper() + if len(d.PostCo) != 2 || !hasCodexLogin(d.PostCo) { + t.Fatalf("PostCo = %v, want codex+bugbot", d.PostCo) + } + if !d.PostCodex { + t.Fatal("PostCodex mirror must be set while codex is in PostCo") + } + } + + d := DecideFire(free, queued, Observation{Head: head, Open: true}, now, p) + if d.Verdict != FirePost { + t.Fatalf("verdict = %v, want FirePost", d.Verdict) + } + wantBoth(t, d) + + // CodeRabbit already reviewed the head → co-only trigger for both. + crReviewed := Observation{Head: head, Open: true, + Reviews: []ReviewSeen{{Bot: "coderabbitai[bot]", Commit: "abcdef1234567890", SubmittedAt: now}}} + d = DecideFire(free, queued, crReviewed, now, p) + if d.Verdict != FireCoOnly { + t.Fatalf("verdict = %v, want FireCoOnly", d.Verdict) + } + wantBoth(t, d) + + // One bot already has head evidence → only the other posts. + partial := crReviewed + partial.Checks = []CheckSeen{{Bot: bugbotLogin, Verdict: dialect.CheckDoneClean, CompletedAt: now}} + d = DecideFire(free, queued, partial, now, p) + if d.Verdict != FireCoOnly || len(d.PostCo) != 1 || !dialect.IsCodexBot(d.PostCo[0]) { + t.Fatalf("expected codex-only PostCo, got %+v", d) + } + + // Account blocked → deferred degrade posts both, slot untouched. + blocked := now.Add(time.Hour) + d = DecideFire(Global{SlotFree: true, BlockedUntil: &blocked}, queued, Observation{Head: head, Open: true}, now, p) + if d.Verdict != FireCoDeferred { + t.Fatalf("verdict = %v, want FireCoDeferred", d.Verdict) + } + wantBoth(t, d) + + // A wanted-only silent bot never forces a wait: with every co-reviewer + // satisfied or inert, the reviewed head dedupes. + inert := Policy{Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]"}, + CoReviewers: []CoReviewerPolicy{{Login: macroLogin, Command: "", Trigger: TriggerSelfHeal}}} + d = DecideFire(free, queued, crReviewed, now, inert) + if d.Verdict != FireDedupe { + t.Fatalf("verdict = %v, want FireDedupe for a wanted-only silent bot", d.Verdict) + } +} diff --git a/internal/engine/fire.go b/internal/engine/fire.go index bc2e2df..25b9b70 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -14,25 +14,41 @@ import ( type FireVerdict int const ( - FireNo FireVerdict = iota // skip this pass (Reason says why) - FirePost // reserve the slot and post the command - FireAdopt // a command is already on the PR — adopt it - FireDedupe // bot already reviewed this head — complete without firing - FireCodexOnly // CodeRabbit reviewed the head but a required Codex still must — post only the Codex command - FireCoReviewWait // CodeRabbit reviewed the head; a gating co-bot has not — wait for it, bounded, without posting or holding the slot - FireCodexDeferred // account blocked — post only the Codex command now; the round stays queued so CodeRabbit fires when the window opens - FireSupersede // observed head differs — supersede the round first - FireDrop // PR closed/merged — abandon the round + FireNo FireVerdict = iota // skip this pass (Reason says why) + FirePost // reserve the slot and post the command + FireAdopt // a command is already on the PR — adopt it + FireDedupe // bot already reviewed this head — complete without firing + FireCoOnly // CodeRabbit reviewed the head but a gating co-reviewer still must — post only its trigger + FireCoReviewWait // CodeRabbit reviewed the head; a gating co-bot has not — wait for it, bounded, without posting or holding the slot + FireCoDeferred // account blocked — post only co-reviewer triggers now; the round stays queued so CodeRabbit fires when the window opens + FireSupersede // observed head differs — supersede the round first + FireDrop // PR closed/merged — abandon the round +) + +// Deprecated: Codex-named aliases for the generic co-reviewer verdicts, kept +// while crq migrates. +const ( + FireCodexOnly = FireCoOnly + FireCodexDeferred = FireCoDeferred ) type FireDecision struct { Verdict FireVerdict Reason string - // Adopt fields identify the existing command comment (FireAdopt). + // Adopt fields identify the existing command comment (FireAdopt), or the + // adopted Codex command on the legacy FireCoDeferred adopt path. AdoptCommandID int64 AdoptAt time.Time - // PostCodex asks the apply layer to post the Codex review command alongside - // the CodeRabbit one (FirePost/FireAdopt). See DecideCodexPost. + // PostCo lists the co-reviewer logins whose trigger commands the apply + // layer posts alongside this verdict (FirePost/FireAdopt/FireCoOnly/ + // FireCoDeferred). See DecideCoPost. + PostCo []string + // AdoptCo identifies live co-reviewer trigger comments to record as this + // round's command anchors instead of posting duplicates (FireCoDeferred). + AdoptCo map[string]CommandSeen + // PostCodex mirrors "Codex ∈ PostCo". + // + // Deprecated: consume PostCo instead. PostCodex bool } @@ -43,6 +59,49 @@ type Global struct { LastFired *time.Time // global pacing anchor } +// coReviewers resolves the effective co-reviewer policies: the explicit list +// when set, otherwise the one synthesized Codex entry (trigger always iff +// configured-required) that preserves the pre-registry behavior. +func (p Policy) coReviewers() []CoReviewerPolicy { + if p.CoReviewers != nil { + return p.CoReviewers + } + return []CoReviewerPolicy{codexPolicy(p)} +} + +// requiredBot reports whether login is in RequiredBots (normalized). +func requiredBot(p Policy, login string) bool { + norm := dialect.NormalizeBotName(login) + for _, bot := range p.RequiredBots { + if dialect.NormalizeBotName(strings.TrimSpace(bot)) == norm { + return true + } + } + return false +} + +// decideCoPosts collects the co-reviewer logins whose trigger crq should post +// while firing this round. Fire-time posting is the always-mode path; a +// selfheal trigger anchors on the fire and so never posts before it. +func decideCoPosts(r state.Round, obs Observation, p Policy, now time.Time) []string { + var out []string + for _, cp := range p.coReviewers() { + if DecideCoPost(r, obs, cp, len(obs.co(cp.Login).Commands) > 0, time.Time{}, now) { + out = append(out, cp.Login) + } + } + return out +} + +func hasCodexLogin(logins []string) bool { + for _, login := range logins { + if dialect.IsCodexBot(login) { + return true + } + } + return false +} + // DecideFire consolidates v2's scattered fire guards, in order: PR open → // head readable → head current → round eligible (phase + RetryAt cooldown) → // slot free → account quota → global pacing → not already reviewed → adopt @@ -72,15 +131,15 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic } } if !g.SlotFree { - // Codex needs no fire slot: a round parked behind another PR's - // in-flight review can start its Codex round immediately. The round - // stays queued and CodeRabbit fires once the slot frees, with - // CodexCommandID preventing a duplicate Codex post. NOT for a head + // Co-reviewers need no fire slot: a round parked behind another PR's + // in-flight review can start its co-reviewer rounds immediately. The + // round stays queued and CodeRabbit fires once the slot frees, with the + // recorded command ids preventing duplicate posts. NOT for a head // CodeRabbit already reviewed — that round belongs to the dedupe - // resolution below once the slot frees (a queued round Codex answers + // resolution below once the slot frees (a queued round a co-bot answers // clean cannot complete, so deferring it here could wedge the wait). if !reviewedHead { - if d, ok := decideCodexDeferred(r, obs, p, "fire slot busy"); ok { + if d, ok := decideCoDeferred(r, obs, p, now, "fire slot busy"); ok { return d } } @@ -88,22 +147,22 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic } // Belt-and-braces live check: even with a fresh round, never fire at a // head the bot has already reviewed (e.g. state was reinitialized). But a - // CodeRabbit review does not finish a round that a required Codex still - // gates — command (or wait for) Codex instead of deduping it away. This - // resolution runs BEFORE the account-block and pacing gates: none of its - // verdicts spend CodeRabbit quota (dedupe completes, FireCodexOnly posts - // only the Codex command, a co-review wait posts nothing), so an account + // CodeRabbit review does not finish a round that a gating co-reviewer still + // must speak on — command (or wait for) it instead of deduping it away. + // This resolution runs BEFORE the account-block and pacing gates: none of + // its verdicts spend CodeRabbit quota (dedupe completes, FireCoOnly posts + // only co-reviewer triggers, a co-review wait posts nothing), so an account // block from another PR must not delay them. if reviewedHead { - return codexAwareDedupe(r, obs, p) + return coAwareDedupe(r, obs, p, now) } if g.BlockedUntil != nil && g.BlockedUntil.After(now) { // Degrade instead of stalling: the block only gates CodeRabbit quota, - // so ask Codex now and leave the round queued — CodeRabbit still - // fires the moment the window opens. DecideCodexPost's guards - // (command configured, codex required, not auto-active, no live or - // already-posted command) make this idempotent per round. - if d, ok := decideCodexDeferred(r, obs, p, "account blocked"); ok { + // so ask the co-reviewers now and leave the round queued — CodeRabbit + // still fires the moment the window opens. DecideCoPost's guards + // (command configured, no already-posted or live command) make this + // idempotent per round. + if d, ok := decideCoDeferred(r, obs, p, now, "account blocked"); ok { return d } return FireDecision{Verdict: FireNo, Reason: "account blocked until " + g.BlockedUntil.UTC().Format(time.RFC3339)} @@ -111,9 +170,9 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic if g.LastFired != nil && now.Sub(*g.LastFired) < p.MinInterval { return FireDecision{Verdict: FireNo, Reason: "min interval"} } - // crq posts the Codex command in the same fire step for a configured-required - // Codex with no auto-review and no existing command for this head. - postCodex := DecideCodexPost(r, obs, p, len(obs.CodexCommands) > 0) + // crq posts always-mode co-reviewer triggers in the same fire step when the + // bot does not auto-review and no command exists for this head. + postCo := decideCoPosts(r, obs, p, now) // Adopt the newest already-posted command instead of posting a duplicate. // observe() has already applied the adoption cutoffs (LastAttemptAt, // force-push, already-answered). @@ -129,71 +188,114 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic if at.IsZero() { at = newest.UpdatedAt } - return FireDecision{Verdict: FireAdopt, Reason: "review command already posted", AdoptCommandID: newest.ID, AdoptAt: at, PostCodex: postCodex} + return FireDecision{Verdict: FireAdopt, Reason: "review command already posted", AdoptCommandID: newest.ID, AdoptAt: at, PostCo: postCo, PostCodex: hasCodexLogin(postCo)} } - return FireDecision{Verdict: FirePost, PostCodex: postCodex} + return FireDecision{Verdict: FirePost, PostCo: postCo, PostCodex: hasCodexLogin(postCo)} } -// decideCodexDeferred starts or adopts the Codex half of a round while -// CodeRabbit cannot fire. CodexCommands is cutoff-filtered by observe(), so an -// existing command here is safe to bind to this head and must be recorded as -// the round anchor rather than merely suppressing a duplicate post. -func decideCodexDeferred(r state.Round, obs Observation, p Policy, reason string) (FireDecision, bool) { - if !p.RateLimitCodexDegrade || r.CodexCommandID != 0 { +// decideCoDeferred starts or adopts the co-reviewer half of a round while +// CodeRabbit cannot fire. Each co-bot's Commands are cutoff-filtered by +// observe(), so an existing command here is safe to bind to this head and must +// be recorded as the round anchor rather than merely suppressing a duplicate +// post. The legacy Adopt fields mirror the Codex entry for pre-migration +// consumers. +func decideCoDeferred(r state.Round, obs Observation, p Policy, now time.Time, reason string) (FireDecision, bool) { + if !p.RateLimitCodexDegrade { return FireDecision{}, false } - if DecideCodexPost(r, obs, p, len(obs.CodexCommands) > 0) { - return FireDecision{ - Verdict: FireCodexDeferred, - Reason: reason + "; requesting codex review now, coderabbit deferred", - PostCodex: true, - }, true + var post []string + adopt := map[string]CommandSeen{} + for _, cp := range p.coReviewers() { + if roundCoCommandID(r, cp.Login) != 0 { + continue + } + commands := obs.co(cp.Login).Commands + if DecideCoPost(r, obs, cp, len(commands) > 0, time.Time{}, now) { + post = append(post, cp.Login) + continue + } + // Only a trigger-capable bot may anchor the deferred round on an + // adopted command; for the rest a live human command is just ambient. + if cp.Trigger != TriggerAlways { + continue + } + if newest := newestCommand(commands); newest != nil { + adopt[cp.Login] = *newest + } + } + if len(post) == 0 && len(adopt) == 0 { + return FireDecision{}, false + } + d := FireDecision{Verdict: FireCoDeferred, PostCo: post, PostCodex: hasCodexLogin(post)} + if len(adopt) > 0 { + d.AdoptCo = adopt } + switch { + case len(post) > 0 && len(adopt) > 0: + d.Reason = reason + "; requesting/adopting co-reviews now, coderabbit deferred" + case len(post) > 0: + d.Reason = reason + "; requesting co-review now, coderabbit deferred" + default: + d.Reason = reason + "; adopting existing co-review command, coderabbit deferred" + } + if cmd, ok := adopt[dialect.CodexBotLogin]; ok { + at := cmd.CreatedAt + if at.IsZero() { + at = cmd.UpdatedAt + } + d.AdoptCommandID = cmd.ID + d.AdoptAt = at + } + return d, true +} + +func newestCommand(commands []CommandSeen) *CommandSeen { var newest *CommandSeen - for i := range obs.CodexCommands { - cmd := &obs.CodexCommands[i] + for i := range commands { + cmd := &commands[i] if newest == nil || cmd.CreatedAt.After(newest.CreatedAt) { newest = cmd } } - if newest == nil { - return FireDecision{}, false - } - at := newest.CreatedAt - if at.IsZero() { - at = newest.UpdatedAt - } - return FireDecision{ - Verdict: FireCodexDeferred, - Reason: reason + "; adopting existing codex review command, coderabbit deferred", - AdoptCommandID: newest.ID, - AdoptAt: at, - }, true + return newest } -// codexAwareDedupe resolves what to do when CodeRabbit already reviewed the head. -// If no gating Codex is still outstanding, the round is genuinely done (FireDedupe). -// If a required-or-auto-active Codex has no review of this head yet, the round is -// not done: post only the Codex command when crq may (FireCodexOnly). When crq may -// not post but Codex will still produce evidence on its own — it auto-reviews, or a -// command is already on the PR awaiting its answer — wait for it, bounded, without -// posting or holding the slot (FireCoReviewWait); leaving the round queued with no -// deadline is the bug that hangs the loop forever. Only when Codex gates purely by -// configuration with no way to obtain its review (no command configured/on the PR -// and no auto-review) fall back to completing on CodeRabbit's review; the feedback -// gate then surfaces Codex as still pending rather than the round wedging in an -// un-timed fire loop. Completion counts the existing CodeRabbit review, so a -// FireCodexOnly round waits on Codex alone. -func codexAwareDedupe(r state.Round, obs Observation, p Policy) FireDecision { - codexGates := dialect.HasCodexBot(p.RequiredBots) || obs.CodexAutoActive - if !codexGates || codexReviewedHead(obs) { - return FireDecision{Verdict: FireDedupe, Reason: "bot already reviewed head"} - } - if DecideCodexPost(r, obs, p, len(obs.CodexCommands) > 0) { - return FireDecision{Verdict: FireCodexOnly, Reason: "coderabbit reviewed head; codex still required"} - } - if obs.CodexAutoActive || len(obs.CodexCommands) > 0 || r.CodexCommandID != 0 { - return FireDecision{Verdict: FireCoReviewWait, Reason: "awaiting codex co-review"} +// coAwareDedupe resolves what to do when CodeRabbit already reviewed the head. +// If no gating co-reviewer is still outstanding, the round is genuinely done +// (FireDedupe). If a required-or-auto-active co-bot has no review of this head +// yet, the round is not done: post the triggers crq may (FireCoOnly). When crq +// may not post but the bot will still produce evidence on its own — it +// auto-reviews, or a command is already on the PR awaiting its answer — wait +// for it, bounded, without posting or holding the slot (FireCoReviewWait); +// leaving the round queued with no deadline is the bug that hangs the loop +// forever. Only when a co-bot gates purely by configuration with no way to +// obtain its review (no command configured/on the PR and no auto-review) fall +// back to completing on CodeRabbit's review; the feedback gate then surfaces +// it as still pending rather than the round wedging in an un-timed fire loop. +// Completion counts the existing CodeRabbit review, so a FireCoOnly round +// waits on the co-reviewers alone. +func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time) FireDecision { + var post []string + wait := false + for _, cp := range p.coReviewers() { + co := obs.co(cp.Login) + gates := requiredBot(p, cp.Login) || co.AutoActive + if !gates || coReviewedHead(obs, cp.Login) { + continue + } + if DecideCoPost(r, obs, cp, len(co.Commands) > 0, time.Time{}, now) { + post = append(post, cp.Login) + continue + } + if co.AutoActive || len(co.Commands) > 0 || roundCoCommandID(r, cp.Login) != 0 { + wait = true + } + } + if len(post) > 0 { + return FireDecision{Verdict: FireCoOnly, Reason: "coderabbit reviewed head; co-review still required", PostCo: post, PostCodex: hasCodexLogin(post)} + } + if wait { + return FireDecision{Verdict: FireCoReviewWait, Reason: "awaiting co-review"} } return FireDecision{Verdict: FireDedupe, Reason: "bot already reviewed head"} } From 8e9f0c2d41917aa909728b618fc37f9a250828ae Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 04:45:55 +0200 Subject: [PATCH 04/31] Fetch commit check runs through the gh transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5 of the co-reviewer abstraction (Ref #34): ListCheckRuns pages the {total_count, check_runs} envelope over the existing send/nextPage plumbing, so it rides the ETag cache and throttle handling for free. The transport stays bot-agnostic — matching runs to a co-reviewer is dialect's ClassifyCheckRun. Added to the crq GitHubAPI interface and the test fake. --- internal/crq/service.go | 1 + internal/crq/service_test.go | 28 +++++++++++ internal/gh/checks.go | 70 +++++++++++++++++++++++++++ internal/gh/github_test.go | 91 ++++++++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 internal/gh/checks.go diff --git a/internal/crq/service.go b/internal/crq/service.go index cfbfd16..1285dde 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -30,6 +30,7 @@ type GitHubAPI interface { ListReviewComments(context.Context, string, int) ([]ghapi.ReviewComment, error) ListIssueReactions(context.Context, string, int) ([]ghapi.Reaction, error) ListCommentReactions(context.Context, string, int64) ([]ghapi.Reaction, error) + ListCheckRuns(context.Context, string, string) ([]ghapi.CheckRun, error) PostIssueComment(context.Context, string, int, string) (ghapi.IssueComment, error) DeleteIssueComment(context.Context, string, int64) error CreateIssue(context.Context, string, string, string) (ghapi.Issue, error) diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 662706f..b82ae3f 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -25,6 +25,8 @@ type fakeGitHub struct { reviewComments map[string][]ghapi.ReviewComment issueReactions map[string][]ghapi.Reaction reactions map[int64][]ghapi.Reaction + checkRuns map[string][]ghapi.CheckRun // key: ref (short or full sha) + checkRunErrs map[string]error posted []string deleted []int64 commentID int64 @@ -61,6 +63,32 @@ func newFakeGitHub() *fakeGitHub { func fakeKey(repo string, pr int) string { return QueueKey(repo, pr) } +// ListCheckRuns serves the runs stored under the requested ref, tolerating a +// stored short-SHA key for a full-SHA request and vice versa (observe fetches +// by the pull's full head SHA). +func (f *fakeGitHub) ListCheckRuns(_ context.Context, _ string, ref string) ([]ghapi.CheckRun, error) { + f.mu.Lock() + defer f.mu.Unlock() + if err := f.checkRunErrs[ref]; err != nil { + return nil, err + } + for key, runs := range f.checkRuns { + if strings.HasPrefix(ref, key) || strings.HasPrefix(key, ref) { + return append([]ghapi.CheckRun(nil), runs...), nil + } + } + return nil, nil +} + +func (f *fakeGitHub) setCheckRuns(ref string, runs ...ghapi.CheckRun) { + f.mu.Lock() + defer f.mu.Unlock() + if f.checkRuns == nil { + f.checkRuns = map[string][]ghapi.CheckRun{} + } + f.checkRuns[ref] = runs +} + func (f *fakeGitHub) GetPull(_ context.Context, repo string, pr int) (ghapi.Pull, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/gh/checks.go b/internal/gh/checks.go new file mode 100644 index 0000000..cae8b6a --- /dev/null +++ b/internal/gh/checks.go @@ -0,0 +1,70 @@ +package gh + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// CheckRun is one check run on a commit, reduced to what crq consumes. The +// bot-agnostic transport fetches ALL runs for a ref; matching them to a +// co-reviewer (by app slug / name) is dialect's job — server-side filtering +// cannot express Macroscope's repo-custom check names. +type CheckRun struct { + ID int64 `json:"id"` + Name string `json:"name"` + HeadSHA string `json:"head_sha"` + Status string `json:"status"` // queued | in_progress | completed + Conclusion string `json:"conclusion"` // success | neutral | failure | skipped | … ("" while running) + StartedAt time.Time `json:"started_at"` + CompletedAt time.Time `json:"completed_at"` // zero while running + Output struct { + Title string `json:"title"` + Summary string `json:"summary"` + } `json:"output"` + App struct { + Slug string `json:"slug"` + } `json:"app"` +} + +// ListCheckRuns fetches every check run for a commit ref. The endpoint wraps +// its pages in a {total_count, check_runs} envelope, which requestPaged's +// bare-slice decoding cannot express — hence this envelope-aware page loop +// over the same send/nextPage plumbing (so it rides the ETag cache and +// throttle handling for free; repeat polls of an unchanged ref are 304s). +func (g *GitHub) ListCheckRuns(ctx context.Context, repo, ref string) ([]CheckRun, error) { + next := g.apiBase + fmt.Sprintf("/repos/%s/commits/%s/check-runs?per_page=100", repoPath(repo), url.PathEscape(ref)) + var out []CheckRun + for next != "" { + resp, err := g.send(ctx, http.MethodGet, next, nil) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusNotFound { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return nil, ErrNotFound + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + return nil, &APIError{Method: http.MethodGet, URL: next, Status: resp.StatusCode, Body: string(b)} + } + var envelope struct { + CheckRuns []CheckRun `json:"check_runs"` + } + decodeErr := json.NewDecoder(resp.Body).Decode(&envelope) + link := resp.Header.Get("Link") + resp.Body.Close() + if decodeErr != nil { + return nil, decodeErr + } + out = append(out, envelope.CheckRuns...) + next = nextPage(link) + } + return out, nil +} diff --git a/internal/gh/github_test.go b/internal/gh/github_test.go index b94c6cc..da9ef88 100644 --- a/internal/gh/github_test.go +++ b/internal/gh/github_test.go @@ -598,3 +598,94 @@ func TestRequestPagedFollowsLinksThroughETagCache(t *testing.T) { t.Fatalf("expected 4 upstream requests (2 fresh + 2 revalidations), got %d", got) } } + +// TestListCheckRunsEnvelopePagination: the check-runs endpoint wraps pages in +// a {total_count, check_runs} envelope; the loop must follow Link headers, +// concatenate pages, and surface a missing ref as ErrNotFound. +func TestListCheckRunsEnvelopePagination(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "test-token") + var srvURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/commits/gone/"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"Not Found"}`)) + case r.URL.Query().Get("page") == "2": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"total_count":3,"check_runs":[{"id":3,"name":"Macroscope - Correctness Check","status":"completed","conclusion":"neutral","app":{"slug":"macroscopeapp"},"output":{"title":"1 issue identified (5 code objects reviewed).","summary":""}}]}`)) + default: + w.Header().Set("Link", `<`+srvURL+r.URL.Path+`?per_page=100&page=2>; rel="next"`) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"total_count":3,"check_runs":[{"id":1,"name":"Cursor Bugbot","status":"completed","conclusion":"success","app":{"slug":"cursor"},"output":{"title":"Bugbot Review","summary":"no issues found!"}},{"id":2,"name":"CI","status":"in_progress","conclusion":null,"completed_at":null,"app":{"slug":"github-actions"}}]}`)) + } + })) + defer srv.Close() + srvURL = srv.URL + + g := &GitHub{ + token: "test-token", + httpClient: srv.Client(), + apiBase: srv.URL, + maxRetries: 2, + maxWait: time.Second, + backoffBase: time.Millisecond, + } + runs, err := g.ListCheckRuns(context.Background(), "o/r", "abc123") + if err != nil { + t.Fatalf("ListCheckRuns: %v", err) + } + if len(runs) != 3 || runs[0].ID != 1 || runs[2].ID != 3 { + t.Fatalf("expected 3 runs across pages, got %+v", runs) + } + if runs[0].App.Slug != "cursor" || runs[0].Output.Summary != "no issues found!" { + t.Fatalf("run fields not decoded: %+v", runs[0]) + } + if !runs[1].CompletedAt.IsZero() || runs[1].Conclusion != "" { + t.Fatalf("null conclusion/completed_at must decode to zero values: %+v", runs[1]) + } + if runs[2].Name != "Macroscope - Correctness Check" { + t.Fatalf("page 2 not appended: %+v", runs[2]) + } + + if _, err := g.ListCheckRuns(context.Background(), "o/r", "gone"); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing ref must map to ErrNotFound, got %v", err) + } +} + +// TestListCheckRunsRidesETagCache: a 304 revalidation must replay the cached +// envelope instead of failing or double-charging the REST quota. +func TestListCheckRunsRidesETagCache(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "test-token") + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if atomic.AddInt32(&calls, 1) > 1 && r.Header.Get("If-None-Match") == `"v1"` { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("ETag", `"v1"`) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"total_count":1,"check_runs":[{"id":9,"name":"Cursor Bugbot","status":"in_progress","app":{"slug":"cursor"}}]}`)) + })) + defer srv.Close() + + g := &GitHub{ + token: "test-token", + httpClient: srv.Client(), + apiBase: srv.URL, + maxRetries: 2, + maxWait: time.Second, + backoffBase: time.Millisecond, + } + for i := 0; i < 2; i++ { + runs, err := g.ListCheckRuns(context.Background(), "o/r", "abc123") + if err != nil { + t.Fatalf("ListCheckRuns pass %d: %v", i, err) + } + if len(runs) != 1 || runs[0].ID != 9 { + t.Fatalf("pass %d: got %+v", i, runs) + } + } + if got := atomic.LoadInt32(&calls); got != 2 { + t.Fatalf("expected 2 HTTP calls (200 then 304), got %d", got) + } +} From 28eb86d5a5b283dde0ab101ade8b7f9db8c0f272 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 04:49:40 +0200 Subject: [PATCH 05/31] Parse per-co-reviewer configuration Step 6 of the co-reviewer abstraction (Ref #34): CRQ_COBOTS (default codex,bugbot,macroscope; explicitly empty disables all) plus per-bot CRQ_COBOT__{REQUIRED,TRIGGER,CMD,GRACE} keys resolve into Config.CoBots. Required co-bots fold into RequiredBots; enabled logins join the FeedbackBots default (replacing the hard-coded Codex extra); CRQ_CODEX_CMD stays as the alias of CRQ_COBOT_CODEX_CMD, and CRQ_RL_CO_DEGRADE aliases CRQ_RL_CODEX_DEGRADE. Codex keeps its historical defaults (trigger always iff required); Bugbot/Macroscope default to selfheal. --- internal/crq/config.go | 140 +++++++++++++++++++++--- internal/crq/config_test.go | 196 ++++++++++++++++++++++++++++++++++ internal/crq/feedback_test.go | 10 +- 3 files changed, 329 insertions(+), 17 deletions(-) diff --git a/internal/crq/config.go b/internal/crq/config.go index 7bb4b62..dc29bd7 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -10,6 +10,7 @@ import ( "time" "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) @@ -38,8 +39,15 @@ type Config struct { RequiredBots []string FeedbackBots []string ReviewCommand string + // CoBots are the enabled co-reviewer bots (CRQ_COBOTS + per-bot + // CRQ_COBOT__* keys). An entry exists for every wanted or required + // co-reviewer; required ones are already folded into RequiredBots. + CoBots []CoBotConfig // CodexCommand is the Codex review trigger crq posts when Codex gates a round - // and does not auto-review (CRQ_CODEX_CMD). Empty disables all Codex firing. + // and does not auto-review. Empty disables all Codex firing. + // + // Deprecated: derived from the codex CoBots entry (CRQ_COBOT_CODEX_CMD, + // alias CRQ_CODEX_CMD) for callers that predate CoBots. CodexCommand string RateLimitCommand string RateLimitMarker string @@ -103,6 +111,24 @@ func LoadConfig() (Config, error) { host, _ := os.Hostname() bot := stringEnv(env, "CRQ_BOT", "coderabbitai[bot]") requiredBots := listEnv(env, "CRQ_REQUIRED_BOTS", bot) + coBots := parseCoBots(env, requiredBots) + // A required co-reviewer gates convergence via RequiredBots membership — + // that list stays the single source of required-ness. + for _, cb := range coBots { + if cb.Required { + requiredBots = unionBots(requiredBots, []string{cb.Login}) + } + } + // Enabled co-reviewers surface findings without gating: their logins join + // the feedback set unless CRQ_FEEDBACK_BOTS overrides explicitly. + coLogins := make([]string, 0, len(coBots)) + codexCommand := "" + for _, cb := range coBots { + coLogins = append(coLogins, cb.Login) + if cb.Name == "codex" { + codexCommand = cb.Command + } + } cfg := Config{ GateRepo: env["CRQ_REPO"], DashboardIssue: intEnv(env, "CRQ_ISSUE", 0), @@ -115,9 +141,10 @@ func LoadConfig() (Config, error) { StateRef: stringEnv(env, "CRQ_STATE_REF", "crq-state-v3"), Bot: bot, RequiredBots: requiredBots, - FeedbackBots: listEnv(env, "CRQ_FEEDBACK_BOTS", strings.Join(unionBots(requiredBots, extraFeedbackBots), ",")), + CoBots: coBots, + FeedbackBots: listEnv(env, "CRQ_FEEDBACK_BOTS", strings.Join(unionBots(requiredBots, coLogins), ",")), ReviewCommand: stringEnv(env, "CRQ_REVIEW_CMD", "@coderabbitai review"), - CodexCommand: stringEnvAllowEmpty(env, "CRQ_CODEX_CMD", "@codex review"), + CodexCommand: codexCommand, RateLimitCommand: stringEnv(env, "CRQ_RATELIMIT_CMD", dialect.DefaultRateLimitCommand), RateLimitMarker: stringEnv(env, "CRQ_RL_MARKER", dialect.DefaultRateLimitMarker), CalibrationMarker: stringEnv(env, "CRQ_CAL_REPLY_MARKER", "auto-generated reply by CodeRabbit"), @@ -140,7 +167,7 @@ func LoadConfig() (Config, error) { FeedbackWaitTimeout: durationEnv(env, "CRQ_FEEDBACK_WAIT_TIMEOUT", 20*time.Minute), SettleWindow: durationEnv(env, "CRQ_SETTLE", 90*time.Second), - RateLimitCodexDegrade: env["CRQ_RL_CODEX_DEGRADE"] != "0", + RateLimitCodexDegrade: stringEnv(env, "CRQ_RL_CO_DEGRADE", stringEnv(env, "CRQ_RL_CODEX_DEGRADE", "1")) != "0", } if len(cfg.Scope) == 0 && cfg.GateRepo != "" { cfg.Scope = []string{ownerOf(cfg.GateRepo)} @@ -253,14 +280,103 @@ func listEnv(env map[string]string, key, fallback string) []string { return out } -// extraFeedbackBots are review bots whose findings crq surfaces on top of the -// required bots, without gating convergence on them. Codex is the motivating -// case: it reviews but isn't "required" (crq neither fires nor waits for it), so -// its findings would otherwise be silently dropped. This is deliberately just -// Codex — CodeRabbit (or any configured reviewer) already enters the feedback -// set via RequiredBots, so listing it here too would wrongly surface CodeRabbit -// findings even when crq is configured for a different reviewer. -var extraFeedbackBots = []string{dialect.CodexBotLogin} +// CoBotConfig is one enabled co-reviewer: "wanted" (findings surface, dynamic +// gate engages when the bot is observed active) unless Required, which gates +// convergence via RequiredBots membership. Trigger and SelfHealGrace shape +// when crq may post Command (see engine.DecideCoPost). +type CoBotConfig struct { + Login string + Name string + Command string + Trigger engine.TriggerMode + Required bool + SelfHealGrace time.Duration +} + +// parseCoBots resolves the enabled co-reviewers from CRQ_COBOTS (default all +// known; explicitly empty disables all) plus the per-bot CRQ_COBOT__* +// keys. A co-reviewer login listed in CRQ_REQUIRED_BOTS is required+enabled +// even when CRQ_COBOTS omits it. Codex keeps its historical defaults: trigger +// `always` iff required (else never), command from CRQ_CODEX_CMD as the +// legacy alias of CRQ_COBOT_CODEX_CMD. Bugbot/Macroscope default to +// `selfheal` — they auto-review pushes, so crq only nudges one that went +// silent on a head it should have covered. +func parseCoBots(env map[string]string, requiredBots []string) []CoBotConfig { + enabled := map[string]bool{} + for _, item := range splitList(stringEnvAllowEmpty(env, "CRQ_COBOTS", "codex,bugbot,macroscope")) { + if co, ok := dialect.CoReviewerByName(item); ok { + enabled[co.Name] = true + } + } + requiredSet := map[string]bool{} + for _, bot := range requiredBots { + requiredSet[dialect.NormalizeBotName(strings.TrimSpace(bot))] = true + } + var out []CoBotConfig + for _, co := range dialect.KnownCoReviewers() { + key := strings.ToUpper(co.Name) + required := boolEnv(env, "CRQ_COBOT_"+key+"_REQUIRED", false) || requiredSet[dialect.NormalizeBotName(co.Login)] + if !enabled[co.Name] && !required { + continue + } + command := co.Command + if v, ok := env["CRQ_COBOT_"+key+"_CMD"]; ok { + command = v + } else if co.Name == "codex" { + command = stringEnvAllowEmpty(env, "CRQ_CODEX_CMD", command) + } + command = strings.TrimSpace(command) + trigger := engine.TriggerSelfHeal + if co.Name == "codex" { + trigger = engine.TriggerNever + if required { + trigger = engine.TriggerAlways + } + } + switch v := engine.TriggerMode(strings.ToLower(strings.TrimSpace(env["CRQ_COBOT_"+key+"_TRIGGER"]))); v { + case engine.TriggerNever, engine.TriggerSelfHeal, engine.TriggerAlways: + trigger = v + } + if command == "" { + // No trigger command means crq can never post one, whatever the mode. + trigger = engine.TriggerNever + } + out = append(out, CoBotConfig{ + Login: co.Login, + Name: co.Name, + Command: command, + Trigger: trigger, + Required: required, + SelfHealGrace: durationEnv(env, "CRQ_COBOT_"+key+"_GRACE", 10*time.Minute), + }) + } + return out +} + +// splitList splits a comma-separated list, dropping blanks (an all-blank or +// empty value yields nil — unlike listEnv it never falls back). +func splitList(value string) []string { + var out []string + for _, item := range strings.Split(value, ",") { + item = strings.TrimSpace(item) + if item != "" { + out = append(out, item) + } + } + return out +} + +func boolEnv(env map[string]string, key string, fallback bool) bool { + v := strings.TrimSpace(env[key]) + if v == "" { + return fallback + } + b, err := strconv.ParseBool(v) + if err != nil { + return fallback + } + return b +} // unionBots concatenates bot lists, dropping blanks and case-insensitively // de-duplicating on the normalized login (so "coderabbitai" and diff --git a/internal/crq/config_test.go b/internal/crq/config_test.go index bdfc08f..6fffc4c 100644 --- a/internal/crq/config_test.go +++ b/internal/crq/config_test.go @@ -4,6 +4,10 @@ import ( "os" "path/filepath" "testing" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" ) func TestLoadConfigDefaultsToCodeRabbitRequiredBot(t *testing.T) { @@ -217,3 +221,195 @@ func TestAuthorSetNormalizesCaseAndBotSuffix(t *testing.T) { t.Fatalf("expected normalized {dependabot, renovate}, got %#v", set) } } + +func coBotByName(cfg Config, name string) (CoBotConfig, bool) { + for _, cb := range cfg.CoBots { + if cb.Name == name { + return cb, true + } + } + return CoBotConfig{}, false +} + +func TestLoadConfigCoBotsDefaults(t *testing.T) { + t.Setenv("CRQ_CONFIG", filepath.Join(t.TempDir(), "missing-env")) + for _, key := range []string{"CRQ_REQUIRED_BOTS", "CRQ_FEEDBACK_BOTS", "CRQ_COBOTS", "CRQ_CODEX_CMD"} { + t.Setenv(key, "") + } + os.Unsetenv("CRQ_COBOTS") // "" would disable all; the default is all known + os.Unsetenv("CRQ_CODEX_CMD") + + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if len(cfg.CoBots) != 3 { + t.Fatalf("default CoBots = %#v, want codex+bugbot+macroscope", cfg.CoBots) + } + codex, _ := coBotByName(cfg, "codex") + if codex.Trigger != engine.TriggerNever || codex.Command != "@codex review" || codex.Required { + t.Fatalf("codex defaults wrong: %+v", codex) + } + if cfg.CodexCommand != "@codex review" { + t.Fatalf("CodexCommand mirror = %q", cfg.CodexCommand) + } + bugbot, _ := coBotByName(cfg, "bugbot") + if bugbot.Trigger != engine.TriggerSelfHeal || bugbot.Command != "bugbot run" || bugbot.SelfHealGrace != 10*time.Minute { + t.Fatalf("bugbot defaults wrong: %+v", bugbot) + } + macro, _ := coBotByName(cfg, "macroscope") + if macro.Trigger != engine.TriggerSelfHeal || macro.Command != "@macroscope-app review" { + t.Fatalf("macroscope defaults wrong: %+v", macro) + } + // Wanted co-reviewers surface findings: their logins join FeedbackBots. + for _, want := range []string{"cursor[bot]", "macroscopeapp[bot]", dialect.CodexBotLogin} { + found := false + for _, b := range cfg.FeedbackBots { + if b == want { + found = true + } + } + if !found { + t.Fatalf("FeedbackBots missing %s: %#v", want, cfg.FeedbackBots) + } + } + // Wanted-only bots never fold into RequiredBots. + if len(cfg.RequiredBots) != 1 { + t.Fatalf("RequiredBots must stay CodeRabbit-only by default: %#v", cfg.RequiredBots) + } +} + +func TestLoadConfigCoBotsExplicitEmptyDisablesAll(t *testing.T) { + t.Setenv("CRQ_CONFIG", filepath.Join(t.TempDir(), "missing-env")) + t.Setenv("CRQ_REQUIRED_BOTS", "") + t.Setenv("CRQ_FEEDBACK_BOTS", "") + t.Setenv("CRQ_COBOTS", "") + + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if len(cfg.CoBots) != 0 { + t.Fatalf("CRQ_COBOTS=\"\" must disable all co-bots, got %#v", cfg.CoBots) + } + if cfg.CodexCommand != "" { + t.Fatalf("CodexCommand must be empty with codex disabled, got %q", cfg.CodexCommand) + } + if len(cfg.FeedbackBots) != 1 || cfg.FeedbackBots[0] != "coderabbitai[bot]" { + t.Fatalf("FeedbackBots must collapse to the required set: %#v", cfg.FeedbackBots) + } +} + +func TestLoadConfigCoBotRequiredFoldsIntoRequiredBots(t *testing.T) { + t.Setenv("CRQ_CONFIG", filepath.Join(t.TempDir(), "missing-env")) + t.Setenv("CRQ_REQUIRED_BOTS", "") + t.Setenv("CRQ_COBOTS", "") // even disabled, required forces the entry + t.Setenv("CRQ_COBOT_BUGBOT_REQUIRED", "1") + + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + bugbot, ok := coBotByName(cfg, "bugbot") + if !ok || !bugbot.Required { + t.Fatalf("required bugbot must be enabled: %#v", cfg.CoBots) + } + found := false + for _, b := range cfg.RequiredBots { + if b == "cursor[bot]" { + found = true + } + } + if !found { + t.Fatalf("required co-bot must fold into RequiredBots: %#v", cfg.RequiredBots) + } + // Required-ness does not change Bugbot's selfheal default (it auto-reviews). + if bugbot.Trigger != engine.TriggerSelfHeal { + t.Fatalf("bugbot trigger = %v, want selfheal", bugbot.Trigger) + } +} + +func TestLoadConfigRequiredBotsListingEnablesCoBot(t *testing.T) { + t.Setenv("CRQ_CONFIG", filepath.Join(t.TempDir(), "missing-env")) + t.Setenv("CRQ_REQUIRED_BOTS", "coderabbitai[bot],"+dialect.CodexBotLogin) + t.Setenv("CRQ_COBOTS", "bugbot") + + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + codex, ok := coBotByName(cfg, "codex") + if !ok || !codex.Required { + t.Fatalf("codex listed in CRQ_REQUIRED_BOTS must be required+enabled: %#v", cfg.CoBots) + } + // A configured-required Codex keeps today's fire-time trigger. + if codex.Trigger != engine.TriggerAlways { + t.Fatalf("required codex trigger = %v, want always", codex.Trigger) + } + if _, ok := coBotByName(cfg, "macroscope"); ok { + t.Fatalf("macroscope not in CRQ_COBOTS and not required must be absent: %#v", cfg.CoBots) + } +} + +func TestLoadConfigCoBotCmdAliasesAndOverrides(t *testing.T) { + t.Setenv("CRQ_CONFIG", filepath.Join(t.TempDir(), "missing-env")) + t.Setenv("CRQ_REQUIRED_BOTS", "") + t.Setenv("CRQ_CODEX_CMD", "@codex ship it") + t.Setenv("CRQ_COBOT_BUGBOT_TRIGGER", "always") + t.Setenv("CRQ_COBOT_MACROSCOPE_CMD", "") + t.Setenv("CRQ_COBOT_MACROSCOPE_GRACE", "5m") + + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + codex, _ := coBotByName(cfg, "codex") + if codex.Command != "@codex ship it" || cfg.CodexCommand != "@codex ship it" { + t.Fatalf("CRQ_CODEX_CMD alias not honored: %+v / %q", codex, cfg.CodexCommand) + } + bugbot, _ := coBotByName(cfg, "bugbot") + if bugbot.Trigger != engine.TriggerAlways { + t.Fatalf("bugbot trigger override not honored: %+v", bugbot) + } + macro, _ := coBotByName(cfg, "macroscope") + if macro.Command != "" || macro.Trigger != engine.TriggerNever { + t.Fatalf("an empty command must force trigger never: %+v", macro) + } + if macro.SelfHealGrace != 5*time.Minute { + t.Fatalf("grace override not honored: %+v", macro) + } + + // The per-bot key wins over the legacy alias. + t.Setenv("CRQ_COBOT_CODEX_CMD", "@codex review please") + cfg, err = LoadConfig() + if err != nil { + t.Fatal(err) + } + codex, _ = coBotByName(cfg, "codex") + if codex.Command != "@codex review please" { + t.Fatalf("CRQ_COBOT_CODEX_CMD must win over CRQ_CODEX_CMD: %+v", codex) + } +} + +func TestLoadConfigRLCoDegradeAlias(t *testing.T) { + t.Setenv("CRQ_CONFIG", filepath.Join(t.TempDir(), "missing-env")) + + t.Setenv("CRQ_RL_CO_DEGRADE", "0") + cfg, err := LoadConfig() + if err != nil { + t.Fatal(err) + } + if cfg.RateLimitCodexDegrade { + t.Fatal("CRQ_RL_CO_DEGRADE=0 must disable the degrade") + } + + os.Unsetenv("CRQ_RL_CO_DEGRADE") + t.Setenv("CRQ_RL_CODEX_DEGRADE", "0") + cfg, err = LoadConfig() + if err != nil { + t.Fatal(err) + } + if cfg.RateLimitCodexDegrade { + t.Fatal("legacy CRQ_RL_CODEX_DEGRADE=0 must still disable the degrade") + } +} diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index c54f47f..f5a0670 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -399,7 +399,7 @@ func TestFeedbackSurfacesCodexEvenWhenNotRequired(t *testing.T) { cfg := Config{ Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]"}, - FeedbackBots: unionBots([]string{"coderabbitai[bot]"}, extraFeedbackBots), + FeedbackBots: unionBots([]string{"coderabbitai[bot]"}, []string{dialect.CodexBotLogin}), } gh := newFakeGitHub() sha := "abcdef1234567890" @@ -637,7 +637,7 @@ func TestFeedbackSurfacesBodyFindingsFromSupersededCommit(t *testing.T) { cfg := Config{ Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]"}, - FeedbackBots: unionBots([]string{"coderabbitai[bot]"}, extraFeedbackBots), + FeedbackBots: unionBots([]string{"coderabbitai[bot]"}, []string{dialect.CodexBotLogin}), } gh := newFakeGitHub() head := "9999999999999999" @@ -692,7 +692,7 @@ func TestFeedbackNewerHeadReviewSupersedesOldBodyFindings(t *testing.T) { cfg := Config{ Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]"}, - FeedbackBots: unionBots([]string{"coderabbitai[bot]"}, extraFeedbackBots), + FeedbackBots: unionBots([]string{"coderabbitai[bot]"}, []string{dialect.CodexBotLogin}), } gh := newFakeGitHub() head := "9999999999999999" @@ -732,7 +732,7 @@ func TestFeedbackCurrentRoundDoesNotResurfacePreRoundBodyFindings(t *testing.T) cfg := Config{ Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]"}, - FeedbackBots: unionBots([]string{"coderabbitai[bot]"}, extraFeedbackBots), + FeedbackBots: unionBots([]string{"coderabbitai[bot]"}, []string{dialect.CodexBotLogin}), } gh := newFakeGitHub() head := "9999999999999999" @@ -780,7 +780,7 @@ func TestFeedbackCurrentCodeRabbitRoundKeepsLatestCodexBodyFinding(t *testing.T) cfg := Config{ Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]"}, - FeedbackBots: unionBots([]string{"coderabbitai[bot]"}, extraFeedbackBots), + FeedbackBots: unionBots([]string{"coderabbitai[bot]"}, []string{dialect.CodexBotLogin}), } gh := newFakeGitHub() head := "850772b68de27efabc7ec5eeda30bb5ea138eb29" From 1ea424af457e64c1dc1b69f1ef848a587764d9d6 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 05:03:59 +0200 Subject: [PATCH 06/31] Wire co-reviewers through observe, fire, feedback, and the dashboard Step 7 (part 1) of the co-reviewer abstraction (Ref #34): observe builds the registry-backed classifier, fetches and classifies head check runs (degrading with a log line when unavailable), populates the per-bot Co map, and gathers adoptable trigger commands per bot (answered-guard now includes completed checks). The fire paths generalize: fireCoOnly posts triggers without taking the FireSlot (per-round trigger claims are the concurrency guard), fireCoDeferred and the self-heal sweep iterate every triggerable co-bot, and recordFire lands all trigger ids in one CAS write. Feedback skips classified co events and Macroscope's settled-edit findings, dedupes on Bugbot's BUG_ID, and reports per-bot co_reviewers status (informational only). The dashboard gains a co-reviewers row and per-round trigger marks, both absent without co-bots. --- internal/crq/codex_replay_test.go | 4 +- internal/crq/feedback.go | 112 +++++- internal/crq/observe.go | 231 ++++++++++-- internal/crq/service.go | 575 +++++++++++++++++++----------- internal/crq/service_test.go | 10 +- internal/crq/state.go | 38 +- internal/engine/engine.go | 4 + internal/engine/fire.go | 10 + internal/state/dashboard.go | 35 +- internal/state/store.go | 4 + 10 files changed, 761 insertions(+), 262 deletions(-) diff --git a/internal/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go index 858e7b6..a413ca3 100644 --- a/internal/crq/codex_replay_test.go +++ b/internal/crq/codex_replay_test.go @@ -496,9 +496,9 @@ func TestSelfHealCodexClaimPreventsDoublePost(t *testing.T) { } obs.eng.Events = events before := f.codexPosted(repo, pr) - f.svc.selfHealCodex(f.ctx, *round, obs.eng, f.clk.now()) + f.svc.selfHealCoReviewers(f.ctx, *round, obs.eng, f.clk.now()) // Second sweeper with the SAME stale observation (still CodexCommandID==0). - f.svc.selfHealCodex(f.ctx, *round, obs.eng, f.clk.now()) + f.svc.selfHealCoReviewers(f.ctx, *round, obs.eng, f.clk.now()) if got := f.codexPosted(repo, pr) - before; got != 1 { t.Fatalf("concurrent self-heals must post exactly once, got %d extra posts", got) } diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index bad4608..dcbb0bf 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -32,6 +32,23 @@ type FeedbackReport struct { // DeferredUntil. Converged stays false until it does. CodeRabbitDeferred bool `json:"coderabbit_deferred,omitempty"` DeferredUntil *time.Time `json:"coderabbit_deferred_until,omitempty"` + // CoReviewers is each enabled co-reviewer's per-head status, keyed by + // normalized login. Informational only — the Verdict never gates + // convergence or changes exit codes. + CoReviewers map[string]CoReviewerStatus `json:"co_reviewers,omitempty"` +} + +// CoReviewerStatus is one co-reviewer's observed state for the current head. +type CoReviewerStatus struct { + // Reviewed reports head review evidence: a review, a SHA-matched clean + // summary, or a completed check run. + Reviewed bool `json:"reviewed"` + // CheckState summarizes the bot's check runs for the head: + // clean | issues | in_progress | unknown (no check observed). + CheckState string `json:"check_state,omitempty"` + // Verdict is Macroscope's approvability verdict when one was posted: + // approved | needs_human_review. + Verdict string `json:"verdict,omitempty"` } func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackReport, error) { @@ -82,6 +99,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe } completion := engine.Completion(completionRound, obs.eng, s.policy()) report.ReviewedBy = completion.ReviewedBy + report.CoReviewers = coReviewerStatuses(s.cfg, obs.eng) // extractBots is the broader set whose findings we surface — a superset that // includes Codex — so a bot that reviews without being required (and would @@ -199,6 +217,18 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe } return headCutoff } + // Classified co-reviewer events (clean summaries, verdicts, notices, + // trigger commands, unable notices) are completion/participation signals, + // not actionable findings — index them by comment id so the loop below + // skips them (the same pattern as the Codex clean-summary skip, which the + // classifier now owns generically). + coEventKinds := map[int64]dialect.EventKind{} + for _, ev := range obs.eng.Events { + switch ev.Kind { + case dialect.EvCoClean, dialect.EvCoUnable, dialect.EvCoNotice, dialect.EvCoVerdict, dialect.EvCoCommand: + coEventKinds[ev.CommentID] = ev.Kind + } + } for _, comment := range obs.comments { if !dialect.InBots(extractBots, comment.User.Login) { continue @@ -206,10 +236,11 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe if s.cr.IsRateLimited(comment.Body) { continue // an account-quota notice is never a finding } - // Codex clean-review summaries and every configured-bot issue comment are - // completion signals, not actionable findings: engine.Completion already - // folded them into ReviewedBy, so they are never surfaced here. - if dialect.IsCodexBot(comment.User.Login) && dialect.IsCodexNoActionReviewCompletion(comment.Body) { + // Co-reviewer clean summaries/verdicts/notices and every configured-bot + // issue comment are completion signals, not actionable findings: + // engine.Completion already folded them into ReviewedBy, so they are + // never surfaced here. + if _, ok := coEventKinds[comment.ID]; ok { continue } if s.isConfiguredBot(comment.User.Login) { @@ -984,6 +1015,14 @@ func threadFindings(thread reviewThread, bots map[string]struct{}) []dialect.Fin if !dialect.InBots(bots, comment.Author.Login) { continue } + // A settled-marker edit means the bot considers the finding addressed — + // Macroscope never resolves threads, it EDITS the comment ("✅ Resolved + // in " / "No longer relevant as of "); the edit IS its + // resolution, trusted like a thread resolution (no head match required). + if co, ok := dialect.CoReviewerByName(comment.Author.Login); ok && + co.ResolvedInSHA != nil && co.ResolvedInSHA(comment.Body) != "" { + continue + } commit := dialect.ShortOID(comment.Commit.OID) if commit == "" { commit = dialect.ShortOID(comment.OriginalCommit.OID) @@ -1047,6 +1086,14 @@ func dedupeFindings(in []dialect.Finding, suppressPromptAt map[string]bool) []di } } key := dialect.NormalizeBotName(finding.Bot) + "|" + finding.Path + "|" + strconv.Itoa(finding.Line) + "|" + finding.Title + "|" + finding.Body + "|" + finding.ThreadID + // A bot-stable identity marker (Bugbot's BUG_ID) supersedes the + // location/title key: the same bug re-reported in a new thread after a + // push must collapse to one finding. + if co, ok := dialect.CoReviewerByName(finding.Bot); ok && co.FindingDedupeKey != nil { + if stable, ok := co.FindingDedupeKey(finding.Body); ok { + key = dialect.NormalizeBotName(finding.Bot) + "|" + stable + } + } sum := sha256.Sum256([]byte(key)) finding.ID = hex.EncodeToString(sum[:]) if seen[finding.ID] { @@ -1058,6 +1105,63 @@ func dedupeFindings(in []dialect.Finding, suppressPromptAt map[string]bool) []di return out } +// coReviewerStatuses summarizes each enabled co-reviewer's observed state for +// the current head: head review evidence, check-run state, and Macroscope's +// approvability verdict. Informational only — nothing here gates convergence. +func coReviewerStatuses(cfg Config, obs engine.Observation) map[string]CoReviewerStatus { + if len(cfg.CoBots) == 0 { + return nil + } + out := map[string]CoReviewerStatus{} + for _, cb := range cfg.CoBots { + key := dialect.NormalizeBotName(cb.Login) + status := CoReviewerStatus{Reviewed: engine.CoReviewedHead(obs, cb.Login)} + inProgress, clean, done := false, false, false + for _, c := range obs.Checks { + if dialect.NormalizeBotName(c.Bot) != key { + continue + } + switch c.Verdict { + case dialect.CheckInProgress: + inProgress = true + case dialect.CheckDoneClean: + clean = true + case dialect.CheckDone: + done = true + } + } + switch { + case inProgress: + status.CheckState = "in_progress" + case clean: + status.CheckState = "clean" + case done: + status.CheckState = "issues" + default: + status.CheckState = "unknown" + } + var latest time.Time + for _, ev := range obs.Events { + if ev.Kind != dialect.EvCoVerdict || ev.Approved == nil { + continue + } + if dialect.NormalizeBotName(firstNonEmpty(ev.For, ev.Bot)) != key { + continue + } + if at := ev.ObservedTime(); latest.IsZero() || at.After(latest) { + latest = at + if *ev.Approved { + status.Verdict = "approved" + } else { + status.Verdict = "needs_human_review" + } + } + } + out[key] = status + } + return out +} + func isCurrentCodexThumbsUp(reaction ghapi.Reaction, since time.Time) bool { if !dialect.IsCodexBot(reaction.User.Login) || reaction.Content != "+1" { return false diff --git a/internal/crq/observe.go b/internal/crq/observe.go index 53703f6..2bc4e90 100644 --- a/internal/crq/observe.go +++ b/internal/crq/observe.go @@ -75,11 +75,40 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round return observation{}, err } o.comments = comments - classifier := dialect.Classifier{CodeRabbit: s.cr, Bot: s.cfg.Bot, ReviewCommand: s.cfg.ReviewCommand, CodexCommand: s.cfg.CodexCommand} + classifier := dialect.Classifier{ + CodeRabbit: s.cr, + Bot: s.cfg.Bot, + ReviewCommand: s.cfg.ReviewCommand, + CodexCommand: s.cfg.CodexCommand, + CoReviewers: s.classifierCoReviewers(), + } for _, c := range comments { o.eng.Events = append(o.eng.Events, classifier.Classify(c.User.Login, c.Body, c.ID, c.CreatedAt, c.UpdatedAt)) } + // Check runs are the only place a silent-clean Bugbot round exists, and they + // also suppress selfheal triggers and inform pre-fire decisions — so they are + // fetched for any open PR with an enabled check-bearing co-reviewer, NOT + // gated on FiredAt. An unfetchable check degrades to the bounded co-review + // wait rather than failing the observation; the log line is the operator's + // signal that a required check-bearing bot may time out spuriously. + if o.eng.Open && pull.Head.SHA != "" && s.coChecksRelevant() { + runs, cerr := s.gh.ListCheckRuns(ctx, repo, pull.Head.SHA) + if cerr != nil { + if s.log != nil { + s.log.Printf("warning: check runs unavailable for %s#%d@%s: %v (check-bearing co-reviewer evidence degraded)", repo, pr, dialect.ShortOID(pull.Head.SHA), cerr) + } + } else { + for _, run := range runs { + login, verdict := dialect.ClassifyCheckRun(run.App.Slug, run.Name, run.Output.Title, run.Output.Summary, run.Status, run.Conclusion) + if verdict == dialect.CheckUnrelated || !s.coBotEnabled(login) { + continue + } + o.eng.Checks = append(o.eng.Checks, engine.CheckSeen{Bot: login, Name: run.Name, Verdict: verdict, CompletedAt: run.CompletedAt}) + } + } + } + // Reactions and Codex thumbs-up only matter for a round that has fired. if round != nil && round.FiredAt != nil { cutoff := round.FiredAt.UTC() @@ -111,26 +140,89 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round } } - // Codex activity is derived from the same snapshot: whether Codex reviews the - // PR unprompted (drives the fire decision) and whether it participates in the - // current round (drives the dynamic completion gate). + // Co-reviewer activity is derived from the same snapshot: whether each bot + // reviews the PR unprompted (drives the fire decision) and whether it + // participates in the current round (drives the dynamic completion gate). + // The legacy Codex fields stay populated for not-yet-migrated readers. o.eng.CodexAutoActive = engine.CodexAutoActive(o.eng) if round != nil { o.eng.CodexActiveThisRound = engine.CodexActiveThisRound(*round, o.eng) } + if len(s.cfg.CoBots) > 0 { + o.eng.Co = map[string]engine.CoSeen{} + for _, cb := range s.cfg.CoBots { + seen := engine.CoSeen{AutoActive: engine.CoAutoActive(o.eng, cb.Login)} + if round != nil { + if dialect.IsCodexBot(cb.Login) { + // Codex's thumbs-up quirk counts as round participation. + seen.ActiveThisRound = engine.CodexActiveThisRound(*round, o.eng) + } else { + seen.ActiveThisRound = engine.CoActiveThisRound(*round, o.eng, cb.Login) + } + } + o.eng.Co[dialect.NormalizeBotName(cb.Login)] = seen + } + } // Adoptable commands are only consulted for a fire-eligible round. if round != nil && round.FireEligible(now) { - cr, codex, err := s.reviewCommands(ctx, repo, pr, o.eng, adoptCutoff(*round), pull, comments, reviews) + cr, co, err := s.reviewCommands(ctx, repo, pr, o.eng, adoptCutoff(*round), pull, comments, reviews) if err != nil { return observation{}, err } o.eng.Commands = cr - o.eng.CodexCommands = codex + o.eng.CodexCommands = co[dialect.NormalizeBotName(dialect.CodexBotLogin)] + for key, cmds := range co { + if entry, ok := o.eng.Co[key]; ok { + entry.Commands = cmds + o.eng.Co[key] = entry + } + } } return o, nil } +// classifierCoReviewers resolves the enabled registry entries with their +// config-resolved trigger commands. nil (no parsed CoBots) leaves the +// Classifier on its legacy Codex shim. +func (s *Service) classifierCoReviewers() []dialect.CoReviewer { + if len(s.cfg.CoBots) == 0 { + return nil + } + out := make([]dialect.CoReviewer, 0, len(s.cfg.CoBots)) + for _, cb := range s.cfg.CoBots { + co, ok := dialect.CoReviewerByName(cb.Name) + if !ok { + continue + } + co.Command = cb.Command + out = append(out, co) + } + return out +} + +// coChecksRelevant reports whether any enabled co-reviewer owns check runs, +// so the extra REST fetch (ETag'd — repeat polls are 304s) is only spent when +// a bot's evidence can live there. +func (s *Service) coChecksRelevant() bool { + for _, cb := range s.cfg.CoBots { + if co, ok := dialect.CoReviewerByName(cb.Name); ok && co.AppSlug != "" { + return true + } + } + return false +} + +// coBotEnabled reports whether login is one of the enabled co-reviewers. +func (s *Service) coBotEnabled(login string) bool { + for _, cb := range s.cfg.CoBots { + if dialect.NormalizeBotName(cb.Login) == dialect.NormalizeBotName(login) { + return true + } + } + return false +} + // adoptCutoff is the earliest command timestamp a round may adopt: the most // recent failed/abandoned attempt, so a stale command from before a requeue is // never adopted. @@ -160,20 +252,27 @@ func (s *Service) codexRelevant(obs engine.Observation) bool { return false } -// reviewCommands ports v2's existingReviewCommand and extends it to Codex. It -// returns the newest CodeRabbit command safe to adopt as an already-posted fire -// (cr) and the live `@codex review` commands present for the head (codex). Both -// share ONE cutoff computation (LastAttemptAt floor, head-commit date, -// force-push) so a stale command from a previous head is excluded from both, and -// the head-guard/cutoff lookups are skipped entirely when neither command is on -// the PR. The Codex list is only gathered when Codex gates the round, since only -// a configured-required Codex is ever fired. -func (s *Service) reviewCommands(ctx context.Context, repo string, pr int, obs engine.Observation, notBeforeCutoff time.Time, pull ghapi.Pull, comments []ghapi.IssueComment, reviews []ghapi.Review) (cr, codex []engine.CommandSeen, err error) { +// reviewCommands ports v2's existingReviewCommand and extends it to the +// co-reviewers. It returns the newest CodeRabbit command safe to adopt as an +// already-posted fire (cr) and each co-reviewer's live trigger commands +// present for the head, keyed by normalized login. All share ONE cutoff +// computation (LastAttemptAt floor, head-commit date, force-push) so a stale +// command from a previous head is excluded everywhere, and the head-guard/ +// cutoff lookups are skipped entirely when no command is on the PR. +func (s *Service) reviewCommands(ctx context.Context, repo string, pr int, obs engine.Observation, notBeforeCutoff time.Time, pull ghapi.Pull, comments []ghapi.IssueComment, reviews []ghapi.Review) (cr []engine.CommandSeen, co map[string][]engine.CommandSeen, err error) { command := strings.TrimSpace(s.cfg.ReviewCommand) - codexCommand := strings.TrimSpace(s.cfg.CodexCommand) hasCR := command != "" && hasCommentBody(comments, command) - hasCodex := codexCommand != "" && dialect.HasCodexBot(s.cfg.RequiredBots) && hasCommentBody(comments, codexCommand) - if !hasCR && !hasCodex { + coBodies := s.coCommandBodies() + present := map[string][]string{} + for key, bodies := range coBodies { + for _, body := range bodies { + if hasCommentBody(comments, body) { + present[key] = bodies + break + } + } + } + if !hasCR && len(present) == 0 { return nil, nil, nil } cutoff := notBeforeCutoff @@ -212,30 +311,92 @@ func (s *Service) reviewCommands(ctx context.Context, repo string, pr int, obs e if hasCR { cr = s.adoptableCR(obs, cutoff, command, comments, reviews) } - if hasCodex { - codex = adoptableCodex(cutoff, codexCommand, comments, reviews) + for key, bodies := range present { + if cmds := adoptableCo(obs, key, cutoff, bodies, comments, reviews); len(cmds) > 0 { + if co == nil { + co = map[string][]engine.CommandSeen{} + } + co[key] = cmds + } + } + return cr, co, nil +} + +// coCommandBodies maps each triggerable co-reviewer (normalized login) to the +// comment bodies that count as its trigger: the config-resolved command plus +// the registry's alternate spellings (`bugbot run` / `cursor review`). With no +// parsed CoBots the legacy rule applies: Codex's exact command, and only when +// Codex gates the round — only a configured-required Codex is ever fired. +func (s *Service) coCommandBodies() map[string][]string { + out := map[string][]string{} + if len(s.cfg.CoBots) == 0 { + codexCommand := strings.TrimSpace(s.cfg.CodexCommand) + if codexCommand != "" && dialect.HasCodexBot(s.cfg.RequiredBots) { + out[dialect.NormalizeBotName(dialect.CodexBotLogin)] = []string{codexCommand} + } + return out + } + for _, cb := range s.cfg.CoBots { + var bodies []string + add := func(body string) { + body = strings.TrimSpace(body) + if body == "" { + return + } + for _, have := range bodies { + if have == body { + return + } + } + bodies = append(bodies, body) + } + add(cb.Command) + if co, ok := dialect.CoReviewerByName(cb.Name); ok { + for _, alias := range co.TriggerAliases { + add(alias) + } + } + if len(bodies) > 0 { + out[dialect.NormalizeBotName(cb.Login)] = bodies + } } - return cr, codex, nil + return out } -// adoptableCodex returns the newest `@codex review` command comment present for -// the head that Codex has NOT already answered with a review, or none. A command -// answered by a later Codex review belongs to a finished round for an earlier -// head: on a regular push whose commit date predates that old command, the -// command survives the cutoff yet is already consumed, so treating it as live -// makes DecideCodexPost see commandPresent=true and suppress the Codex command -// the new head still needs. Mirrors adoptableCR's review-answered guard. -func adoptableCodex(cutoff time.Time, codexCommand string, comments []ghapi.IssueComment, reviews []ghapi.Review) []engine.CommandSeen { - best := newestCommandSince(codexCommand, cutoff, comments) +// adoptableCo returns the newest trigger command comment present for the head +// that the co-reviewer has NOT already answered — with a review, or (Bugbot, +// Macroscope) a completed check run. A command answered by later evidence +// belongs to a finished round for an earlier head: on a regular push whose +// commit date predates that old command, the command survives the cutoff yet +// is already consumed, so treating it as live makes DecideCoPost see +// commandPresent=true and suppress the trigger the new head still needs. +// Mirrors adoptableCR's review-answered guard. +func adoptableCo(obs engine.Observation, loginKey string, cutoff time.Time, bodies []string, comments []ghapi.IssueComment, reviews []ghapi.Review) []engine.CommandSeen { + var best []engine.CommandSeen + var bestAt time.Time + for _, body := range bodies { + if got := newestCommandSince(body, cutoff, comments); len(got) > 0 { + at := got[0].CreatedAt + if at.IsZero() { + at = got[0].UpdatedAt + } + if best == nil || at.After(bestAt) { + best, bestAt = got, at + } + } + } if len(best) == 0 { return nil } - bestAt := best[0].CreatedAt - if bestAt.IsZero() { - bestAt = best[0].UpdatedAt - } for _, review := range reviews { - if dialect.IsCodexBot(review.User.Login) && !review.SubmittedAt.Before(bestAt) { + if dialect.NormalizeBotName(review.User.Login) == loginKey && !review.SubmittedAt.Before(bestAt) { + return nil + } + } + for _, check := range obs.Checks { + if dialect.NormalizeBotName(check.Bot) == loginKey && + (check.Verdict == dialect.CheckDone || check.Verdict == dialect.CheckDoneClean) && + !check.CompletedAt.Before(bestAt) { return nil } } diff --git a/internal/crq/service.go b/internal/crq/service.go index 1285dde..70757e2 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -267,19 +267,20 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { if err != nil { return result, err } - // A blocked or slot-busy front of the queue whose Codex command is - // already posted must not starve later PRs of THEIR early Codex round — - // scan a bounded number of following queued rounds for a postable - // Codex defer (each costs one observation; ETag caching keeps it cheap). + // A blocked or slot-busy front of the queue whose co-reviewer triggers are + // already posted must not starve later PRs of THEIR early co-review round — + // scan a bounded number of following queued rounds for a postable defer + // (each costs one observation; ETag caching keeps it cheap). accountBlocked := global.BlockedUntil != nil && global.BlockedUntil.After(now) if s.cfg.RateLimitCodexDegrade && decision.Verdict == engine.FireNo && (accountBlocked || !global.SlotFree) { scanned := 0 + policy := s.policy() for _, r := range st.QueuedRounds(now) { if r.Repo == next.Repo && r.PR == next.PR { continue } - if r.CodexCommandID != 0 || scanned >= 3 { + if !anyCoUncommanded(r, policy) || scanned >= 3 { continue } scanned++ @@ -288,8 +289,8 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { if oerr != nil { continue } - d := engine.DecideFire(s.global(st, now), round, robs.eng, now, s.policy()) - if d.Verdict != engine.FireCodexDeferred { + d := engine.DecideFire(s.global(st, now), round, robs.eng, now, policy) + if d.Verdict != engine.FireCoDeferred { continue } return s.applyFire(ctx, round, robs.eng, d, now) @@ -298,6 +299,21 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { return result, nil } +// anyCoUncommanded reports whether some triggerable co-reviewer has no +// command recorded on the round yet — the cheap pre-filter for the deferred +// scan above (a round with every trigger already posted cannot defer again). +func anyCoUncommanded(r Round, p engine.Policy) bool { + for _, cp := range p.CoReviewerPolicies() { + if strings.TrimSpace(cp.Command) == "" { + continue + } + if r.Co(cp.Login).CommandID == 0 && !(dialect.IsCodexBot(cp.Login) && r.CodexCommandID != 0) { + return true + } + } + return false +} + func (s *Service) global(st State, now time.Time) engine.Global { return engine.Global{ SlotFree: st.SlotRound() == nil, @@ -317,7 +333,7 @@ func (s *Service) progressSlotRound(ctx context.Context, slot Round) (PumpResult if err != nil { return PumpResult{}, err } - s.selfHealCodex(ctx, slot, obs.eng, now) + s.selfHealCoReviewers(ctx, slot, obs.eng, now) tr := engine.Progress(slot, st.Account, obs.eng, now, s.policy()) if tr.Outcome == engine.KeepWaiting { return PumpResult{Action: "waiting", Repo: slot.Repo, PR: slot.PR, Reason: tr.Reason}, nil @@ -459,7 +475,7 @@ func (s *Service) sweepReviewing(ctx context.Context, st State, now time.Time) ( } return st, nil } - s.selfHealCodex(ctx, *target, obs.eng, now) + s.selfHealCoReviewers(ctx, *target, obs.eng, now) tr := engine.Progress(*target, st.Account, obs.eng, now, s.policy()) if tr.Outcome == engine.KeepWaiting { return st, nil @@ -495,18 +511,18 @@ func (s *Service) applyFire(ctx context.Context, round Round, obs engine.Observa return s.abandonRound(ctx, round, "pr closed", "skipped") case engine.FireDedupe: return s.dedupeRound(ctx, round, now, d.Reason) - case engine.FireCodexOnly: - return s.fireCodexOnly(ctx, round, d.Reason, now) - case engine.FireCodexDeferred: - return s.fireCodexDeferred(ctx, round, d.AdoptCommandID, d.AdoptAt, d.Reason, now) + case engine.FireCoOnly: + return s.fireCoOnly(ctx, round, d.PostCo, d.Reason, now) + case engine.FireCoDeferred: + return s.fireCoDeferred(ctx, round, d, now) case engine.FireCoReviewWait: return s.fireCoReviewWait(ctx, round, obs, d.Reason, now) case engine.FireSupersede: return s.supersedeRound(ctx, round, obs.Head, now) case engine.FireAdopt: - return s.fireRound(ctx, round, obs, false, d.AdoptCommandID, d.AdoptAt, d.Reason, d.PostCodex, now) + return s.fireRound(ctx, round, obs, false, d.AdoptCommandID, d.AdoptAt, d.Reason, d.PostCo, now) case engine.FirePost: - return s.fireRound(ctx, round, obs, true, 0, time.Time{}, "", d.PostCodex, now) + return s.fireRound(ctx, round, obs, true, 0, time.Time{}, "", d.PostCo, now) default: // FireNo return PumpResult{Action: mapFireNo(d.Reason), Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: d.Reason}, nil } @@ -609,10 +625,10 @@ func (s *Service) supersedeRound(ctx context.Context, round Round, head string, } // fireRound posts (or adopts) the review command and records the fire on the -// round, reserving the global slot under compare-and-swap. When postCodex, it -// also posts the Codex review command alongside (non-fatal on failure — the -// self-heal path retries). -func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observation, post bool, adoptID int64, adoptAt time.Time, reason string, postCodex bool, now time.Time) (PumpResult, error) { +// round, reserving the global slot under compare-and-swap. postCo lists the +// co-reviewer logins whose trigger commands are posted alongside (non-fatal +// on failure — the self-heal path retries). +func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observation, post bool, adoptID int64, adoptAt time.Time, reason string, postCo []string, now time.Time) (PumpResult, error) { key := QueueKey(round.Repo, round.PR) if s.cfg.DryRun { return PumpResult{Action: "dry_run", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil @@ -648,21 +664,26 @@ func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observa r.WaitDeadline = &dl st.Warn = "" st.FireSlot = &FireSlot{Key: key, Token: token, Since: now} - // Claim the Codex post in the SAME write: the fired state must never be - // visible with CodexCommandID == 0 and no claim, or another daemon can - // self-heal-post in the gap before fireCodexReview below runs. - if postCodex { - t := now.UTC() - r.CodexClaimedAt = &t - } else if id := newestCommandID(obs.CodexCommands); id != 0 && r.CodexCommandID == 0 { - // Not posting because a live `@codex review` command already answers - // this head — record its id now. The self-heal scan anchors on FiredAt - // and would miss a command posted before the adopted CodeRabbit command, - // posting a duplicate; recording it here keeps the round "asked". Its - // timestamp anchors the codex cutoff too, or a SHA-less answer that - // landed before this adopted fire would never bind to the round. - r.CodexCommandID = id - r.CodexCommandedAt = codexCommandTime(obs.CodexCommands, id) + // Claim the co-reviewer posts in the SAME write: the fired state must + // never be visible with no recorded command and no claim, or another + // daemon can self-heal-post in the gap before fireCoTrigger below runs. + for _, login := range postCo { + r.ClaimCo(login, now) + } + // For bots crq is NOT posting because a live trigger already answers + // this head — record its id now. The self-heal scan anchors on FiredAt + // and would miss a command posted before the adopted CodeRabbit command, + // posting a duplicate; recording it here keeps the round "asked". Its + // timestamp anchors that bot's cutoff too, or a SHA-less answer that + // landed before this adopted fire would never bind to the round. + for _, cp := range s.policy().CoReviewerPolicies() { + if hasLogin(postCo, cp.Login) || r.Co(cp.Login).CommandID != 0 { + continue + } + cmds := obs.CoSeenFor(cp.Login).Commands + if id := newestCommandID(cmds); id != 0 { + r.SetCoCommand(cp.Login, id, commandCreatedAt(cmds, id, now)) + } } st.PutRound(*r) recorded = true @@ -678,8 +699,8 @@ func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observa if s.log != nil { s.log.Printf("fire %s@%s (adopted existing review command)", key, round.Head) } - if postCodex { - s.fireCodexReview(ctx, round) + for _, login := range postCo { + s.fireCoTrigger(ctx, round, login) } return PumpResult{Action: "fired", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil } @@ -735,13 +756,16 @@ func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observa if firedAt.IsZero() { firedAt = now } - // Post the Codex command before recording so its id lands in the same fire - // write. A failed post returns 0 (logged) and the self-heal path retries. - var codexID int64 - if postCodex { - codexID, _ = s.postCodexReviewComment(ctx, round) + // Post the co-reviewer triggers before recording so their ids land in the + // same fire write. A failed post returns 0 (logged) and the self-heal path + // retries. + var coPosts []coPost + for _, login := range postCo { + if id, at := s.postCoTrigger(ctx, round, login); id != 0 { + coPosts = append(coPosts, coPost{login: login, id: id, at: at}) + } } - updated, err := s.recordFire(ctx, round, token, comment.ID, codexID, firedAt, now) + updated, err := s.recordFire(ctx, round, token, comment.ID, coPosts, firedAt, now) if err != nil { if errors.Is(err, ErrNoChange) { return PumpResult{Action: "lost_race"}, nil @@ -755,77 +779,159 @@ func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observa return PumpResult{Action: "fired", Repo: round.Repo, PR: round.PR, Head: round.Head}, nil } -// fireCodexOnly handles a round where CodeRabbit already reviewed the head but a -// required Codex has not: it reserves the slot, posts ONLY the Codex command, and -// records the round as fired with that comment as both the CommandID anchor and -// the CodexCommandID. Completion already counts the existing CodeRabbit review, so -// the round then waits on Codex alone — no `@coderabbitai review` is ever posted. -func (s *Service) fireCodexOnly(ctx context.Context, round Round, reason string, now time.Time) (PumpResult, error) { +// coPost is one posted (or to-post) co-reviewer trigger comment. +type coPost struct { + login string + id int64 + at time.Time +} + +func hasLogin(logins []string, login string) bool { + for _, have := range logins { + if dialect.NormalizeBotName(have) == dialect.NormalizeBotName(login) { + return true + } + } + return false +} + +// coCommandFor resolves the trigger comment body crq posts for login. +func (s *Service) coCommandFor(login string) string { + for _, cb := range s.cfg.CoBots { + if dialect.NormalizeBotName(cb.Login) == dialect.NormalizeBotName(login) { + return cb.Command + } + } + if dialect.IsCodexBot(login) { + return s.cfg.CodexCommand + } + return "" +} + +// fireCoOnly handles a round where CodeRabbit already reviewed the head but +// gating co-reviewers have not: it posts ONLY their trigger commands and +// records the round as fired with the first trigger as the CommandID anchor. +// Completion already counts the existing CodeRabbit review, so the round then +// waits on the co-reviewers alone — no `@coderabbitai review` is ever posted, +// no CodeRabbit quota is spent, and therefore NO FireSlot is taken: the +// per-round trigger claims (CoBots[login].ClaimedAt, CAS-set before the +// network post) are the concurrency guard. +func (s *Service) fireCoOnly(ctx context.Context, round Round, logins []string, reason string, now time.Time) (PumpResult, error) { key := QueueKey(round.Repo, round.PR) if s.cfg.DryRun { return PumpResult{Action: "dry_run", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil } - token := randomToken() - - reserved, err := s.store.Update(ctx, func(st *State) error { - if st.FireSlot != nil { - return ErrNoChange - } + var claimed []string + updated, err := s.store.Update(ctx, func(st *State) error { + claimed = claimed[:0] r := st.Round(round.Repo, round.PR) if !sameRound(r, round) || !r.FireEligible(now) { return ErrNoChange } - if err := r.Reserve(token, s.cfg.Host, now); err != nil { - return err + for _, login := range logins { + c := r.Co(login) + if c.CommandID != 0 { + continue + } + if c.ClaimedAt != nil && now.Sub(c.ClaimedAt.UTC()) < triggerClaimTTL { + continue + } + r.ClaimCo(login, now) + claimed = append(claimed, login) + } + if len(claimed) == 0 { + return ErrNoChange } - st.FireSlot = &FireSlot{Key: key, Token: token, Since: now} st.PutRound(*r) return nil }) - if err != nil { + if err != nil && !errors.Is(err, ErrNoChange) { return PumpResult{}, err } - if reserved.FireSlot == nil || reserved.FireSlot.Token != token { + if len(claimed) == 0 { return PumpResult{Action: "lost_race"}, nil } - s.sync(ctx, reserved) + s.sync(ctx, updated) - comment, err := s.gh.PostIssueComment(ctx, round.Repo, round.PR, s.cfg.CodexCommand) - if err != nil { - updated, uerr := s.store.Update(ctx, func(st *State) error { + var posts []coPost + for _, login := range claimed { + if id, at := s.postCoTrigger(ctx, round, login); id != 0 { + posts = append(posts, coPost{login: login, id: id, at: at}) + } + } + if len(posts) == 0 { + // Every post failed: park with the post-failure cooldown. The claims + // stay — their TTL is the retry backoff. + postErr := errors.New("failed to post co-reviewer trigger command") + parked, uerr := s.store.Update(ctx, func(st *State) error { r := st.Round(round.Repo, round.PR) - if r == nil || r.Token != token { + if !sameRound(r, round) { return ErrNoChange } - if rerr := r.AwaitRetry(now.Add(postFailureBackoff), "failed to post codex review command: "+err.Error(), now); rerr != nil { + // AwaitRetry is only legal from reserved/fired/reviewing; pass a + // still-queued round through Reserve (a pure phase transition — no + // global FireSlot is registered) so the park is a legal edge. + if r.FireEligible(now) { + if rerr := r.Reserve(randomToken(), s.cfg.Host, now); rerr != nil { + return rerr + } + } + if rerr := r.AwaitRetry(now.Add(postFailureBackoff), postErr.Error(), now); rerr != nil { return rerr } - releaseSlot(st, key) - st.Warn = "failed to post codex review command: " + err.Error() + st.Warn = postErr.Error() st.PutRound(*r) return nil }) if uerr == nil { - s.sync(ctx, updated) + s.sync(ctx, parked) } - return PumpResult{Action: "post_failed", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: err.Error()}, err + return PumpResult{Action: "post_failed", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: postErr.Error()}, postErr } - firedAt := comment.CreatedAt.UTC() + firedAt := posts[0].at.UTC() if firedAt.IsZero() { firedAt = now } - // The Codex comment anchors the round both as its fired command and as the - // recorded Codex command, so self-heal will not re-post it. - updated, err := s.recordFire(ctx, round, token, comment.ID, comment.ID, firedAt, now) - if err != nil { - if errors.Is(err, ErrNoChange) { - return PumpResult{Action: "lost_race"}, nil + // The first trigger comment anchors the round as its fired command; every + // posted trigger is recorded per bot so self-heal will not re-post them. A + // partially failed post set keeps its claims and heals after the TTL. + recorded := false + updated, err = s.store.Update(ctx, func(st *State) error { + recorded = false + r := st.Round(round.Repo, round.PR) + if !sameRound(r, round) { + return ErrNoChange + } + if r.FireEligible(now) { + if err := r.Reserve(randomToken(), s.cfg.Host, now); err != nil { + return err + } + if err := r.Fire(posts[0].id, firedAt); err != nil { + return err + } + r.Token = "" + dl := firedAt.Add(s.cfg.FeedbackWaitTimeout) + r.WaitDeadline = &dl + st.Warn = "" + } + for _, p := range posts { + if r.Co(p.login).CommandID == 0 { + r.SetCoCommand(p.login, p.id, p.at) + } } + st.PutRound(*r) + recorded = true + return nil + }) + if err != nil { return PumpResult{}, err } + if !recorded { + return PumpResult{Action: "lost_race"}, nil + } s.sync(ctx, updated) if s.log != nil { - s.log.Printf("fire %s@%s (coderabbit already reviewed; posted %s)", key, round.Head, strings.TrimSpace(s.cfg.CodexCommand)) + s.log.Printf("fire %s@%s (coderabbit already reviewed; posted co-reviewer triggers for %s)", key, round.Head, strings.Join(claimed, ",")) } return PumpResult{Action: "fired", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil } @@ -842,14 +948,15 @@ func newestCommandID(cmds []engine.CommandSeen) int64 { return best.ID } -func codexCommandTime(commands []engine.CommandSeen, id int64) *time.Time { +// commandCreatedAt resolves a command comment's creation time, falling back +// to the caller's clock when GitHub omitted it. +func commandCreatedAt(commands []engine.CommandSeen, id int64, fallback time.Time) time.Time { for _, command := range commands { if command.ID == id && !command.CreatedAt.IsZero() { - at := command.CreatedAt.UTC() - return &at + return command.CreatedAt.UTC() } } - return nil + return fallback.UTC() } // fireCoReviewWait bounds a co-review wait: CodeRabbit already reviewed the head @@ -865,11 +972,10 @@ func (s *Service) fireCoReviewWait(ctx context.Context, round Round, obs engine. if s.cfg.DryRun { return result, nil } - codexID := newestCommandID(obs.CodexCommands) - // The anchor is the wait's evidence floor. Prefer the adopted command's - // time; with no command (auto-review) fall back to the primary bot's head - // review — a SHA-less legacy clean summary posted after either must count, - // or an answer that already exists is hidden until the deadline. + // The anchor is the wait's evidence floor. Prefer an adopted trigger + // command's time; with no command (auto-review) fall back to the primary + // bot's head review — a SHA-less legacy clean summary posted after either + // must count, or an answer that already exists is hidden until the deadline. anchor := now for _, rv := range obs.Reviews { if isConfiguredBotLogin(s.cfg.Bot, rv.Bot) && rv.Commit != "" && strings.HasPrefix(rv.Commit, round.Head) && @@ -877,10 +983,21 @@ func (s *Service) fireCoReviewWait(ctx context.Context, round Round, obs engine. anchor = rv.SubmittedAt } } - for _, c := range obs.CodexCommands { - if c.ID == codexID && !c.CreatedAt.IsZero() { - anchor = c.CreatedAt + type adoptCmd struct { + login string + id int64 + at time.Time + } + var adopts []adoptCmd + for _, cp := range s.policy().CoReviewerPolicies() { + cmds := obs.CoSeenFor(cp.Login).Commands + id := newestCommandID(cmds) + if id == 0 { + continue } + at := commandCreatedAt(cmds, id, now) + adopts = append(adopts, adoptCmd{login: cp.Login, id: id, at: at}) + anchor = at } changed := false updated, err := s.store.Update(ctx, func(st *State) error { @@ -893,9 +1010,13 @@ func (s *Service) fireCoReviewWait(ctx context.Context, round Round, obs engine. if err := r.AwaitCoReview(deadline, anchor); err != nil { return err } - if r.CodexCommandID == 0 && codexID != 0 { - r.CodexCommandID = codexID - r.CodexCommandedAt = codexCommandTime(obs.CodexCommands, codexID) + // Existing trigger commands are adopted as the round's command anchors + // so the self-heal path (which anchors on the round's fire time, later + // than a pre-existing command) does not re-post them. + for _, a := range adopts { + if r.Co(a.login).CommandID == 0 { + r.SetCoCommand(a.login, a.id, a.at) + } } st.PutRound(*r) changed = true @@ -912,10 +1033,10 @@ func (s *Service) fireCoReviewWait(ctx context.Context, round Round, obs engine. } // recordFire records the posted command on the reserved round, with a 30s retry -// on a transient state-write failure so a fired command is never lost. codexID -// is the Codex command comment posted alongside (0 when none), recorded in the -// same write. -func (s *Service) recordFire(ctx context.Context, round Round, token string, commandID, codexID int64, firedAt, now time.Time) (State, error) { +// on a transient state-write failure so a fired command is never lost. coPosts +// are the co-reviewer trigger comments posted alongside (empty when none), +// recorded in the same write. +func (s *Service) recordFire(ctx context.Context, round Round, token string, commandID int64, coPosts []coPost, firedAt, now time.Time) (State, error) { record := func(c context.Context) (State, bool, error) { recorded := false st, err := s.store.Update(c, func(st *State) error { @@ -927,8 +1048,10 @@ func (s *Service) recordFire(ctx context.Context, round Round, token string, com if err := r.Fire(commandID, firedAt); err != nil { return err } - if codexID != 0 { - r.CodexCommandID = codexID + for _, p := range coPosts { + if r.Co(p.login).CommandID == 0 { + r.SetCoCommand(p.login, p.id, p.at) + } } lf := firedAt st.LastFired = &lf @@ -956,24 +1079,28 @@ func (s *Service) recordFire(ctx context.Context, round Round, token string, com return st, nil } -// postCodexReviewComment posts the Codex review command and returns its comment -// id, or 0 on failure. A failed post is non-fatal: it logs and leaves -// CodexCommandID unset so a later pump's self-heal retries. The fresh-fire path +// postCoTrigger posts login's trigger command and returns its comment id, or +// 0 on failure. A failed post is non-fatal: it logs and leaves the round's +// command unset so a later pump's self-heal retries. The fresh-fire path // folds the returned id into recordFire's write. -func (s *Service) postCodexReviewComment(ctx context.Context, round Round) (int64, time.Time) { - comment, err := s.gh.PostIssueComment(ctx, round.Repo, round.PR, s.cfg.CodexCommand) +func (s *Service) postCoTrigger(ctx context.Context, round Round, login string) (int64, time.Time) { + command := strings.TrimSpace(s.coCommandFor(login)) + if command == "" { + return 0, time.Time{} + } + comment, err := s.gh.PostIssueComment(ctx, round.Repo, round.PR, command) if err != nil { if s.log != nil { - s.log.Printf("warning: Codex review command post failed for %s@%s: %v (will retry on a later pump)", QueueKey(round.Repo, round.PR), round.Head, err) + s.log.Printf("warning: %s trigger post failed for %s@%s: %v (will retry on a later pump)", dialect.NormalizeBotName(login), QueueKey(round.Repo, round.PR), round.Head, err) } return 0, time.Time{} } if s.log != nil { - s.log.Printf("fire %s@%s (posted %s)", QueueKey(round.Repo, round.PR), round.Head, strings.TrimSpace(s.cfg.CodexCommand)) + s.log.Printf("fire %s@%s (posted %s)", QueueKey(round.Repo, round.PR), round.Head, command) } - // The command's GitHub timestamp is the evidence anchor: a fast Codex - // reply can otherwise land before a local post-return clock reading and - // fall outside the stored cutoff forever (with no repost possible). + // The command's GitHub timestamp is the evidence anchor: a fast reply can + // otherwise land before a local post-return clock reading and fall outside + // the stored cutoff forever (with no repost possible). at := comment.CreatedAt.UTC() if at.IsZero() { at = s.clock().UTC() @@ -981,15 +1108,16 @@ func (s *Service) postCodexReviewComment(ctx context.Context, round Round) (int6 return comment.ID, at } -// fireCodexReview posts the Codex review command for an already-fired round and -// records its id under CAS. It is used by the adopt fire path and the self-heal -// retry (the fresh-post path records the id inside recordFire instead). The CAS -// guard (same head, CodexCommandID still unset) makes a concurrent post benign. -func (s *Service) fireCodexReview(ctx context.Context, round Round) { - codexID, codexAt := s.postCodexReviewComment(ctx, round) - if codexID == 0 { +// fireCoTrigger posts login's trigger command for an already-fired round and +// records its id under CAS. It is used by the adopt fire path and the +// self-heal retry (the fresh-post path records the id inside recordFire +// instead). The CAS guard (same head, command still unset) makes a concurrent +// post benign. +func (s *Service) fireCoTrigger(ctx context.Context, round Round, login string) { + id, at := s.postCoTrigger(ctx, round, login) + if id == 0 { // Failed post: KEEP the claim — its TTL is the retry backoff. Clearing it - // here would let the very next pump repost, bypassing codexClaimTTL. + // here would let the very next pump repost, bypassing triggerClaimTTL. return } updated, err := s.store.Update(ctx, func(st *State) error { @@ -999,147 +1127,170 @@ func (s *Service) fireCodexReview(ctx context.Context, round Round) { if !sameRound(r, round) { return ErrNoChange } - r.CodexClaimedAt = nil - if r.CodexCommandID == 0 { - r.CodexCommandID = codexID - r.CodexCommandedAt = &codexAt + if r.Co(login).CommandID == 0 { + r.SetCoCommand(login, id, at) + } else { + r.ClearCoClaim(login) } st.PutRound(*r) return nil }) if err != nil { if s.log != nil && !errors.Is(err, ErrNoChange) { - s.log.Printf("warning: failed to record Codex command %d for %s: %v", codexID, QueueKey(round.Repo, round.PR), err) + s.log.Printf("warning: failed to record %s command %d for %s: %v", dialect.NormalizeBotName(login), id, QueueKey(round.Repo, round.PR), err) } return } s.sync(ctx, updated) } -// fireCodexDeferred posts ONLY the Codex review command for a round the -// CodeRabbit account block is holding back. Unlike fireCodexOnly (CodeRabbit -// already reviewed the head) the round stays queued/awaiting-retry: the -// CodeRabbit review is still owed and fires normally the moment the window -// opens, at which point PostCodex stays false because CodexCommandID is set. -// The claim-then-post shape mirrors selfHealCodex — this path is not -// serialized by the fire slot either. -func (s *Service) fireCodexDeferred(ctx context.Context, round Round, adoptID int64, adoptAt time.Time, reason string, now time.Time) (PumpResult, error) { - result := PumpResult{Action: "codex_fired", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason} - if s.cfg.DryRun { - return result, nil - } - if adoptID != 0 { - adopted := false - updated, err := s.store.Update(ctx, func(st *State) error { - r := st.Round(round.Repo, round.PR) - if !sameRound(r, round) || r.CodexCommandID != 0 || - (r.Phase != PhaseQueued && r.Phase != PhaseAwaitingRetry) { - return ErrNoChange +// fireCoDeferred posts (or adopts) ONLY co-reviewer trigger commands for a +// round the CodeRabbit account block (or a busy slot) is holding back. Unlike +// fireCoOnly (CodeRabbit already reviewed the head) the round stays +// queued/awaiting-retry: the CodeRabbit review is still owed and fires +// normally the moment the window opens, at which point the recorded command +// ids keep the triggers from re-posting. The claim-then-post shape mirrors +// selfHealCoReviewers — this path is not serialized by the fire slot either. +func (s *Service) fireCoDeferred(ctx context.Context, round Round, d engine.FireDecision, now time.Time) (PumpResult, error) { + action := func(base string) string { + // Preserve the historical action names for the Codex-only case. + if len(d.PostCo) <= 1 && len(d.AdoptCo) <= 1 { + onlyCodex := true + for _, login := range d.PostCo { + onlyCodex = onlyCodex && dialect.IsCodexBot(login) } - // A concurrent worker may already be posting the command represented by - // this observation. Let its fresh claim finish instead of adopting and - // racing its state write; a stale claim no longer blocks recovery. - if r.CodexClaimedAt != nil && now.Sub(r.CodexClaimedAt.UTC()) < codexClaimTTL { - return ErrNoChange + for login := range d.AdoptCo { + onlyCodex = onlyCodex && dialect.IsCodexBot(login) } - at := adoptAt.UTC() - if at.IsZero() { - at = now.UTC() + if onlyCodex { + return "codex_" + base } - r.CodexCommandID = adoptID - r.CodexCommandedAt = &at - r.CodexClaimedAt = nil - r.Note = "existing codex review command adopted; coderabbit deferred (account rate-limited)" - st.PutRound(*r) - adopted = true - return nil - }) - if err != nil && !errors.Is(err, ErrNoChange) { - return result, err - } - if !adopted { - result.Action = "deduped" - result.Reason = "codex command already adopted or posted" - return result, nil } - s.sync(ctx, updated) - result.Action = "codex_adopted" + return "co_" + base + } + result := PumpResult{Action: action("fired"), Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: d.Reason} + if s.cfg.DryRun { return result, nil } - claimed := false + + adopted, claimed := 0, []string{} updated, err := s.store.Update(ctx, func(st *State) error { + adopted, claimed = 0, claimed[:0] r := st.Round(round.Repo, round.PR) - if !sameRound(r, round) || r.CodexCommandID != 0 { + if !sameRound(r, round) || (r.Phase != PhaseQueued && r.Phase != PhaseAwaitingRetry) { return ErrNoChange } - if r.Phase != PhaseQueued && r.Phase != PhaseAwaitingRetry { - return ErrNoChange + changed := false + for login, cmd := range d.AdoptCo { + c := r.Co(login) + if c.CommandID != 0 { + continue + } + // A concurrent worker may already be posting the command represented + // by this observation. Let its fresh claim finish instead of adopting + // and racing its state write; a stale claim no longer blocks recovery. + if c.ClaimedAt != nil && now.Sub(c.ClaimedAt.UTC()) < triggerClaimTTL { + continue + } + at := cmd.CreatedAt + if at.IsZero() { + at = cmd.UpdatedAt + } + if at.IsZero() { + at = now + } + r.SetCoCommand(login, cmd.ID, at) + adopted++ + changed = true + } + for _, login := range d.PostCo { + c := r.Co(login) + if c.CommandID != 0 { + continue + } + if c.ClaimedAt != nil && now.Sub(c.ClaimedAt.UTC()) < triggerClaimTTL { + continue + } + r.ClaimCo(login, now) + claimed = append(claimed, login) + changed = true } - if r.CodexClaimedAt != nil && now.Sub(r.CodexClaimedAt.UTC()) < codexClaimTTL { + if !changed { return ErrNoChange } - t := now.UTC() - r.CodexClaimedAt = &t - r.Note = "codex review requested; coderabbit deferred (account rate-limited)" + r.Note = "co-review requested; coderabbit deferred (account rate-limited)" st.PutRound(*r) - claimed = true return nil }) if err != nil && !errors.Is(err, ErrNoChange) { return result, err } - if !claimed { + if adopted == 0 && len(claimed) == 0 { result.Action = "deduped" - result.Reason = "codex command already claimed or posted" + result.Reason = "co-reviewer commands already claimed, adopted, or posted" return result, nil } s.sync(ctx, updated) - s.fireCodexReview(ctx, round) + for _, login := range claimed { + s.fireCoTrigger(ctx, round, login) + } + if len(claimed) == 0 { + result.Action = action("adopted") + } return result, nil } -// selfHealCodex re-posts the Codex review command for a fired/reviewing round -// whose initial Codex post failed (CodexCommandID still 0). It runs on the -// daemon's progress/sweep paths; idempotence comes from the observation — Codex -// evidence, a live `@codex review` command, or an account that reviews on its -// own all suppress it — not a retry counter. -func (s *Service) selfHealCodex(ctx context.Context, round Round, obs engine.Observation, now time.Time) { - if s.cfg.DryRun || round.CodexCommandID != 0 || round.FiredAt == nil || obs.Head != round.Head { +// selfHealCoReviewers posts (or re-posts) co-reviewer trigger commands for a +// fired/reviewing round: an always-mode bot whose initial post failed (its +// command id still 0), or a selfheal-mode bot observed active that missed the +// head past its grace period. It runs on the daemon's progress/sweep paths; +// idempotence comes from the observation — the bot's evidence, a live trigger +// command, or an account that reviews on its own all suppress it (see +// DecideCoPost) — not a retry counter. +func (s *Service) selfHealCoReviewers(ctx context.Context, round Round, obs engine.Observation, now time.Time) { + if s.cfg.DryRun || round.FiredAt == nil || obs.Head != round.Head { return } - commandPresent := engine.CodexCommandSince(obs, round.FiredAt.UTC()) - if !engine.DecideCodexPost(round, obs, s.policy(), commandPresent) { - return - } - // Claim the post under CAS BEFORE the network call: this sweep path is not - // serialized by the fire slot, so two concurrent pumps observing - // CodexCommandID == 0 would otherwise both post. A claim older than - // codexClaimTTL is stale (the poster died mid-flight) and may be re-claimed. - claimed := false - updated, err := s.store.Update(ctx, func(st *State) error { - r := st.Round(round.Repo, round.PR) - if !sameRound(r, round) || r.CodexCommandID != 0 { - return ErrNoChange + firedAt := round.FiredAt.UTC() + for _, cp := range s.policy().CoReviewerPolicies() { + if round.Co(cp.Login).CommandID != 0 || (dialect.IsCodexBot(cp.Login) && round.CodexCommandID != 0) { + continue } - if r.CodexClaimedAt != nil && now.Sub(r.CodexClaimedAt.UTC()) < codexClaimTTL { - return ErrNoChange + commandPresent := engine.CoCommandSince(obs, cp.Login, firedAt) + if !engine.DecideCoPost(round, obs, cp, commandPresent, firedAt, now) { + continue } - t := now.UTC() - r.CodexClaimedAt = &t - st.PutRound(*r) - claimed = true - return nil - }) - if err != nil || !claimed { - return + // Claim the post under CAS BEFORE the network call: this sweep path is + // not serialized by the fire slot, so two concurrent pumps observing an + // unset command would otherwise both post. A claim older than + // triggerClaimTTL is stale (the poster died mid-flight) and may be + // re-claimed. + login := cp.Login + claimed := false + updated, err := s.store.Update(ctx, func(st *State) error { + r := st.Round(round.Repo, round.PR) + if !sameRound(r, round) || r.Co(login).CommandID != 0 { + return ErrNoChange + } + if c := r.Co(login); c.ClaimedAt != nil && now.Sub(c.ClaimedAt.UTC()) < triggerClaimTTL { + return ErrNoChange + } + r.ClaimCo(login, now) + st.PutRound(*r) + claimed = true + return nil + }) + if err != nil || !claimed { + continue + } + s.sync(ctx, updated) + s.fireCoTrigger(ctx, round, login) } - s.sync(ctx, updated) - s.fireCodexReview(ctx, round) } -// codexClaimTTL bounds a Codex self-heal claim: past it, a claim whose poster -// never recorded a command id is stale and the post may be retried. -const codexClaimTTL = 2 * time.Minute +// triggerClaimTTL bounds a co-reviewer trigger claim: past it, a claim whose +// poster never recorded a command id is stale and the post may be retried. +const triggerClaimTTL = 2 * time.Minute func (s *Service) Cancel(ctx context.Context, repo string, pr int) error { repo = NormalizeRepo(repo) diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index b82ae3f..9034b5d 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -1326,7 +1326,7 @@ func TestRecordFireResetsRecordedAcrossRetry(t *testing.T) { cfg := firingConfig() svc := NewService(cfg, newFakeGitHub(), retryNoChangeStore{cfg: cfg}, nil) round := Round{Repo: "owner/repo", PR: 12, Head: "abcdef123"} - _, err := svc.recordFire(context.Background(), round, "token", 1, 0, time.Now().UTC(), time.Now().UTC()) + _, err := svc.recordFire(context.Background(), round, "token", 1, nil, time.Now().UTC(), time.Now().UTC()) if !errors.Is(err, ErrNoChange) { t.Fatalf("expected no-change after retry lost the fire slot, got %v", err) } @@ -2266,7 +2266,7 @@ func TestFireCodexDeferredAdoptionHonorsDryRunAndActiveClaim(t *testing.T) { round := *st.Round("o/carrier", 95) svc.cfg.DryRun = true - res, err := svc.fireCodexDeferred(ctx, round, 703, now.Add(-time.Minute), "dry run adopt", now) + res, err := svc.fireCoDeferred(ctx, round, engine.FireDecision{Verdict: engine.FireCoDeferred, Reason: "dry run adopt", AdoptCo: map[string]engine.CommandSeen{dialect.CodexBotLogin: {ID: 703, CreatedAt: now.Add(-time.Minute)}}}, now) if err != nil { t.Fatal(err) } @@ -2287,7 +2287,7 @@ func TestFireCodexDeferredAdoptionHonorsDryRunAndActiveClaim(t *testing.T) { }); err != nil { t.Fatal(err) } - res, err = svc.fireCodexDeferred(ctx, round, 703, now.Add(-time.Minute), "claimed adopt", now) + res, err = svc.fireCoDeferred(ctx, round, engine.FireDecision{Verdict: engine.FireCoDeferred, Reason: "claimed adopt", AdoptCo: map[string]engine.CommandSeen{dialect.CodexBotLogin: {ID: 703, CreatedAt: now.Add(-time.Minute)}}}, now) if err != nil { t.Fatal(err) } @@ -2299,8 +2299,8 @@ func TestFireCodexDeferredAdoptionHonorsDryRunAndActiveClaim(t *testing.T) { t.Fatalf("the active claim must remain untouched, got %#v", got) } - staleNow := now.Add(codexClaimTTL + time.Second) - res, err = svc.fireCodexDeferred(ctx, round, 703, now.Add(-time.Minute), "stale claim adopt", staleNow) + staleNow := now.Add(triggerClaimTTL + time.Second) + res, err = svc.fireCoDeferred(ctx, round, engine.FireDecision{Verdict: engine.FireCoDeferred, Reason: "stale claim adopt", AdoptCo: map[string]engine.CommandSeen{dialect.CodexBotLogin: {ID: 703, CreatedAt: now.Add(-time.Minute)}}}, staleNow) if err != nil { t.Fatal(err) } diff --git a/internal/crq/state.go b/internal/crq/state.go index d4e36aa..8856841 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -48,9 +48,32 @@ func (c Config) storeConfig() StoreConfig { DashboardIssue: c.DashboardIssue, Timezone: c.Timezone, Scope: c.Scope, + CoReviewers: c.coReviewerSummary(), } } +// coReviewerSummary renders the enabled co-reviewers for the dashboard row: +// "codex (required, always) · bugbot (selfheal) · macroscope (selfheal)". +// Empty with no co-bots so existing dashboards stay byte-identical. +func (c Config) coReviewerSummary() string { + parts := make([]string, 0, len(c.CoBots)) + for _, cb := range c.CoBots { + attrs := []string{} + if cb.Required { + attrs = append(attrs, "required") + } + if cb.Trigger != "" && cb.Trigger != engine.TriggerNever { + attrs = append(attrs, string(cb.Trigger)) + } + part := cb.Name + if len(attrs) > 0 { + part += " (" + strings.Join(attrs, ", ") + ")" + } + parts = append(parts, part) + } + return strings.Join(parts, " · ") +} + // NewGitStateStore builds the git-ref-backed store. The logger surfaces the // loud auto-reinit line when a stale-schema payload is loaded. func NewGitStateStore(cfg Config, gh *ghapi.GitHub, log Logger) *crqstate.GitStateStore { @@ -78,9 +101,11 @@ func issueBody(st State, cfg Config) (string, error) { return crqstate.IssueBody(st, cfg.storeConfig()) } -// policy assembles the engine Policy from config. +// policy assembles the engine Policy from config. With no parsed CoBots +// (hand-built Configs in tests), CoReviewers stays nil so the engine falls +// back to its legacy Codex synthesis from CodexCommand/RequiredBots. func (s *Service) policy() engine.Policy { - return engine.Policy{ + p := engine.Policy{ Bot: s.cfg.Bot, RequiredBots: s.cfg.RequiredBots, CodexCommand: s.cfg.CodexCommand, @@ -89,6 +114,15 @@ func (s *Service) policy() engine.Policy { RateLimitFallback: s.cfg.RateLimitFallback, RateLimitCodexDegrade: s.cfg.RateLimitCodexDegrade, } + for _, cb := range s.cfg.CoBots { + p.CoReviewers = append(p.CoReviewers, engine.CoReviewerPolicy{ + Login: cb.Login, + Command: cb.Command, + Trigger: cb.Trigger, + SelfHealGrace: cb.SelfHealGrace, + }) + } + return p } func NormalizeRepo(repo string) string { diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 9c42e96..ff77cf2 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -155,6 +155,10 @@ type Observation struct { Co map[string]CoSeen } +// CoSeenFor exposes one co-reviewer's observation slice to the apply layer +// (trigger adoption at fire time shares the engine's view of live commands). +func (o Observation) CoSeenFor(login string) CoSeen { return o.co(login) } + // co returns login's observation slice, falling back to the legacy Codex // fields when the map has no entry (engine tests and pre-migration callers // still populate those directly). diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 25b9b70..1732c76 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -69,6 +69,16 @@ func (p Policy) coReviewers() []CoReviewerPolicy { return []CoReviewerPolicy{codexPolicy(p)} } +// CoReviewerPolicies exposes the effective co-reviewer list to the apply +// layer (self-heal sweeps, trigger posting), which shares DecideCoPost with +// the fire path and so must iterate the same resolved entries. +func (p Policy) CoReviewerPolicies() []CoReviewerPolicy { return p.coReviewers() } + +// CoReviewedHead reports whether login already has head review evidence (a +// head review, a SHA-matched clean summary, or a completed check run) — the +// feedback layer surfaces it as per-bot status. +func CoReviewedHead(obs Observation, login string) bool { return coReviewedHead(obs, login) } + // requiredBot reports whether login is in RequiredBots (normalized). func requiredBot(p Policy, login string) bool { norm := dialect.NormalizeBotName(login) diff --git a/internal/state/dashboard.go b/internal/state/dashboard.go index f5a0838..c964534 100644 --- a/internal/state/dashboard.go +++ b/internal/state/dashboard.go @@ -88,6 +88,34 @@ func requestedRounds(st State) []Round { return out } +// coBotMarks renders a round's co-reviewer trigger bookkeeping for the +// feedback-wait row: ✓ = trigger posted/adopted, ⏳ = post claimed but not +// yet recorded. Empty (byte-identical row) when the round tracks no co-bots. +func coBotMarks(r Round) string { + if len(r.CoBots) == 0 { + return "" + } + names := make([]string, 0, len(r.CoBots)) + for name := range r.CoBots { + names = append(names, name) + } + sort.Strings(names) + parts := make([]string, 0, len(names)) + for _, name := range names { + c := r.CoBots[name] + switch { + case c.CommandID != 0: + parts = append(parts, name+" ✓") + case c.ClaimedAt != nil: + parts = append(parts, name+" ⏳") + } + } + if len(parts) == 0 { + return "" + } + return " · triggers: " + strings.Join(parts, ", ") +} + // RenderDashboard renders the human-facing dashboard for v3 state: rounds by // phase instead of v2's queue/fired/awaiting maps. func RenderDashboard(st State, cfg StoreConfig) string { @@ -134,6 +162,9 @@ func RenderDashboard(st State, cfg StoreConfig) string { } else { fmt.Fprintf(&b, "| **CodeRabbit quota** | ✅ not currently blocked |\n") } + if cfg.CoReviewers != "" { + fmt.Fprintf(&b, "| **Co-reviewers** | %s |\n", cfg.CoReviewers) + } fmt.Fprintf(&b, "| **Last review fired** | %s |\n", fmtStamp(st.LastFired, loc)) if slot != nil { fmt.Fprintf(&b, "| **In flight** | [%s#%d](https://github.com/%s/pull/%d) · fired %s · `%s` |\n", @@ -143,8 +174,8 @@ func RenderDashboard(st State, cfg StoreConfig) string { } if len(reviewing) > 0 { r := reviewing[0] - fmt.Fprintf(&b, "| **Feedback wait** | [%s#%d](https://github.com/%s/pull/%d) · `%s` · deadline %s |\n", - r.Repo, r.PR, r.Repo, r.PR, r.Head, fmtStamp(r.WaitDeadline, loc)) + fmt.Fprintf(&b, "| **Feedback wait** | [%s#%d](https://github.com/%s/pull/%d) · `%s` · deadline %s%s |\n", + r.Repo, r.PR, r.Repo, r.PR, r.Head, fmtStamp(r.WaitDeadline, loc), coBotMarks(r)) } else { fmt.Fprintf(&b, "| **Feedback wait** | — |\n") } diff --git a/internal/state/store.go b/internal/state/store.go index 38901e5..ffc3c63 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -38,6 +38,10 @@ type StoreConfig struct { DashboardIssue int Timezone string Scope []string + // CoReviewers is a preformatted display string of the enabled co-reviewer + // bots ("" hides the dashboard row, keeping co-bot-less dashboards + // byte-identical). + CoReviewers string } func (c StoreConfig) requireState() error { From 72566377b2c5245ff142963626ce6705fe8ddceb Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 05:09:06 +0200 Subject: [PATCH 07/31] Add end-to-end co-reviewer replay scenarios Step 7 (part 2) of the co-reviewer abstraction (Ref #34): replay coverage for the silent-clean Bugbot round converging on its check run, BUG_ID dedupe across re-reported threads, Macroscope's settled-edit resolution (both wordings), selfheal triggering exactly once past grace (suppressed by a human trigger or a running check), the empty-shell filter staying scoped to the configured bot, a wanted-only Macroscope verdict never gating, and always-mode triggers posting with the fire and healing after a failed post. --- internal/crq/coreview_replay_test.go | 557 +++++++++++++++++++++++++++ internal/crq/service_test.go | 4 + 2 files changed, 561 insertions(+) create mode 100644 internal/crq/coreview_replay_test.go diff --git a/internal/crq/coreview_replay_test.go b/internal/crq/coreview_replay_test.go new file mode 100644 index 0000000..00a24ef --- /dev/null +++ b/internal/crq/coreview_replay_test.go @@ -0,0 +1,557 @@ +package crq + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" +) + +// End-to-end scenarios for the co-reviewer abstraction (Bugbot, Macroscope): +// check-run convergence, BUG_ID dedupe, resolved-edit settlement, selfheal +// triggers, empty-shell retention, informational verdicts, and always-mode +// fire-time triggers. Bot wording comes from the dialect golden corpus so a +// rewording that breaks classification breaks these replays too. + +const ( + bugbotLogin = dialect.BugbotLogin + macroLogin = dialect.MacroscopeLogin +) + +// defaultCoBots mirrors LoadConfig's defaults for the three known bots. +func defaultCoBots() []CoBotConfig { + return []CoBotConfig{ + {Login: dialect.CodexBotLogin, Name: "codex", Command: "@codex review", Trigger: engine.TriggerNever, SelfHealGrace: 10 * time.Minute}, + {Login: bugbotLogin, Name: "bugbot", Command: "bugbot run", Trigger: engine.TriggerSelfHeal, SelfHealGrace: 10 * time.Minute}, + {Login: macroLogin, Name: "macroscope", Command: "@macroscope-app review", Trigger: engine.TriggerSelfHeal, SelfHealGrace: 10 * time.Minute}, + } +} + +func newCoReplayFixture(t *testing.T, base time.Time, mutate func(*Config)) *replayFixture { + t.Helper() + clk := newReplayClock(base) + cfg := replayConfig() + cfg.CodexCommand = "@codex review" + cfg.CoBots = defaultCoBots() + cfg.FeedbackBots = unionBots(cfg.RequiredBots, []string{dialect.CodexBotLogin, bugbotLogin, macroLogin}) + if mutate != nil { + mutate(&cfg) + } + gh := newFakeGitHub() + gh.now = clk.now + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + svc.now = clk.now + return &replayFixture{t: t, ctx: t.Context(), clk: clk, gh: gh, store: store, svc: svc, cfg: cfg, bot: cfg.Bot} +} + +// requireBugbot marks Bugbot configured-required (the CoBots entry and the +// RequiredBots fold LoadConfig would produce). +func requireBugbot(cfg *Config) { + cfg.RequiredBots = append(cfg.RequiredBots, bugbotLogin) + for i := range cfg.CoBots { + if cfg.CoBots[i].Name == "bugbot" { + cfg.CoBots[i].Required = true + } + } +} + +// corpusCheckRun loads one captured check-run object from the dialect corpus. +func corpusCheckRun(t *testing.T, name string) ghapi.CheckRun { + t.Helper() + var run ghapi.CheckRun + if err := json.Unmarshal([]byte(corpusMessage(t, name)), &run); err != nil { + t.Fatalf("corpus check run %s: %v", name, err) + } + return run +} + +// coPostedBody counts posted comments whose body equals body. +func (f *replayFixture) coPostedBody(repo string, pr int, body string) int { + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + want := QueueKey(repo, pr) + ":" + body + n := 0 + for _, p := range f.gh.posted { + if p == want { + n++ + } + } + return n +} + +// threadsGraphQL installs a GraphQL handler serving the given review-thread +// nodes (and an empty force-push timeline). +func (f *replayFixture) threadsGraphQL(nodes []map[string]any) { + payload := map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "reviewThreads": map[string]any{ + "pageInfo": map[string]any{"hasNextPage": false, "endCursor": ""}, + "nodes": nodes, + }, + }, + }, + } + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + f.gh.graphQL = func(query string, _ map[string]any, out any) error { + if strings.Contains(query, "reviewThreads") { + raw, err := json.Marshal(payload) + if err != nil { + return err + } + return json.Unmarshal(raw, out) + } + return json.Unmarshal([]byte(`{"repository":{"pullRequest":{"timelineItems":{"nodes":[]}}}}`), out) + } +} + +func threadNode(id string, resolved bool, login, path string, line int, commentID int64, body string, at time.Time) map[string]any { + return map[string]any{ + "id": id, "isResolved": resolved, "isOutdated": false, "path": path, "line": line, + "comments": map[string]any{ + "totalCount": 1, + "nodes": []map[string]any{{ + "databaseId": commentID, "body": body, "url": "https://example.test/c/" + id, + "path": path, "line": line, "originalLine": line, + "createdAt": at.UTC().Format(time.RFC3339), + "author": map[string]any{"login": login}, + }}, + }, + } +} + +// --- 1. required Bugbot, silent-clean round --------------------------------- + +// TestCoReplayBugbotSilentCleanConvergesOnCheckRun: Bugbot posts NOTHING on +// the timeline for a clean round — only its check run. A required Bugbot +// round must wait while the check runs and converge once it completes clean. +func TestCoReplayBugbotSilentCleanConvergesOnCheckRun(t *testing.T) { + base := time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC) + f := newCoReplayFixture(t, base, requireBugbot) + repo, pr, sha := "o/r", 31, "abcdef1234567890" + f.openPull(repo, pr, sha) + f.setCommitDate(sha, base.Add(-time.Hour)) + + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected the CodeRabbit fire, got %+v", res) + } + // Bugbot's selfheal trigger must NOT post at fire time. + if got := f.coPostedBody(repo, pr, "bugbot run"); got != 0 { + t.Fatalf("selfheal must not trigger at fire time, got %d posts", got) + } + + // CodeRabbit reviews the head; Bugbot's check is still running. + f.clk.advance(2 * time.Minute) + f.botReview(repo, pr, 500, sha, f.clk.now()) + running := corpusCheckRun(t, "bugbot/check-in-progress.json") + f.gh.setCheckRuns(sha, running) + f.pump() // review submitted; bugbot still required → reviewing, not complete + if r := f.round(repo, pr); r == nil || r.Phase != PhaseReviewing { + t.Fatalf("round must keep waiting on the running check, got %+v", r) + } + rep, err := f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatal(err) + } + if rep.Converged { + t.Fatalf("an in-progress check must not converge the round: %+v", rep) + } + if st := rep.CoReviewers[dialect.NormalizeBotName(bugbotLogin)]; st.CheckState != "in_progress" || st.Reviewed { + t.Fatalf("co_reviewers must report the running check, got %+v", st) + } + + // The check completes clean (conclusion success, "no issues found"). + f.clk.advance(3 * time.Minute) + clean := corpusCheckRun(t, "bugbot/check-clean.json") + clean.CompletedAt = f.clk.now() + f.gh.setCheckRuns(sha, clean) + rep, err = f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatal(err) + } + if !rep.Converged || len(rep.Findings) != 0 { + t.Fatalf("the clean check run must converge the silent-clean round: %+v", rep) + } + if st := rep.CoReviewers[dialect.NormalizeBotName(bugbotLogin)]; st.CheckState != "clean" || !st.Reviewed { + t.Fatalf("co_reviewers must report the clean check, got %+v", st) + } + f.pump() // the reviewing sweep completes the round + if r := f.round(repo, pr); r == nil || r.Phase != PhaseCompleted { + t.Fatalf("round must complete, got %+v", r) + } + if got := f.coPostedBody(repo, pr, "bugbot run"); got != 0 { + t.Fatalf("no trigger may ever post for an auto-reviewing clean round, got %d", got) + } +} + +// --- 2. Bugbot findings + BUG_ID dedupe ------------------------------------- + +// TestCoReplayBugbotFindingsDedupeOnBugID: Bugbot re-reports the same bug in a +// new thread after a push; the BUG_ID marker must collapse both threads to ONE +// finding, and resolving them (with the completed check) converges. +func TestCoReplayBugbotFindingsDedupeOnBugID(t *testing.T) { + base := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) + f := newCoReplayFixture(t, base, requireBugbot) + repo, pr, sha := "o/r", 32, "abcdef1234567890" + head := sha[:9] + f.openPull(repo, pr, sha) + f.setCommitDate(sha, base.Add(-time.Hour)) + firedAt := base.Add(-10 * time.Minute) + seedRound(t, f.store, f.cfg, repo, pr, head, PhaseReviewing, firedAt, 400) + + f.botReview(repo, pr, 500, sha, base.Add(-5*time.Minute)) + issues := corpusCheckRun(t, "bugbot/check-issues.json") + issues.CompletedAt = base.Add(-4 * time.Minute) + f.gh.setCheckRuns(sha, issues) + // The carrier review summary () must not add findings. + f.gh.mu.Lock() + carrier := ghapi.Review{ID: 501, CommitID: sha, State: "COMMENTED", SubmittedAt: base.Add(-4 * time.Minute), Body: corpusMessage(t, "bugbot/review-summary-issues.md")} + carrier.User.Login = bugbotLogin + f.gh.reviews[fakeKey(repo, pr)] = append(f.gh.reviews[fakeKey(repo, pr)], carrier) + f.gh.mu.Unlock() + + finding := corpusMessage(t, "bugbot/inline-finding-high.md") + f.threadsGraphQL([]map[string]any{ + threadNode("PRRT_1", false, "cursor", "apps/server/src/orchestration/projector.ts", 447, 901, finding, base.Add(-4*time.Minute)), + threadNode("PRRT_2", false, "cursor", "apps/server/src/orchestration/projector.ts", 451, 902, finding, base.Add(-2*time.Minute)), + }) + + rep, err := f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatal(err) + } + bugbotFindings := 0 + for _, fd := range rep.Findings { + if dialect.NormalizeBotName(fd.Bot) == "cursor" { + bugbotFindings++ + } + } + if bugbotFindings != 1 { + t.Fatalf("the shared BUG_ID must dedupe to one finding, got %d: %+v", bugbotFindings, rep.Findings) + } + if rep.Converged { + t.Fatal("open findings must block convergence") + } + if st := rep.CoReviewers[dialect.NormalizeBotName(bugbotLogin)]; st.CheckState != "issues" || !st.Reviewed { + t.Fatalf("co_reviewers must report the completed findings check, got %+v", st) + } + + // Resolving the threads clears the findings; the completed check + the + // CodeRabbit review converge the round. + f.threadsGraphQL([]map[string]any{ + threadNode("PRRT_1", true, "cursor", "apps/server/src/orchestration/projector.ts", 447, 901, finding, base.Add(-4*time.Minute)), + threadNode("PRRT_2", true, "cursor", "apps/server/src/orchestration/projector.ts", 451, 902, finding, base.Add(-2*time.Minute)), + }) + rep, err = f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatal(err) + } + if !rep.Converged || len(rep.Findings) != 0 { + t.Fatalf("resolved threads + completed check must converge: %+v", rep) + } +} + +// --- 3. Macroscope resolved-edit -------------------------------------------- + +// TestCoReplayMacroscopeResolvedEditSettlesFinding: Macroscope never resolves +// threads — it EDITS the finding comment to append its settled marker. The +// edited finding must vanish (both wordings) while an untouched finding still +// surfaces. +func TestCoReplayMacroscopeResolvedEditSettlesFinding(t *testing.T) { + base := time.Date(2026, 7, 20, 11, 0, 0, 0, time.UTC) + f := newCoReplayFixture(t, base, nil) + repo, pr, sha := "o/r", 33, "abcdef1234567890" + head := sha[:9] + f.openPull(repo, pr, sha) + f.setCommitDate(sha, base.Add(-time.Hour)) + seedRound(t, f.store, f.cfg, repo, pr, head, PhaseReviewing, base.Add(-10*time.Minute), 400) + f.botReview(repo, pr, 500, sha, base.Add(-5*time.Minute)) + + open := corpusMessage(t, "macroscope/inline-finding-high.md") + resolved := corpusMessage(t, "macroscope/inline-finding-resolved.md") + noLonger := corpusMessage(t, "macroscope/inline-finding-no-longer-relevant.md") + f.threadsGraphQL([]map[string]any{ + threadNode("PRRT_1", false, "macroscopeapp", "preview/handlers.ts", 57, 901, open, base.Add(-4*time.Minute)), + threadNode("PRRT_2", false, "macroscopeapp", "Layers/ProjectionPipeline.ts", 1554, 902, resolved, base.Add(-4*time.Minute)), + threadNode("PRRT_3", false, "macroscopeapp", "home/homeThreadList.ts", 105, 903, noLonger, base.Add(-4*time.Minute)), + }) + + rep, err := f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatal(err) + } + var macro []dialect.Finding + for _, fd := range rep.Findings { + if dialect.NormalizeBotName(fd.Bot) == "macroscopeapp" { + macro = append(macro, fd) + } + } + if len(macro) != 1 || macro[0].Line != 57 { + t.Fatalf("only the un-settled finding may surface, got %+v", macro) + } + + // The open finding gets the settled edit too → nothing left, convergence + // clears naturally (thread rebuttal machinery untouched: nothing surfaces + // from these single-comment threads). + f.threadsGraphQL([]map[string]any{ + threadNode("PRRT_1", false, "macroscopeapp", "preview/handlers.ts", 57, 901, "\n"+open+"\n\n✅ Resolved in "+sha, base.Add(-time.Minute)), + threadNode("PRRT_2", false, "macroscopeapp", "Layers/ProjectionPipeline.ts", 1554, 902, resolved, base.Add(-4*time.Minute)), + threadNode("PRRT_3", false, "macroscopeapp", "home/homeThreadList.ts", 105, 903, noLonger, base.Add(-4*time.Minute)), + }) + rep, err = f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatal(err) + } + for _, fd := range rep.Findings { + if dialect.NormalizeBotName(fd.Bot) == "macroscopeapp" { + t.Fatalf("a settled-edit finding must not surface: %+v", fd) + } + } + if !rep.Converged { + t.Fatalf("with every finding settled the round must converge: %+v", rep) + } +} + +// --- 4. selfheal trigger ---------------------------------------------------- + +// TestCoReplaySelfHealTriggersOnceAfterGrace: an auto-active Bugbot that +// missed the head gets exactly one `bugbot run` after the grace period — no +// repost, no post while its check runs, no post over a human trigger. +func TestCoReplaySelfHealTriggersOnceAfterGrace(t *testing.T) { + base := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + f := newCoReplayFixture(t, base, requireBugbot) + repo, pr, sha := "o/r", 34, "abcdef1234567890" + f.openPull(repo, pr, sha) + f.setCommitDate(sha, base.Add(-2*time.Hour)) + // Bugbot auto-reviewed an OLDER head (uncommanded evidence → AutoActive). + f.gh.mu.Lock() + old := ghapi.Review{ID: 400, CommitID: "0123456789aaaaaa", State: "COMMENTED", SubmittedAt: base.Add(-90 * time.Minute), Body: "### Old finding\n\n**High Severity**"} + old.User.Login = bugbotLogin + f.gh.reviews[fakeKey(repo, pr)] = append(f.gh.reviews[fakeKey(repo, pr)], old) + f.gh.mu.Unlock() + + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected the CodeRabbit fire, got %+v", res) + } + f.botReview(repo, pr, 500, sha, f.clk.now().Add(time.Minute)) + + // Inside the grace period: no trigger. + f.clk.advance(5 * time.Minute) + f.pump() + if got := f.coPostedBody(repo, pr, "bugbot run"); got != 0 { + t.Fatalf("selfheal must wait out the grace period, got %d posts", got) + } + // Past the grace period: exactly one trigger, recorded on the round. + f.clk.advance(6 * time.Minute) + f.pump() + if got := f.coPostedBody(repo, pr, "bugbot run"); got != 1 { + t.Fatalf("selfheal must post exactly once, got %d", got) + } + if r := f.round(repo, pr); r == nil || r.Co(bugbotLogin).CommandID == 0 { + t.Fatalf("the trigger must be recorded on the round, got %+v", r) + } + f.clk.advance(20 * time.Minute) + f.pump() + if got := f.coPostedBody(repo, pr, "bugbot run"); got != 1 { + t.Fatalf("a recorded command must never repost, got %d", got) + } +} + +// TestCoReplaySelfHealSuppressedByHumanTriggerAndRunningCheck: a live human +// `bugbot run` (or `cursor review`) after the fire, or an in-progress Bugbot +// check, suppresses the selfheal post. +func TestCoReplaySelfHealSuppressedByHumanTriggerAndRunningCheck(t *testing.T) { + base := time.Date(2026, 7, 20, 13, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name string + setup func(f *replayFixture, repo string, pr int, sha string) + }{ + {name: "human trigger (alt spelling)", setup: func(f *replayFixture, repo string, pr int, sha string) { + f.humanComment(repo, pr, 700, "cursor review", f.clk.now()) + }}, + {name: "running check", setup: func(f *replayFixture, repo string, pr int, sha string) { + f.gh.setCheckRuns(sha, corpusCheckRun(f.t, "bugbot/check-in-progress.json")) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newCoReplayFixture(t, base, requireBugbot) + repo, pr, sha := "o/r", 35, "abcdef1234567890" + f.openPull(repo, pr, sha) + f.setCommitDate(sha, base.Add(-2*time.Hour)) + f.gh.mu.Lock() + old := ghapi.Review{ID: 400, CommitID: "0123456789aaaaaa", State: "COMMENTED", SubmittedAt: base.Add(-90 * time.Minute), Body: "### Old finding\n\n**High Severity**"} + old.User.Login = bugbotLogin + f.gh.reviews[fakeKey(repo, pr)] = append(f.gh.reviews[fakeKey(repo, pr)], old) + f.gh.mu.Unlock() + + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected the fire, got %+v", res) + } + f.botReview(repo, pr, 500, sha, f.clk.now().Add(time.Minute)) + tc.setup(f, repo, pr, sha) + f.clk.advance(11 * time.Minute) + f.pump() + f.pump() + if got := f.coPostedBody(repo, pr, "bugbot run"); got != 0 { + t.Fatalf("selfheal must stay quiet (%s), got %d posts", tc.name, got) + } + }) + } +} + +// --- 5. empty-shell regression ---------------------------------------------- + +// TestCoReplayEmptyShellScopedToConfiguredBot: both new bots deliver findings +// on empty-body COMMENTED reviews — those carriers ARE review evidence and +// must be retained, while the configured bot's empty shell is still dropped. +func TestCoReplayEmptyShellScopedToConfiguredBot(t *testing.T) { + base := time.Date(2026, 7, 20, 14, 0, 0, 0, time.UTC) + f := newCoReplayFixture(t, base, nil) + repo, pr, sha := "o/r", 36, "abcdef1234567890" + head := sha[:9] + f.openPull(repo, pr, sha) + f.setCommitDate(sha, base.Add(-time.Hour)) + seedRound(t, f.store, f.cfg, repo, pr, head, PhaseReviewing, base.Add(-10*time.Minute), 400) + + // A CodeRabbit empty COMMENTED shell (dropped) and a Macroscope empty + // COMMENTED carrier (retained as its head review evidence). + f.shellReview(repo, pr, 500, sha, base.Add(-5*time.Minute)) + f.gh.mu.Lock() + carrier := ghapi.Review{ID: 501, CommitID: sha, State: "COMMENTED", SubmittedAt: base.Add(-4 * time.Minute)} + carrier.User.Login = macroLogin + f.gh.reviews[fakeKey(repo, pr)] = append(f.gh.reviews[fakeKey(repo, pr)], carrier) + f.gh.mu.Unlock() + + rep, err := f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatal(err) + } + if rep.ReviewedBy[f.bot] { + t.Fatalf("the configured bot's empty shell must not count as review evidence: %+v", rep.ReviewedBy) + } + if st := rep.CoReviewers[dialect.NormalizeBotName(macroLogin)]; !st.Reviewed { + t.Fatalf("macroscope's empty carrier review must count as head evidence: %+v", rep.CoReviewers) + } +} + +// --- 6. wanted-only Macroscope verdict -------------------------------------- + +// TestCoReplayWantedOnlyMacroscopeVerdictNeverGates: a wanted (non-required) +// Macroscope surfaces its status and verdict, and a "Needs human review" +// verdict never blocks convergence or changes exit semantics. +func TestCoReplayWantedOnlyMacroscopeVerdictNeverGates(t *testing.T) { + base := time.Date(2026, 7, 20, 15, 0, 0, 0, time.UTC) + f := newCoReplayFixture(t, base, nil) + repo, pr, sha := "o/r", 37, "abcdef1234567890" + head := sha[:9] + f.openPull(repo, pr, sha) + f.setCommitDate(sha, base.Add(-time.Hour)) + firedAt := base.Add(-10 * time.Minute) + seedRound(t, f.store, f.cfg, repo, pr, head, PhaseReviewing, firedAt, 400) + f.botReview(repo, pr, 500, sha, base.Add(-5*time.Minute)) + + // Macroscope participates: an inline-carrier review, its checks, and the + // per-round approvability verdict comment. + f.gh.mu.Lock() + carrier := ghapi.Review{ID: 501, CommitID: sha, State: "COMMENTED", SubmittedAt: base.Add(-4 * time.Minute)} + carrier.User.Login = macroLogin + f.gh.reviews[fakeKey(repo, pr)] = append(f.gh.reviews[fakeKey(repo, pr)], carrier) + verdict := ghapi.IssueComment{ID: 800, Body: corpusMessage(t, "macroscope/approvability-needs-human.md"), CreatedAt: base.Add(-3 * time.Minute), UpdatedAt: base.Add(-3 * time.Minute)} + verdict.User.Login = macroLogin + f.gh.comments[fakeKey(repo, pr)] = append(f.gh.comments[fakeKey(repo, pr)], verdict) + f.gh.mu.Unlock() + clean := corpusCheckRun(t, "macroscope/check-correctness-clean.json") + clean.CompletedAt = base.Add(-3 * time.Minute) + f.gh.setCheckRuns(sha, clean) + + rep, err := f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatal(err) + } + if !rep.Converged || len(rep.Findings) != 0 { + t.Fatalf("a needs-human verdict must not gate convergence: %+v", rep) + } + st := rep.CoReviewers[dialect.NormalizeBotName(macroLogin)] + if st.Verdict != "needs_human_review" || !st.Reviewed || st.CheckState != "clean" { + t.Fatalf("co_reviewers must carry the informational verdict, got %+v", st) + } + // The verdict comment itself must never surface as a finding. + for _, fd := range rep.Findings { + if fd.CommentID == 800 { + t.Fatalf("the approvability comment surfaced as a finding: %+v", fd) + } + } +} + +// --- 7. always-mode fire-time trigger --------------------------------------- + +// TestCoReplayAlwaysModePostsWithFireAndHealsAfterFailure: an always-mode +// Bugbot trigger posts atomically with the CodeRabbit fire; when the trigger +// post fails, the fire still stands and a later pump heals it exactly once. +func TestCoReplayAlwaysModePostsWithFireAndHealsAfterFailure(t *testing.T) { + base := time.Date(2026, 7, 20, 16, 0, 0, 0, time.UTC) + alwaysBugbot := func(cfg *Config) { + requireBugbot(cfg) + for i := range cfg.CoBots { + if cfg.CoBots[i].Name == "bugbot" { + cfg.CoBots[i].Trigger = engine.TriggerAlways + } + } + } + + f := newCoReplayFixture(t, base, alwaysBugbot) + repo, pr, sha := "o/r", 38, "abcdef1234567890" + f.openPull(repo, pr, sha) + f.setCommitDate(sha, base.Add(-time.Hour)) + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected the fire, got %+v", res) + } + if got := f.coPostedBody(repo, pr, "bugbot run"); got != 1 { + t.Fatalf("always-mode must post the trigger with the fire, got %d", got) + } + if r := f.round(repo, pr); r == nil || r.Co(bugbotLogin).CommandID == 0 { + t.Fatalf("the trigger id must land in the fire write, got %+v", r) + } + + // Failure path: the trigger post fails while the fire succeeds. + f2 := newCoReplayFixture(t, base, alwaysBugbot) + f2.openPull(repo, pr, sha) + f2.setCommitDate(sha, base.Add(-time.Hour)) + f2.gh.mu.Lock() + f2.gh.postBodyErrs = map[string]error{"bugbot run": errors.New("boom")} + f2.gh.mu.Unlock() + f2.enqueue(repo, pr) + if res := f2.pump(); res.Action != "fired" { + t.Fatalf("the CodeRabbit fire must survive a failed trigger post, got %+v", res) + } + if r := f2.round(repo, pr); r == nil || r.Co(bugbotLogin).CommandID != 0 { + t.Fatalf("a failed post must leave the command unset for the heal, got %+v", r) + } + f2.gh.mu.Lock() + f2.gh.postBodyErrs = nil + f2.gh.mu.Unlock() + f2.clk.advance(time.Minute) + f2.pump() // progress/sweep path heals the missing trigger + if got := f2.coPostedBody(repo, pr, "bugbot run"); got != 1 { + t.Fatalf("the heal must post exactly once, got %d", got) + } + if r := f2.round(repo, pr); r == nil || r.Co(bugbotLogin).CommandID == 0 { + t.Fatalf("the healed trigger must be recorded, got %+v", r) + } + f2.clk.advance(time.Minute) + f2.pump() + if got := f2.coPostedBody(repo, pr, "bugbot run"); got != 1 { + t.Fatalf("no repost after the heal, got %d", got) + } +} diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 9034b5d..01f599f 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -27,6 +27,7 @@ type fakeGitHub struct { reactions map[int64][]ghapi.Reaction checkRuns map[string][]ghapi.CheckRun // key: ref (short or full sha) checkRunErrs map[string]error + postBodyErrs map[string]error // body → error (selective trigger-post failures) posted []string deleted []int64 commentID int64 @@ -144,6 +145,9 @@ func (f *fakeGitHub) PostIssueComment(_ context.Context, repo string, pr int, bo if err := f.postErrs[fakeKey(repo, pr)]; err != nil { return ghapi.IssueComment{}, err } + if err := f.postBodyErrs[body]; err != nil { + return ghapi.IssueComment{}, err + } f.commentID++ f.posted = append(f.posted, repo+"#"+strconv.Itoa(pr)+":"+body) now := f.clock() From 8c8d94c4b1345a2542934e0482ed4344e0de1f3b Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 22:55:30 +0200 Subject: [PATCH 08/31] Run co-reviewers alone when CodeRabbit's plan is summary-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit Free reviews public repos in full but only summarizes private ones: it posts its walkthrough tagged "Summarized by CodeRabbit Free" and never submits a review, however often it is asked. Waiting for one wedged the round until the in-flight timeout re-fired it, forever. dialect gains IsSummaryOnlyPlan and BotEvent.SummaryOnly, which rides alongside the dominant Kind because the declaration is an account fact, not a round fact. DecideFire resolves such a round through coAwareDedupe before the slot/quota/pacing gates — none should be spent on a review that cannot happen — and every enabled co-reviewer gates it, since they are its only reviewers. Completion counts the walkthrough as CodeRabbit's delivery once those co-reviewers are satisfied, with no fire required. Self-healing: upgrade the plan and crq resumes firing next observation. --- README.md | 10 ++ internal/dialect/coderabbit.go | 17 +++ internal/dialect/event.go | 15 ++- internal/dialect/golden_test.go | 13 +++ .../coderabbit/summary-only-free-plan.md | 40 +++++++ internal/engine/completion.go | 21 +++- internal/engine/coreview_test.go | 107 ++++++++++++++++++ internal/engine/engine.go | 19 ++++ internal/engine/fire.go | 32 +++++- llms.txt | 8 ++ 10 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 internal/dialect/testdata/coderabbit/summary-only-free-plan.md diff --git a/README.md b/README.md index 71ffe24..d181ad9 100644 --- a/README.md +++ b/README.md @@ -459,6 +459,16 @@ its review too. A Codex usage-limit notice releases that dynamic gate so an exha stall rounds it volunteered for; an explicitly required Codex is still bounded only by the normal feedback deadline. +**Summary-only plans (CodeRabbit Free on private repos):** a Free plan reviews public repos in full +but only *summarizes* private ones — CodeRabbit posts its walkthrough, tags it `🎁 Summarized by +CodeRabbit Free`, and never submits a review, however often it is asked. crq reads that notice and +runs the co-reviewers alone on those PRs: it posts no `@coderabbitai review`, takes no fire slot, +spends no account quota or pacing interval, and converges the round on Codex's review instead of +waiting out a review that cannot arrive. Every enabled co-reviewer gates such a round — they are its +only reviewers — while a configured-but-absent bot still can't wedge it. There is nothing to +configure and nothing to reset: upgrade to Pro and crq starts firing CodeRabbit again on the next +observation. + **Multiple orgs:** CodeRabbit's quota is per-org, so PRs in different orgs draw from *different* buckets. Run a separate gate (its own `CRQ_REPO`) per org rather than mixing them — otherwise you'd serialize reviews that don't actually compete. diff --git a/internal/dialect/coderabbit.go b/internal/dialect/coderabbit.go index 6405421..2a4b884 100644 --- a/internal/dialect/coderabbit.go +++ b/internal/dialect/coderabbit.go @@ -102,6 +102,23 @@ func (d CodeRabbit) IsReviewAlreadyDone(body string) bool { return !d.IsRateLimited(l) } +// IsSummaryOnlyPlan reports whether a CodeRabbit comment declares that the +// account's plan produces a high-level summary and walkthrough ONLY, with no +// line-by-line review. CodeRabbit ships this notice on private repositories +// under the Free plan; public repositories get Pro-grade reviews for free and +// carry "**Plan**: Pro" instead, so the notice is exactly the "CodeRabbit will +// never submit a review of this PR" signal — no repo-visibility lookup needed. +// +// Both anchors are statements about THIS account ("Summarized by CodeRabbit +// Free", "on the Free plan"), never generic marketing copy: a false positive +// would stop crq firing CodeRabbit on a PR it could really review. +func (d CodeRabbit) IsSummaryOnlyPlan(body string) bool { + l := strings.ToLower(body) + return strings.Contains(l, "summarized by coderabbit free") || + (strings.Contains(l, "on the free plan") && + strings.Contains(l, "high-level summary and a walkthrough")) +} + // IsReviewInProgress reports whether body is CodeRabbit's editable top-summary // state for a review that has started but has not finished. CodeRabbit can post // a "Review finished" command reply before this summary leaves the processing diff --git a/internal/dialect/event.go b/internal/dialect/event.go index 8aad02a..21be0d8 100644 --- a/internal/dialect/event.go +++ b/internal/dialect/event.go @@ -53,10 +53,16 @@ type BotEvent struct { CommentID int64 CreatedAt time.Time UpdatedAt time.Time - AutoReply bool // body carries the auto-reply (calibration) marker - Window *time.Time // EvRateLimited: parsed "available in" deadline - Remaining *int // EvRateLimited: parsed remaining reviews - SHA string // EvCoClean: reviewed-commit sha, "" if absent + AutoReply bool // body carries the auto-reply (calibration) marker + // SummaryOnly reports that this configured-bot comment declares a + // summary-and-walkthrough-only plan (CodeRabbit Free on a private repo). + // It is a property of the ACCOUNT, not of any round, so it rides alongside + // the dominant Kind rather than competing with it — a rate-limit notice or + // an in-progress summary must still classify as itself. + SummaryOnly bool + Window *time.Time // EvRateLimited: parsed "available in" deadline + Remaining *int // EvRateLimited: parsed remaining reviews + SHA string // EvCoClean: reviewed-commit sha, "" if absent // For is the canonical co-reviewer login the event concerns: the commanded // bot for EvCoCommand, the authoring bot for a co-reviewer's own comments. // "" for primary-bot and human events. @@ -149,6 +155,7 @@ func (c Classifier) Classify(author, body string, id int64, createdAt, updatedAt return ev } ev.AutoReply = c.CodeRabbit.IsAutoReply(body) + ev.SummaryOnly = c.CodeRabbit.IsSummaryOnlyPlan(body) switch { case c.CodeRabbit.IsRateLimited(body): ev.Kind = EvRateLimited diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index 7f7c049..e6fc5bd 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -39,6 +39,7 @@ func TestGoldenClassification(t *testing.T) { completionReply bool autoReply bool noAction bool + summaryOnly bool codexClean bool codexUsageLimit bool nonActionable bool @@ -60,6 +61,10 @@ func TestGoldenClassification(t *testing.T) { {file: "coderabbit/review-failed.md", failed: true}, {file: "coderabbit/reviews-paused.md", paused: true}, {file: "coderabbit/no-actionable-comments.md", noAction: true}, + // CodeRabbit Free on a private repo: the walkthrough IS the whole + // output — no review object ever follows, so crq must run the + // co-reviewers alone rather than firing (and waiting on) CodeRabbit. + {file: "coderabbit/summary-only-free-plan.md", summaryOnly: true}, {file: "coderabbit/already-reviewed.md", alreadyDone: true, autoReply: true}, {file: "coderabbit/completion-reply.md", completionReply: true, autoReply: true}, // The standalone trailer is an ack; a real finding CARRYING the trailer @@ -92,6 +97,7 @@ func TestGoldenClassification(t *testing.T) { {"IsCompletionReply", goldenCR.IsCompletionReply(body), tc.completionReply}, {"IsAutoReply", goldenCR.IsAutoReply(body), tc.autoReply}, {"IsNoActionReviewCompletion", IsNoActionReviewCompletion(body), tc.noAction}, + {"IsSummaryOnlyPlan", goldenCR.IsSummaryOnlyPlan(body), tc.summaryOnly}, {"IsCodexNoActionReviewCompletion", IsCodexNoActionReviewCompletion(body), tc.codexClean}, {"IsCodexUsageLimit", IsCodexUsageLimit(body), tc.codexUsageLimit}, {"IsNonActionableText", IsNonActionableText(body), tc.nonActionable}, @@ -117,6 +123,13 @@ func TestGoldenClassification(t *testing.T) { t.Errorf("Classify kind = %v, want %v", got, tc.wantKind) } } + // SummaryOnly rides alongside the dominant kind, so it is asserted + // on the classified event rather than through wantKind. + if strings.HasPrefix(tc.file, "coderabbit/") { + if got := classifier.Classify("coderabbitai[bot]", body, 1, base, base).SummaryOnly; got != tc.summaryOnly { + t.Errorf("Classify SummaryOnly = %v, want %v", got, tc.summaryOnly) + } + } }) } } diff --git a/internal/dialect/testdata/coderabbit/summary-only-free-plan.md b/internal/dialect/testdata/coderabbit/summary-only-free-plan.md new file mode 100644 index 0000000..3f7a080 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/summary-only-free-plan.md @@ -0,0 +1,40 @@ + +
+ℹ️ Recent review info + +
+⚙️ Run configuration + +**Configuration used**: Organization UI + +**Review profile**: ASSERTIVE + +**Plan**: Free + +
+ +
+ + + +## Walkthrough + +(walkthrough prose, file table and sequence diagrams omitted) + + + + +--- + +> [!NOTE] +>
+> 🎁 Summarized by CodeRabbit Free +> +> Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting . +> +>
+ + +Comment `@coderabbitai help` to get the list of available commands. + + diff --git a/internal/engine/completion.go b/internal/engine/completion.go index f01489c..946d2fb 100644 --- a/internal/engine/completion.go +++ b/internal/engine/completion.go @@ -48,6 +48,7 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { if r.FiredAt != nil { cutoff = r.FiredAt.UTC() } + summaryOnly := SummaryOnlyPlan(obs, p) // Dynamic co-reviewer gate: a fired round that a co-bot participates in — // reviewing the PR on its own (AutoActive) or acting this round @@ -56,7 +57,13 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { // (Codex's usage-limit exhaustion) disengages this gate (the bot cannot // finish this round); configured-required bots are left to the wait // deadline as before. - if r.FiredAt != nil { + // + // A summary-only round need not have fired to engage this gate: CodeRabbit + // cannot review, so crq resolves the round without ever posting its command + // and FiredAt stays nil while the co-reviewers do all of the work. The gate + // still keys on observed participation, never on mere configuration — an + // enabled-but-absent co-bot must not wedge a round nothing else will finish. + if r.FiredAt != nil || summaryOnly { for _, cp := range p.coReviewers() { if requiredBot(p, cp.Login) { continue @@ -138,6 +145,18 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { } } + // 3b. Summary-only plan: the walkthrough IS CodeRabbit's entire output for + // this PR — no review object will ever follow, however often it is asked. + // Waiting for one wedges the round until the in-flight timeout re-fires it, + // forever. Count the bot as having delivered once every gating co-reviewer + // is satisfied, exactly as the clean-review summary does above; unlike that + // path it needs no fire, since the round can resolve without crq ever + // posting the review command. + if summaryOnly && needsBotReview(reviewedBy, p.Bot) && + coReviewersSatisfied(r, obs, p, cutoff, reviewedBy) { + markReviewed(reviewedBy, p.Bot) + } + // 4. Completion-reply fallback. if needsBotReview(reviewedBy, p.Bot) && r.FiredAt != nil && completionReplyForRound(obs, p, cutoff) { diff --git a/internal/engine/coreview_test.go b/internal/engine/coreview_test.go index 9e12a24..8f20144 100644 --- a/internal/engine/coreview_test.go +++ b/internal/engine/coreview_test.go @@ -246,3 +246,110 @@ func TestDecideFireMultiBotPostCo(t *testing.T) { t.Fatalf("verdict = %v, want FireDedupe for a wanted-only silent bot", d.Verdict) } } + +// TestSummaryOnlyPlanRunsCoReviewersAlone pins the CodeRabbit-Free-on-a-private-repo +// contract: the bot ships a walkthrough and NO review object, ever. crq must never +// spend a fire (or a slot, or account quota, or pacing) on that review, must fall +// back to the co-reviewers as the round's only reviewers, and must let the round +// converge on them instead of hanging on a review that cannot arrive. +func TestSummaryOnlyPlanRunsCoReviewersAlone(t *testing.T) { + now := time.Date(2026, 7, 24, 20, 0, 0, 0, time.UTC) + head := "abcdef123" + codex := dialect.CodexBotLogin + queued := state.Round{Repo: "o/r", PR: 1021, Head: head, Phase: state.PhaseQueued} + free := Global{SlotFree: true} + + // Codex enabled and triggerable, but NOT required and not auto-active: the + // case that only summary-only promotes into a gating reviewer. + p := Policy{ + Bot: "coderabbitai[bot]", + RequiredBots: []string{"coderabbitai[bot]"}, + MinInterval: 90 * time.Second, + CoReviewers: []CoReviewerPolicy{{Login: codex, Command: "@codex review", Trigger: TriggerAlways}}, + } + // The walkthrough carries the plan notice alongside its own kind. + notice := dialect.BotEvent{ + Kind: dialect.EvOther, Bot: "coderabbitai[bot]", CommentID: 7, + CreatedAt: now.Add(-time.Hour), UpdatedAt: now.Add(-time.Hour), SummaryOnly: true, + } + obs := func(events []dialect.BotEvent, reviews []ReviewSeen, co CoSeen) Observation { + return Observation{Head: head, Open: true, Events: events, Reviews: reviews, + Co: map[string]CoSeen{dialect.NormalizeBotName(codex): co}} + } + codexAtHead := []ReviewSeen{{Bot: codex, Commit: "abcdef1234567890", SubmittedAt: now.Add(-30 * time.Minute)}} + + t.Run("fires codex instead of coderabbit", func(t *testing.T) { + d := DecideFire(free, queued, obs([]dialect.BotEvent{notice}, nil, CoSeen{}), now, p) + if d.Verdict != FireCoOnly { + t.Fatalf("verdict = %v (%s), want FireCoOnly", d.Verdict, d.Reason) + } + if len(d.PostCo) != 1 || !dialect.IsCodexBot(d.PostCo[0]) { + t.Fatalf("PostCo = %v, want codex only", d.PostCo) + } + }) + + t.Run("without the notice the same round fires coderabbit", func(t *testing.T) { + d := DecideFire(free, queued, obs(nil, nil, CoSeen{}), now, p) + if d.Verdict != FirePost { + t.Fatalf("verdict = %v, want FirePost", d.Verdict) + } + }) + + // The resolution costs no CodeRabbit quota, so neither a busy slot nor an + // account block from another PR may delay or divert it. + t.Run("bypasses the slot and account-block gates", func(t *testing.T) { + blocked := now.Add(time.Hour) + for name, g := range map[string]Global{ + "slot busy": {SlotFree: false}, + "account blocked": {SlotFree: true, BlockedUntil: &blocked}, + "pacing": {SlotFree: true, LastFired: &now}, + } { + d := DecideFire(g, queued, obs([]dialect.BotEvent{notice}, nil, CoSeen{}), now, p) + if d.Verdict != FireCoOnly { + t.Errorf("%s: verdict = %v (%s), want FireCoOnly", name, d.Verdict, d.Reason) + } + } + }) + + t.Run("waits bounded for an auto-reviewing codex", func(t *testing.T) { + d := DecideFire(free, queued, obs([]dialect.BotEvent{notice}, nil, CoSeen{AutoActive: true}), now, p) + if d.Verdict != FireCoReviewWait { + t.Fatalf("verdict = %v (%s), want FireCoReviewWait", d.Verdict, d.Reason) + } + if len(d.PostCo) != 0 { + t.Fatalf("PostCo = %v, want no post for an auto-reviewing bot", d.PostCo) + } + }) + + t.Run("dedupes when no co-reviewer can be asked", func(t *testing.T) { + inert := p + inert.CoReviewers = []CoReviewerPolicy{{Login: codex, Trigger: TriggerNever}} + d := DecideFire(free, queued, obs([]dialect.BotEvent{notice}, nil, CoSeen{}), now, inert) + if d.Verdict != FireDedupe { + t.Fatalf("verdict = %v (%s), want FireDedupe", d.Verdict, d.Reason) + } + }) + + // Completion: the round may resolve without ever firing, so the never-fired + // round is the one that has to converge. + t.Run("unfired round converges on the codex review", func(t *testing.T) { + st := Completion(queued, obs([]dialect.BotEvent{notice}, codexAtHead, CoSeen{AutoActive: true}), p) + if !st.Done { + t.Fatalf("Done = false, want true (reviewedBy = %v)", st.ReviewedBy) + } + }) + + t.Run("does not converge while codex is still silent", func(t *testing.T) { + st := Completion(queued, obs([]dialect.BotEvent{notice}, nil, CoSeen{AutoActive: true}), p) + if st.Done { + t.Fatalf("Done = true, want false while codex has not answered (reviewedBy = %v)", st.ReviewedBy) + } + }) + + t.Run("without the notice coderabbit still gates", func(t *testing.T) { + st := Completion(queued, obs(nil, codexAtHead, CoSeen{AutoActive: true}), p) + if st.Done { + t.Fatalf("Done = true, want false: coderabbit has not reviewed (reviewedBy = %v)", st.ReviewedBy) + } + }) +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index ff77cf2..9d89bb4 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -172,6 +172,25 @@ func (o Observation) co(login string) CoSeen { return CoSeen{} } +// SummaryOnlyPlan reports whether the configured bot has declared a +// summary-and-walkthrough-only plan on this PR (CodeRabbit Free on a private +// repo — the dialect owns the wording). It means the bot will NEVER submit a +// review here however often it is asked, so the round has exactly one honest +// resolution: run the co-reviewers and let them decide it. +// +// The declaration is an account fact, not a round fact: it lives in the top +// summary CodeRabbit edits in place, so no cutoff applies and any occurrence +// counts. It also self-heals — upgrade the plan and the notice stops shipping, +// so crq resumes firing on the next observation with no state to reset. +func SummaryOnlyPlan(obs Observation, p Policy) bool { + for _, ev := range obs.Events { + if ev.SummaryOnly && sameBot(ev.Bot, p.Bot) { + return true + } + } + return false +} + // notBefore mirrors v2: GitHub timestamps are second-granular, so a bot // completion in the same second as the trigger must still count. func notBefore(t, baseline time.Time) bool { return !t.Before(baseline) } diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 1732c76..34a2121 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -133,6 +133,15 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic } return FireDecision{Verdict: FireNo, Reason: reason} } + // The configured bot's plan produces a walkthrough only — no review of this + // head is coming, ever. Resolve the round on the co-reviewers alone, before + // the slot / account-quota / pacing gates: none of them should delay (or be + // spent on) a review that cannot happen. coAwareDedupe posts the triggers + // crq may, waits bounded for a co-bot that answers on its own, and dedupes + // only when there is no co-reviewer to ask. + if SummaryOnlyPlan(obs, p) { + return coAwareDedupe(r, obs, p, now, true) + } reviewedHead := false for _, review := range obs.Reviews { if sameBot(review.Bot, p.Bot) && review.Commit != "" && strings.HasPrefix(review.Commit, obs.Head) { @@ -164,7 +173,7 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic // only co-reviewer triggers, a co-review wait posts nothing), so an account // block from another PR must not delay them. if reviewedHead { - return coAwareDedupe(r, obs, p, now) + return coAwareDedupe(r, obs, p, now, false) } if g.BlockedUntil != nil && g.BlockedUntil.After(now) { // Degrade instead of stalling: the block only gates CodeRabbit quota, @@ -270,7 +279,9 @@ func newestCommand(commands []CommandSeen) *CommandSeen { return newest } -// coAwareDedupe resolves what to do when CodeRabbit already reviewed the head. +// coAwareDedupe resolves what to do when CodeRabbit has delivered everything it +// is ever going to for this head — either it already reviewed the head, or +// (summaryOnly) its plan will never produce a review at all. // If no gating co-reviewer is still outstanding, the round is genuinely done // (FireDedupe). If a required-or-auto-active co-bot has no review of this head // yet, the round is not done: post the triggers crq may (FireCoOnly). When crq @@ -284,12 +295,16 @@ func newestCommand(commands []CommandSeen) *CommandSeen { // it as still pending rather than the round wedging in an un-timed fire loop. // Completion counts the existing CodeRabbit review, so a FireCoOnly round // waits on the co-reviewers alone. -func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time) FireDecision { +// +// Under summaryOnly every ENABLED co-reviewer gates, not just the required or +// auto-active ones: they are the round's only reviewers, so a co-bot crq would +// otherwise treat as optional is the difference between a review and none. +func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time, summaryOnly bool) FireDecision { var post []string wait := false for _, cp := range p.coReviewers() { co := obs.co(cp.Login) - gates := requiredBot(p, cp.Login) || co.AutoActive + gates := requiredBot(p, cp.Login) || co.AutoActive || summaryOnly if !gates || coReviewedHead(obs, cp.Login) { continue } @@ -301,11 +316,18 @@ func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time) Fire wait = true } } + delivered := "coderabbit reviewed head" + if summaryOnly { + delivered = "coderabbit plan is summary-only" + } if len(post) > 0 { - return FireDecision{Verdict: FireCoOnly, Reason: "coderabbit reviewed head; co-review still required", PostCo: post, PostCodex: hasCodexLogin(post)} + return FireDecision{Verdict: FireCoOnly, Reason: delivered + "; co-review still required", PostCo: post, PostCodex: hasCodexLogin(post)} } if wait { return FireDecision{Verdict: FireCoReviewWait, Reason: "awaiting co-review"} } + if summaryOnly { + return FireDecision{Verdict: FireDedupe, Reason: delivered + "; no co-review outstanding"} + } return FireDecision{Verdict: FireDedupe, Reason: "bot already reviewed head"} } diff --git a/llms.txt b/llms.txt index e684910..0cd0078 100644 --- a/llms.txt +++ b/llms.txt @@ -94,6 +94,14 @@ in: NN minutes") and Codex demonstrably reviews the PR, the loop degrades to Cod instead of waiting the window out, and the pump posts `CRQ_CODEX_CMD` for blocked rounds while keeping the CodeRabbit review queued. Disable with `CRQ_RL_CODEX_DEGRADE=0` (default on). +Summary-only plans: on a private repo under a Free CodeRabbit plan the bot posts a walkthrough and +never a line-by-line review ("Summarized by CodeRabbit Free … upgrade … for a comprehensive +line-by-line review"). crq reads that notice and runs the co-reviewers alone there — no +`@coderabbitai review` is posted, no fire slot or account quota is spent, and the round converges on +Codex's review instead of hanging on one that cannot arrive. Public repos get Pro-grade reviews for +free and are unaffected. Nothing to configure: upgrade the plan and crq resumes firing CodeRabbit on +the next observation. + Resolve addressed threads on GitHub (crq keys off GitHub resolution state; a finding keeps reappearing until its thread is resolved there): From 444cd08aba9316a89b95c1a7a08a75b4a8c64d75 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 23:02:43 +0200 Subject: [PATCH 09/31] Delete the Codex compatibility shims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 8 of the co-reviewer abstraction (Ref #34). Codex is now just one registry entry, so the migration scaffolding goes: the EvCodex* kind aliases, Classifier.CodexCommand, Policy.CodexCommand and its implicit "Codex, trigger always iff required" synthesis, the FireCodexOnly / FireCodexDeferred verdict aliases, FireDecision.PostCodex, the legacy Observation.Codex{Commands,AutoActive,ActiveThisRound} fallback, and Config.CodexCommand. RateLimitCodexDegrade becomes RateLimitCoDegrade (CRQ_RL_CODEX_DEGRADE still parses as the alias). engine/codex.go keeps only what is genuinely Codex-specific: the thumbs-up quirk and the rate-limit degrade. Two shims were load-bearing in tests and are now explicit instead: hand-built EvCoCommand events must carry For (the classifier always sets it), and a co-reviewer must be ENABLED in Policy.CoReviewers to gate a round — configuration no longer implies Codex. Round.Codex{CommandID,CommandedAt,ClaimedAt} deliberately stay: the fleet shares one state ref across binary versions, so Codex bookkeeping keeps its dual-write. --- internal/crq/codex_replay_test.go | 43 +++++--- internal/crq/config.go | 23 ++--- internal/crq/config_test.go | 16 ++- internal/crq/coreview_replay_test.go | 1 - internal/crq/feedback.go | 2 +- internal/crq/feedback_test.go | 3 + internal/crq/observe.go | 26 +---- internal/crq/service.go | 7 +- internal/crq/service_test.go | 45 ++++++--- internal/crq/state.go | 17 ++-- internal/dialect/event.go | 35 +------ internal/dialect/golden_test.go | 12 +-- internal/engine/codex.go | 73 ++------------ internal/engine/coreview.go | 17 ++-- internal/engine/coreview_test.go | 17 ++-- internal/engine/engine.go | 38 ++----- internal/engine/engine_test.go | 144 ++++++++++++++++++--------- internal/engine/fire.go | 52 ++-------- 18 files changed, 243 insertions(+), 328 deletions(-) diff --git a/internal/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go index a413ca3..9581892 100644 --- a/internal/crq/codex_replay_test.go +++ b/internal/crq/codex_replay_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) @@ -18,6 +19,9 @@ import ( // codexLogin is the canonical Codex app login, owned by internal/dialect. const codexLogin = dialect.CodexBotLogin +// codexReviewCommand is the trigger these replays configure for Codex. +const codexReviewCommand = "@codex review" + // codexClean renders the tada clean-summary corpus with a chosen reviewed SHA, // so the replay shares one source of truth with the classifier golden corpus. A // rewording that breaks classification breaks this too. @@ -39,10 +43,20 @@ func newCodexReplayFixture(t *testing.T, base time.Time, mutate func(*Config)) * t.Helper() clk := newReplayClock(base) cfg := replayConfig() - cfg.CodexCommand = "@codex review" if mutate != nil { mutate(&cfg) } + // Codex keeps its historical default: crq posts the trigger at fire time + // exactly when Codex is configured-required (parseCoBots' codex rule). + trigger := engine.TriggerNever + if dialect.HasCodexBot(cfg.RequiredBots) { + trigger = engine.TriggerAlways + } + cfg.CoBots = []CoBotConfig{{ + Login: codexLogin, Name: "codex", Command: codexReviewCommand, + Trigger: trigger, Required: dialect.HasCodexBot(cfg.RequiredBots), + SelfHealGrace: 10 * time.Minute, + }} gh := newFakeGitHub() gh.now = clk.now store := NewMemoryStore(cfg) @@ -76,7 +90,7 @@ func (f *replayFixture) codexReview(repo string, pr int, id int64, commitSHA str func (f *replayFixture) codexPosted(repo string, pr int) int { f.gh.mu.Lock() defer f.gh.mu.Unlock() - want := QueueKey(repo, pr) + ":" + f.cfg.CodexCommand + want := QueueKey(repo, pr) + ":" + codexReviewCommand n := 0 for _, p := range f.gh.posted { if p == want { @@ -309,7 +323,7 @@ func TestCodexReplayCoReviewWaitBoundsSilentCodex(t *testing.T) { // CodeRabbit already reviewed this head (clean), and a `@codex review` command // is already on the PR awaiting Codex's answer. f.botReview(repo, pr, 500, head, base.Add(-time.Minute)) - f.humanComment(repo, pr, 600, f.cfg.CodexCommand, base.Add(-30*time.Second)) + f.humanComment(repo, pr, 600, codexReviewCommand, base.Add(-30*time.Second)) f.enqueue(repo, pr) if res := f.pump(); res.Action != "waiting" { @@ -389,10 +403,10 @@ func TestObserveScopesShellFilterToCodeRabbit(t *testing.T) { } } -// TestFireCodexOnlyPostFailureParks pins fix #3: when the Codex-only post fails, +// TestFireCoOnlyPostFailureParks pins fix #3: when the Codex-only post fails, // the round parks in awaiting_retry with a cooldown instead of re-posting on the // very next pump. -func TestFireCodexOnlyPostFailureParks(t *testing.T) { +func TestFireCoOnlyPostFailureParks(t *testing.T) { base := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) f := newCodexReplayFixture(t, base, func(cfg *Config) { cfg.RequiredBots = []string{cfg.Bot, codexLogin} @@ -400,7 +414,7 @@ func TestFireCodexOnlyPostFailureParks(t *testing.T) { repo, pr, head := "o/r", 12, "aaaabbbbccccdddd" f.openPull(repo, pr, head) f.setCommitDate(head, base.Add(-time.Hour)) - // CodeRabbit already reviewed the head → DecideFire returns FireCodexOnly. + // CodeRabbit already reviewed the head → DecideFire returns FireCoOnly. f.botReview(repo, pr, 500, head, base.Add(-time.Minute)) // The Codex command post fails. f.gh.mu.Lock() @@ -485,12 +499,15 @@ func TestSelfHealCodexClaimPreventsDoublePost(t *testing.T) { if err != nil { t.Fatal(err) } - // Erase the live command from the observation so DecideCodexPost wants to - // post (models the failed-initial-post world the finding describes). - obs.eng.CodexCommands = nil + // Erase the live command from the observation so the trigger decision wants + // to post (models the failed-initial-post world the finding describes). + if co, ok := obs.eng.Co[dialect.NormalizeBotName(codexLogin)]; ok { + co.Commands = nil + obs.eng.Co[dialect.NormalizeBotName(codexLogin)] = co + } events := obs.eng.Events[:0] for _, ev := range obs.eng.Events { - if ev.Kind != dialect.EvCodexCommand { + if ev.Kind != dialect.EvCoCommand { events = append(events, ev) } } @@ -558,7 +575,7 @@ func TestCoReviewWaitCountsPrePumpLegacySummary(t *testing.T) { // CodeRabbit reviewed the head; a human posted @codex review 5 minutes ago; // Codex answered with the LEGACY (SHA-less) clean summary 2 minutes ago. f.botReview(repo, pr, 500, head, base.Add(-10*time.Minute)) - f.humanComment(repo, pr, 600, f.cfg.CodexCommand, base.Add(-5*time.Minute)) + f.humanComment(repo, pr, 600, codexReviewCommand, base.Add(-5*time.Minute)) f.codexComment(repo, pr, 601, corpusMessage(t, "codex/clean-summary-legacy.md"), base.Add(-2*time.Minute)) f.enqueue(repo, pr) @@ -647,7 +664,7 @@ func TestCodexReplayIgnoresAnsweredCommandForNewHead(t *testing.T) { // so that command clears the cutoff and only the answered-review guard rejects it. f.setCommitDate(head, base.Add(-2*time.Hour)) // Previous head's history: a `@codex review` command Codex already answered. - f.humanComment(repo, pr, 600, f.cfg.CodexCommand, base.Add(-time.Hour)) + f.humanComment(repo, pr, 600, codexReviewCommand, base.Add(-time.Hour)) f.codexReview(repo, pr, 400, oldHead, base.Add(-30*time.Minute)) f.enqueue(repo, pr) @@ -681,7 +698,7 @@ func TestCodexReplayAdoptRecordsExistingCodexCommand(t *testing.T) { // The Codex command is posted BEFORE the CodeRabbit command crq will adopt, so a // self-heal scan anchored on the fire time would miss it and repost. const codexCmdID = 601 - f.humanComment(repo, pr, codexCmdID, f.cfg.CodexCommand, base.Add(-2*time.Minute)) + f.humanComment(repo, pr, codexCmdID, codexReviewCommand, base.Add(-2*time.Minute)) f.humanComment(repo, pr, 602, f.cfg.ReviewCommand, base.Add(-time.Minute)) f.enqueue(repo, pr) diff --git a/internal/crq/config.go b/internal/crq/config.go index dc29bd7..806c80b 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -42,13 +42,7 @@ type Config struct { // CoBots are the enabled co-reviewer bots (CRQ_COBOTS + per-bot // CRQ_COBOT__* keys). An entry exists for every wanted or required // co-reviewer; required ones are already folded into RequiredBots. - CoBots []CoBotConfig - // CodexCommand is the Codex review trigger crq posts when Codex gates a round - // and does not auto-review. Empty disables all Codex firing. - // - // Deprecated: derived from the codex CoBots entry (CRQ_COBOT_CODEX_CMD, - // alias CRQ_CODEX_CMD) for callers that predate CoBots. - CodexCommand string + CoBots []CoBotConfig RateLimitCommand string RateLimitMarker string CalibrationMarker string @@ -77,10 +71,10 @@ type Config struct { // CodeRabbit review following its comment shells) is caught by crq instead // of by a human re-checking the PR. 0 disables. SettleWindow time.Duration - // RateLimitCodexDegrade degrades an account-blocked round to Codex-only - // (return Codex findings promptly, keep CodeRabbit queued for the window) - // instead of waiting the block out. CRQ_RL_CODEX_DEGRADE, default on. - RateLimitCodexDegrade bool + // RateLimitCoDegrade degrades an account-blocked round to co-reviewers only + // (return their findings promptly, keep CodeRabbit queued for the window) + // instead of waiting the block out. CRQ_RL_CO_DEGRADE, default on. + RateLimitCoDegrade bool } func LoadConfig() (Config, error) { @@ -122,12 +116,8 @@ func LoadConfig() (Config, error) { // Enabled co-reviewers surface findings without gating: their logins join // the feedback set unless CRQ_FEEDBACK_BOTS overrides explicitly. coLogins := make([]string, 0, len(coBots)) - codexCommand := "" for _, cb := range coBots { coLogins = append(coLogins, cb.Login) - if cb.Name == "codex" { - codexCommand = cb.Command - } } cfg := Config{ GateRepo: env["CRQ_REPO"], @@ -144,7 +134,6 @@ func LoadConfig() (Config, error) { CoBots: coBots, FeedbackBots: listEnv(env, "CRQ_FEEDBACK_BOTS", strings.Join(unionBots(requiredBots, coLogins), ",")), ReviewCommand: stringEnv(env, "CRQ_REVIEW_CMD", "@coderabbitai review"), - CodexCommand: codexCommand, RateLimitCommand: stringEnv(env, "CRQ_RATELIMIT_CMD", dialect.DefaultRateLimitCommand), RateLimitMarker: stringEnv(env, "CRQ_RL_MARKER", dialect.DefaultRateLimitMarker), CalibrationMarker: stringEnv(env, "CRQ_CAL_REPLY_MARKER", "auto-generated reply by CodeRabbit"), @@ -167,7 +156,7 @@ func LoadConfig() (Config, error) { FeedbackWaitTimeout: durationEnv(env, "CRQ_FEEDBACK_WAIT_TIMEOUT", 20*time.Minute), SettleWindow: durationEnv(env, "CRQ_SETTLE", 90*time.Second), - RateLimitCodexDegrade: stringEnv(env, "CRQ_RL_CO_DEGRADE", stringEnv(env, "CRQ_RL_CODEX_DEGRADE", "1")) != "0", + RateLimitCoDegrade: stringEnv(env, "CRQ_RL_CO_DEGRADE", stringEnv(env, "CRQ_RL_CODEX_DEGRADE", "1")) != "0", } if len(cfg.Scope) == 0 && cfg.GateRepo != "" { cfg.Scope = []string{ownerOf(cfg.GateRepo)} diff --git a/internal/crq/config_test.go b/internal/crq/config_test.go index 6fffc4c..3952ee5 100644 --- a/internal/crq/config_test.go +++ b/internal/crq/config_test.go @@ -250,9 +250,7 @@ func TestLoadConfigCoBotsDefaults(t *testing.T) { if codex.Trigger != engine.TriggerNever || codex.Command != "@codex review" || codex.Required { t.Fatalf("codex defaults wrong: %+v", codex) } - if cfg.CodexCommand != "@codex review" { - t.Fatalf("CodexCommand mirror = %q", cfg.CodexCommand) - } + bugbot, _ := coBotByName(cfg, "bugbot") if bugbot.Trigger != engine.TriggerSelfHeal || bugbot.Command != "bugbot run" || bugbot.SelfHealGrace != 10*time.Minute { t.Fatalf("bugbot defaults wrong: %+v", bugbot) @@ -292,9 +290,7 @@ func TestLoadConfigCoBotsExplicitEmptyDisablesAll(t *testing.T) { if len(cfg.CoBots) != 0 { t.Fatalf("CRQ_COBOTS=\"\" must disable all co-bots, got %#v", cfg.CoBots) } - if cfg.CodexCommand != "" { - t.Fatalf("CodexCommand must be empty with codex disabled, got %q", cfg.CodexCommand) - } + if len(cfg.FeedbackBots) != 1 || cfg.FeedbackBots[0] != "coderabbitai[bot]" { t.Fatalf("FeedbackBots must collapse to the required set: %#v", cfg.FeedbackBots) } @@ -364,8 +360,8 @@ func TestLoadConfigCoBotCmdAliasesAndOverrides(t *testing.T) { t.Fatal(err) } codex, _ := coBotByName(cfg, "codex") - if codex.Command != "@codex ship it" || cfg.CodexCommand != "@codex ship it" { - t.Fatalf("CRQ_CODEX_CMD alias not honored: %+v / %q", codex, cfg.CodexCommand) + if codex.Command != "@codex ship it" { + t.Fatalf("CRQ_CODEX_CMD alias not honored: %+v", codex) } bugbot, _ := coBotByName(cfg, "bugbot") if bugbot.Trigger != engine.TriggerAlways { @@ -399,7 +395,7 @@ func TestLoadConfigRLCoDegradeAlias(t *testing.T) { if err != nil { t.Fatal(err) } - if cfg.RateLimitCodexDegrade { + if cfg.RateLimitCoDegrade { t.Fatal("CRQ_RL_CO_DEGRADE=0 must disable the degrade") } @@ -409,7 +405,7 @@ func TestLoadConfigRLCoDegradeAlias(t *testing.T) { if err != nil { t.Fatal(err) } - if cfg.RateLimitCodexDegrade { + if cfg.RateLimitCoDegrade { t.Fatal("legacy CRQ_RL_CODEX_DEGRADE=0 must still disable the degrade") } } diff --git a/internal/crq/coreview_replay_test.go b/internal/crq/coreview_replay_test.go index 00a24ef..ca2ada6 100644 --- a/internal/crq/coreview_replay_test.go +++ b/internal/crq/coreview_replay_test.go @@ -36,7 +36,6 @@ func newCoReplayFixture(t *testing.T, base time.Time, mutate func(*Config)) *rep t.Helper() clk := newReplayClock(base) cfg := replayConfig() - cfg.CodexCommand = "@codex review" cfg.CoBots = defaultCoBots() cfg.FeedbackBots = unionBots(cfg.RequiredBots, []string{dialect.CodexBotLogin, bugbotLogin, macroLogin}) if mutate != nil { diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index dcbb0bf..3cfa80a 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -282,7 +282,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe // quota block qualifies — a round's own awaiting_retry cooldown also // covers non-quota retries (post failures, timeouts) that must keep their // normal retry handling. - if s.cfg.RateLimitCodexDegrade && !report.Converged && st.Account.BlockedUntil != nil { + if s.cfg.RateLimitCoDegrade && !report.Converged && st.Account.BlockedUntil != nil { until := st.Account.BlockedUntil.UTC() if until.After(now) && engine.CodexOnlyEligible(completionRound, obs.eng, &until, now) { report.CodeRabbitDeferred = true diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index f5a0670..40e40d9 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -979,6 +979,7 @@ func TestFeedbackDoesNotUseNoActionCompletionWhileCodexRequiredWithoutThumbsUp(t Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"}, } + cfg.CoBots = codexCoBots(cfg.RequiredBots) // Codex enabled as a co-reviewer gh := newFakeGitHub() var pull ghapi.Pull pull.State = "open" @@ -1093,6 +1094,7 @@ func TestFeedbackMarksRequiredCodexCleanReviewSummaryReviewed(t *testing.T) { Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"}, } + cfg.CoBots = codexCoBots(cfg.RequiredBots) // Codex enabled as a co-reviewer gh := newFakeGitHub() var pull ghapi.Pull pull.State = "open" @@ -1496,6 +1498,7 @@ func TestLoopResumesAwaitingFeedbackWithoutRefiring(t *testing.T) { FeedbackWaitTimeout: time.Minute, FiredMax: 500, } + cfg.CoBots = codexCoBots(cfg.RequiredBots) // Codex enabled as a co-reviewer gh := newFakeGitHub() var pull ghapi.Pull pull.State = "open" diff --git a/internal/crq/observe.go b/internal/crq/observe.go index 2bc4e90..dff57b3 100644 --- a/internal/crq/observe.go +++ b/internal/crq/observe.go @@ -79,7 +79,6 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round CodeRabbit: s.cr, Bot: s.cfg.Bot, ReviewCommand: s.cfg.ReviewCommand, - CodexCommand: s.cfg.CodexCommand, CoReviewers: s.classifierCoReviewers(), } for _, c := range comments { @@ -143,11 +142,6 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round // Co-reviewer activity is derived from the same snapshot: whether each bot // reviews the PR unprompted (drives the fire decision) and whether it // participates in the current round (drives the dynamic completion gate). - // The legacy Codex fields stay populated for not-yet-migrated readers. - o.eng.CodexAutoActive = engine.CodexAutoActive(o.eng) - if round != nil { - o.eng.CodexActiveThisRound = engine.CodexActiveThisRound(*round, o.eng) - } if len(s.cfg.CoBots) > 0 { o.eng.Co = map[string]engine.CoSeen{} for _, cb := range s.cfg.CoBots { @@ -171,7 +165,6 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round return observation{}, err } o.eng.Commands = cr - o.eng.CodexCommands = co[dialect.NormalizeBotName(dialect.CodexBotLogin)] for key, cmds := range co { if entry, ok := o.eng.Co[key]; ok { entry.Commands = cmds @@ -183,12 +176,8 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round } // classifierCoReviewers resolves the enabled registry entries with their -// config-resolved trigger commands. nil (no parsed CoBots) leaves the -// Classifier on its legacy Codex shim. +// config-resolved trigger commands. func (s *Service) classifierCoReviewers() []dialect.CoReviewer { - if len(s.cfg.CoBots) == 0 { - return nil - } out := make([]dialect.CoReviewer, 0, len(s.cfg.CoBots)) for _, cb := range s.cfg.CoBots { co, ok := dialect.CoReviewerByName(cb.Name) @@ -245,7 +234,7 @@ func (s *Service) codexRelevant(obs engine.Observation) bool { } } for _, ev := range obs.Events { - if ev.Kind == dialect.EvCodexClean || dialect.IsCodexBot(ev.Bot) { + if ev.Kind == dialect.EvCoClean || dialect.IsCodexBot(ev.Bot) { return true } } @@ -324,18 +313,9 @@ func (s *Service) reviewCommands(ctx context.Context, repo string, pr int, obs e // coCommandBodies maps each triggerable co-reviewer (normalized login) to the // comment bodies that count as its trigger: the config-resolved command plus -// the registry's alternate spellings (`bugbot run` / `cursor review`). With no -// parsed CoBots the legacy rule applies: Codex's exact command, and only when -// Codex gates the round — only a configured-required Codex is ever fired. +// the registry's alternate spellings (`bugbot run` / `cursor review`). func (s *Service) coCommandBodies() map[string][]string { out := map[string][]string{} - if len(s.cfg.CoBots) == 0 { - codexCommand := strings.TrimSpace(s.cfg.CodexCommand) - if codexCommand != "" && dialect.HasCodexBot(s.cfg.RequiredBots) { - out[dialect.NormalizeBotName(dialect.CodexBotLogin)] = []string{codexCommand} - } - return out - } for _, cb := range s.cfg.CoBots { var bodies []string add := func(body string) { diff --git a/internal/crq/service.go b/internal/crq/service.go index 70757e2..aa6cf7a 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -248,7 +248,7 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { } now = s.clock() // No early blocked/min-interval return here: DecideFire owns those gates and - // deliberately resolves the quota-free verdicts (dedupe, FireCodexOnly, + // deliberately resolves the quota-free verdicts (dedupe, FireCoOnly, // FireCoReviewWait) before them, so an account block from another PR does not // delay resolutions that spend no CodeRabbit quota. mapFireNo still reports // "blocked"/"min_interval" for real fires; observing while blocked costs @@ -272,7 +272,7 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { // scan a bounded number of following queued rounds for a postable defer // (each costs one observation; ETag caching keeps it cheap). accountBlocked := global.BlockedUntil != nil && global.BlockedUntil.After(now) - if s.cfg.RateLimitCodexDegrade && decision.Verdict == engine.FireNo && + if s.cfg.RateLimitCoDegrade && decision.Verdict == engine.FireNo && (accountBlocked || !global.SlotFree) { scanned := 0 policy := s.policy() @@ -802,9 +802,6 @@ func (s *Service) coCommandFor(login string) string { return cb.Command } } - if dialect.IsCodexBot(login) { - return s.cfg.CodexCommand - } return "" } diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 01f599f..0eb48d8 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -579,6 +579,21 @@ func TestRenewLeaderRespectsLiveLease(t *testing.T) { } } +// codexCoBots builds the single Codex co-reviewer entry these tests assume, +// with Codex's historical default trigger: post at fire time exactly when it +// is configured-required (parseCoBots' codex rule). +func codexCoBots(requiredBots []string) []CoBotConfig { + trigger := engine.TriggerNever + if dialect.HasCodexBot(requiredBots) { + trigger = engine.TriggerAlways + } + return []CoBotConfig{{ + Login: dialect.CodexBotLogin, Name: "codex", Command: "@codex review", + Trigger: trigger, Required: dialect.HasCodexBot(requiredBots), + SelfHealGrace: 10 * time.Minute, + }} +} + func firingConfig() Config { return Config{ GateRepo: "owner/gate", @@ -1933,8 +1948,9 @@ func TestRefreshQuotaPreservesBlockOnInconclusiveProbe(t *testing.T) { func TestLoopDegradesToCodexOnlyOnRateLimit(t *testing.T) { ctx := context.Background() cfg := firingConfig() - cfg.RateLimitCodexDegrade = true + cfg.RateLimitCoDegrade = true cfg.FeedbackBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"} + cfg.CoBots = codexCoBots(cfg.RequiredBots) // Codex enabled as a co-reviewer gh := newFakeGitHub() head := "a0646f010" pull := ghapi.Pull{State: "open"} @@ -1987,8 +2003,9 @@ func TestLoopDegradesToCodexOnlyOnRateLimit(t *testing.T) { func TestFeedbackDefersCleanAutoCodexWithDefaultRequiredBots(t *testing.T) { ctx := context.Background() cfg := firingConfig() - cfg.RateLimitCodexDegrade = true + cfg.RateLimitCoDegrade = true cfg.FeedbackBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"} + cfg.CoBots = codexCoBots(cfg.RequiredBots) // Codex enabled as a co-reviewer gh := newFakeGitHub() pull := ghapi.Pull{State: "open"} pull.Head.SHA = "abcdef1234567890" @@ -2028,10 +2045,10 @@ func TestFeedbackDefersCleanAutoCodexWithDefaultRequiredBots(t *testing.T) { func TestLoopDeferredCodexCleanExitsZeroNotConverged(t *testing.T) { ctx := context.Background() cfg := firingConfig() - cfg.RateLimitCodexDegrade = true + cfg.RateLimitCoDegrade = true cfg.RequiredBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"} cfg.FeedbackBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"} - cfg.CodexCommand = "@codex review" + cfg.CoBots = codexCoBots(cfg.RequiredBots) cfg.SettleWindow = 0 gh := newFakeGitHub() pull := ghapi.Pull{State: "open"} @@ -2077,9 +2094,9 @@ func TestLoopDeferredCodexCleanExitsZeroNotConverged(t *testing.T) { func TestPumpPostsCodexDeferredDuringBlockThenFiresCodeRabbit(t *testing.T) { ctx := context.Background() cfg := firingConfig() - cfg.RateLimitCodexDegrade = true + cfg.RateLimitCoDegrade = true cfg.RequiredBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"} - cfg.CodexCommand = "@codex review" + cfg.CoBots = codexCoBots(cfg.RequiredBots) gh := newFakeGitHub() pull := ghapi.Pull{State: "open"} pull.Head.SHA = "abcdef1234567890" @@ -2147,9 +2164,9 @@ func TestPumpPostsCodexDeferredDuringBlockThenFiresCodeRabbit(t *testing.T) { func TestPumpScansPastBlockedRoundWithCodexAlreadyRequested(t *testing.T) { ctx := context.Background() cfg := firingConfig() - cfg.RateLimitCodexDegrade = true + cfg.RateLimitCoDegrade = true cfg.RequiredBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"} - cfg.CodexCommand = "@codex review" + cfg.CoBots = codexCoBots(cfg.RequiredBots) gh := newFakeGitHub() for _, target := range []struct { repo string @@ -2202,9 +2219,9 @@ func TestPumpScansPastBlockedRoundWithCodexAlreadyRequested(t *testing.T) { func TestPumpAdoptsExistingCodexCommandDuringBlock(t *testing.T) { ctx := context.Background() cfg := firingConfig() - cfg.RateLimitCodexDegrade = true + cfg.RateLimitCoDegrade = true cfg.RequiredBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"} - cfg.CodexCommand = "@codex review" + cfg.CoBots = codexCoBots(cfg.RequiredBots) gh := newFakeGitHub() headTime := time.Now().UTC().Add(-2 * time.Minute) pull := ghapi.Pull{State: "open"} @@ -2214,7 +2231,7 @@ func TestPumpAdoptsExistingCodexCommandDuringBlock(t *testing.T) { commit.Committer.Date = headTime gh.commits[pull.Head.SHA] = commit commandAt := headTime.Add(time.Minute) - command := ghapi.IssueComment{ID: 702, Body: cfg.CodexCommand, CreatedAt: commandAt, UpdatedAt: commandAt} + command := ghapi.IssueComment{ID: 702, Body: cfg.CoBots[0].Command, CreatedAt: commandAt, UpdatedAt: commandAt} command.User.Login = "kristofferR" gh.comments[fakeKey("o/carrier", 94)] = []ghapi.IssueComment{command} gh.graphQL = noForcePush @@ -2252,12 +2269,12 @@ func TestPumpAdoptsExistingCodexCommandDuringBlock(t *testing.T) { } } -func TestFireCodexDeferredAdoptionHonorsDryRunAndActiveClaim(t *testing.T) { +func TestFireCoDeferredAdoptionHonorsDryRunAndActiveClaim(t *testing.T) { ctx := context.Background() cfg := firingConfig() - cfg.RateLimitCodexDegrade = true + cfg.RateLimitCoDegrade = true cfg.RequiredBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"} - cfg.CodexCommand = "@codex review" + cfg.CoBots = codexCoBots(cfg.RequiredBots) gh := newFakeGitHub() store := NewMemoryStore(cfg) now := time.Now().UTC() diff --git a/internal/crq/state.go b/internal/crq/state.go index 8856841..fd6843c 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -101,18 +101,15 @@ func issueBody(st State, cfg Config) (string, error) { return crqstate.IssueBody(st, cfg.storeConfig()) } -// policy assembles the engine Policy from config. With no parsed CoBots -// (hand-built Configs in tests), CoReviewers stays nil so the engine falls -// back to its legacy Codex synthesis from CodexCommand/RequiredBots. +// policy assembles the engine Policy from config. func (s *Service) policy() engine.Policy { p := engine.Policy{ - Bot: s.cfg.Bot, - RequiredBots: s.cfg.RequiredBots, - CodexCommand: s.cfg.CodexCommand, - MinInterval: s.cfg.MinInterval, - InflightTimeout: s.cfg.InflightTimeout, - RateLimitFallback: s.cfg.RateLimitFallback, - RateLimitCodexDegrade: s.cfg.RateLimitCodexDegrade, + Bot: s.cfg.Bot, + RequiredBots: s.cfg.RequiredBots, + MinInterval: s.cfg.MinInterval, + InflightTimeout: s.cfg.InflightTimeout, + RateLimitFallback: s.cfg.RateLimitFallback, + RateLimitCoDegrade: s.cfg.RateLimitCoDegrade, } for _, cb := range s.cfg.CoBots { p.CoReviewers = append(p.CoReviewers, engine.CoReviewerPolicy{ diff --git a/internal/dialect/event.go b/internal/dialect/event.go index 21be0d8..21511a3 100644 --- a/internal/dialect/event.go +++ b/internal/dialect/event.go @@ -35,15 +35,6 @@ const ( EvCoVerdict // Macroscope approvability verdict (informational only) ) -// Deprecated: Codex-named aliases for the generic co-reviewer kinds, kept -// while engine/crq migrate. In-memory only — never persisted. -const ( - EvCodexCommand = EvCoCommand - EvCodexClean = EvCoClean - EvCodexUsageLimit = EvCoUnable - EvCodexNotice = EvCoNotice -) - // BotEvent is one classified issue comment. CreatedAt orders command↔reply // pairing; UpdatedAt matters because CodeRabbit edits its top summary and its // rate-limit comment in place. @@ -93,30 +84,12 @@ func (e BotEvent) ObservedTime() time.Time { // Classifier classifies issue comments into BotEvents. Bot is the configured // CodeRabbit login; ReviewCommand is the exact trigger comment body; // CoReviewers are the enabled co-reviewer entries with their config-resolved -// trigger commands (a nil slice falls back to the CodexCommand shim). +// trigger commands (empty: no co-reviewer classification at all). type Classifier struct { CodeRabbit CodeRabbit Bot string ReviewCommand string CoReviewers []CoReviewer - // CodexCommand is the exact Codex trigger comment body ("" disables - // Codex-command matching). - // - // Deprecated: shim for callers that predate CoReviewers — when CoReviewers - // is nil, Classify synthesizes the Codex registry entry from this field. - CodexCommand string -} - -// coReviewers resolves the co-reviewer set: the explicit list when set, -// otherwise the legacy Codex-only shim (preserving that Codex comments -// classify even with command matching disabled). -func (c Classifier) coReviewers() []CoReviewer { - if c.CoReviewers != nil { - return c.CoReviewers - } - codex, _ := CoReviewerByName("codex") - codex.Command = strings.TrimSpace(c.CodexCommand) - return []CoReviewer{codex} } // Classify maps one issue comment to its BotEvent. Unrecognized comments @@ -130,15 +103,15 @@ func (c Classifier) Classify(author, body string, id int64, createdAt, updatedAt ev.Kind = EvCommand return ev } - coReviewers := c.coReviewers() - for _, co := range coReviewers { + + for _, co := range c.CoReviewers { if co.matchesCommand(trimmed) && !co.Is(author) { ev.Kind = EvCoCommand ev.For = co.Login return ev } } - for _, co := range coReviewers { + for _, co := range c.CoReviewers { if !co.Is(author) { continue } diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index e6fc5bd..9fe0c44 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -71,16 +71,16 @@ func TestGoldenClassification(t *testing.T) { // must stay actionable (a substring match dropped four real findings). {file: "coderabbit/thread-ack-also-applies.md", nonActionable: true}, {file: "coderabbit/finding-with-also-applies-trailer.md"}, - {file: "codex/clean-summary-legacy.md", codexClean: true, noAction: true, nonActionable: true, author: "chatgpt-codex-connector[bot]", wantKind: EvCodexClean}, - {file: "codex/clean-summary-tada.md", codexClean: true, noAction: true, nonActionable: true, reviewedSHA: "4d9e8bca82", author: "chatgpt-codex-connector[bot]", wantKind: EvCodexClean}, - {file: "codex/usage-limit.md", codexUsageLimit: true, nonActionable: true, author: "chatgpt-codex-connector[bot]", wantKind: EvCodexUsageLimit}, + {file: "codex/clean-summary-legacy.md", codexClean: true, noAction: true, nonActionable: true, author: "chatgpt-codex-connector[bot]", wantKind: EvCoClean}, + {file: "codex/clean-summary-tada.md", codexClean: true, noAction: true, nonActionable: true, reviewedSHA: "4d9e8bca82", author: "chatgpt-codex-connector[bot]", wantKind: EvCoClean}, + {file: "codex/usage-limit.md", codexUsageLimit: true, nonActionable: true, author: "chatgpt-codex-connector[bot]", wantKind: EvCoUnable}, // Codex's "create an environment" platform ad, posted as a thread reply — // never a finding, never a rebuttal. - {file: "codex/environment-notice.md", nonActionable: true, author: "chatgpt-codex-connector[bot]", wantKind: EvCodexNotice}, - {file: "codex/review-command.md", author: "kristofferR", wantKind: EvCodexCommand}, + {file: "codex/environment-notice.md", nonActionable: true, author: "chatgpt-codex-connector[bot]", wantKind: EvCoNotice}, + {file: "codex/review-command.md", author: "kristofferR", wantKind: EvCoCommand}, } base := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) - classifier := Classifier{CodeRabbit: goldenCR, Bot: "coderabbitai[bot]", ReviewCommand: "@coderabbitai review", CodexCommand: "@codex review"} + classifier := Classifier{CodeRabbit: goldenCR, Bot: "coderabbitai[bot]", ReviewCommand: "@coderabbitai review", CoReviewers: KnownCoReviewers()} for _, tc := range cases { t.Run(tc.file, func(t *testing.T) { body := readGolden(t, tc.file) diff --git a/internal/engine/codex.go b/internal/engine/codex.go index 5ab6ce3..37a4b7d 100644 --- a/internal/engine/codex.go +++ b/internal/engine/codex.go @@ -7,77 +7,26 @@ import ( "github.com/kristofferR/coderabbit-queue/internal/state" ) -// Codex-specific layer over the generic co-reviewer algebra in coreview.go: -// the thumbs-up quirk (a Codex +1 stands in for its review) lives here, and -// the pre-migration entry points wrap their generic counterparts keyed by the -// Codex login. The wrappers go away once crq iterates Policy.CoReviewers. +// The Codex-specific quirks layered over the generic co-reviewer algebra in +// coreview.go. Everything bot-shape-generic lives there and is keyed by login; +// only what is genuinely true of Codex alone belongs here. // codexBot is the Codex GitHub app login the engine flips in ReviewedBy when -// Codex gates a round. The dialect owns the literal and the normalization -// (CodexBotLogin/IsCodexBot/HasCodexBot); this consumes the canonical constant. +// its thumbs-up quirk satisfies a gate. The dialect owns the literal and the +// normalization (CodexBotLogin/IsCodexBot); this consumes the constant. const codexBot = dialect.CodexBotLogin -// roundCutoff is the round-window floor: the fire time (UTC), or zero when the -// round has not fired. -func roundCutoff(r state.Round) time.Time { - if r.FiredAt != nil { - return r.FiredAt.UTC() - } - return time.Time{} -} - -// codexPolicy synthesizes the Codex CoReviewerPolicy from the legacy Policy -// fields: trigger always iff Codex is configured-required (DecideCodexPost's -// historical "only when required" guard), never otherwise. -func codexPolicy(p Policy) CoReviewerPolicy { - cp := CoReviewerPolicy{Login: codexBot, Command: p.CodexCommand, Trigger: TriggerNever} - if dialect.HasCodexBot(p.RequiredBots) { - cp.Trigger = TriggerAlways - } - return cp -} - -func codexCutoff(r state.Round) time.Time { return coCutoff(r, codexBot) } - -func codexReviewedRound(r state.Round, obs Observation, cutoff time.Time) bool { - return coReviewedRound(r, obs, codexBot, cutoff) -} - -func codexCommentedRound(obs Observation, cutoff time.Time) bool { - return coCommentedRound(obs, codexBot, cutoff) -} - -// codexReviewedHead is the "Codex already reviewed this head" fire guard. -func codexReviewedHead(obs Observation) bool { return coReviewedHead(obs, codexBot) } - -// CodexActiveThisRound is CoActiveThisRound for Codex plus its thumbs-up -// quirk: a current +1 on the PR or the fired command counts as participation. +// CodexActiveThisRound is CoActiveThisRound plus Codex's thumbs-up quirk: a +// current +1 on the PR or on the fired command counts as participation. No +// other co-reviewer signals with a reaction. func CodexActiveThisRound(r state.Round, obs Observation) bool { return CoActiveThisRound(r, obs, codexBot) || obs.CodexThumbsUp } -// CodexAutoActive reports whether Codex reviews this PR on its own right now. -func CodexAutoActive(obs Observation) bool { return CoAutoActive(obs, codexBot) } - -// CodexCommandSince reports whether an `@codex review` command comment exists -// at/after since. -func CodexCommandSince(obs Observation, since time.Time) bool { - return CoCommandSince(obs, codexBot, since) -} - // CodexOnlyEligible reports whether an account-blocked round may degrade to a -// Codex-only round. +// Codex-only round. The rate-limit degrade is deliberately Codex-specific: it +// trades a blocked CodeRabbit round for Codex's review, and only Codex is +// modelled as a full stand-in reviewer (see Policy.RateLimitCoDegrade). func CodexOnlyEligible(r state.Round, obs Observation, blockedUntil *time.Time, now time.Time) bool { return CoOnlyEligible(r, obs, codexBot, blockedUntil, now) } - -func codexUsageLimitedSince(obs Observation, since time.Time) bool { - return coUnableSince(obs, codexBot, since) -} - -// DecideCodexPost reports whether crq should post its Codex review command -// while firing this round — DecideCoPost under the synthesized Codex policy -// (post at fire time, required-only, unless Codex auto-reviews). -func DecideCodexPost(r state.Round, obs Observation, p Policy, commandPresent bool) bool { - return DecideCoPost(r, obs, codexPolicy(p), commandPresent, time.Time{}, time.Time{}) -} diff --git a/internal/engine/coreview.go b/internal/engine/coreview.go index f46cc92..07cf33e 100644 --- a/internal/engine/coreview.go +++ b/internal/engine/coreview.go @@ -14,6 +14,15 @@ import ( // runs count as evidence/activity alongside reviews and clean summaries // because Bugbot's clean rounds exist ONLY as a check run. +// roundCutoff is the round-window floor: the fire time (UTC), or zero when the +// round has not fired. +func roundCutoff(r state.Round) time.Time { + if r.FiredAt != nil { + return r.FiredAt.UTC() + } + return time.Time{} +} + // roundCoCommandID reads the trigger comment recorded for login this round, // falling back to the legacy Codex fields for rounds built directly (tests) // rather than loaded through state.Normalize's fold. @@ -38,16 +47,12 @@ func roundCoCommandedAt(r state.Round, login string) *time.Time { } // eventConcerns reports whether a classified event concerns the co-reviewer -// login: by the classifier's For attribution when present, otherwise by -// author. A For-less co-command is attributed to Codex — the only bot whose -// commands existed before For did (migration shim for hand-built events). +// login: by the classifier's For attribution when present, otherwise by author +// (a bot's own comments carry it even when For was not set). func eventConcerns(ev dialect.BotEvent, login string) bool { if ev.For != "" { return sameBot(ev.For, login) } - if ev.Kind == dialect.EvCoCommand { - return dialect.IsCodexBot(login) - } return sameBot(ev.Bot, login) } diff --git a/internal/engine/coreview_test.go b/internal/engine/coreview_test.go index 8f20144..54ce466 100644 --- a/internal/engine/coreview_test.go +++ b/internal/engine/coreview_test.go @@ -188,22 +188,27 @@ func TestDecideFireMultiBotPostCo(t *testing.T) { head := "abcdef123" queued := state.Round{Repo: "o/r", PR: 1, Head: head, Phase: state.PhaseQueued} p := Policy{Bot: "coderabbitai[bot]", - RequiredBots: []string{"coderabbitai[bot]", dialect.CodexBotLogin, bugbotLogin}, - RateLimitCodexDegrade: true, + RequiredBots: []string{"coderabbitai[bot]", dialect.CodexBotLogin, bugbotLogin}, + RateLimitCoDegrade: true, CoReviewers: []CoReviewerPolicy{ {Login: dialect.CodexBotLogin, Command: "@codex review", Trigger: TriggerAlways}, {Login: bugbotLogin, Command: "bugbot run", Trigger: TriggerAlways}, }} free := Global{SlotFree: true} + hasLogin := func(logins []string, want string) bool { + for _, l := range logins { + if dialect.NormalizeBotName(l) == dialect.NormalizeBotName(want) { + return true + } + } + return false + } wantBoth := func(t *testing.T, d FireDecision) { t.Helper() - if len(d.PostCo) != 2 || !hasCodexLogin(d.PostCo) { + if len(d.PostCo) != 2 || !hasLogin(d.PostCo, dialect.CodexBotLogin) || !hasLogin(d.PostCo, bugbotLogin) { t.Fatalf("PostCo = %v, want codex+bugbot", d.PostCo) } - if !d.PostCodex { - t.Fatal("PostCodex mirror must be set while codex is in PostCo") - } } d := DecideFire(free, queued, Observation{Head: head, Open: true}, now, p) diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 9d89bb4..f081b70 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -43,11 +43,8 @@ func (cp CoReviewerPolicy) selfHealGrace() time.Duration { type Policy struct { Bot string // configured CodeRabbit login RequiredBots []string // bots that gate round completion - CodexCommand string // Codex review trigger crq posts ("" disables Codex firing) // CoReviewers are the enabled co-reviewer bots with their trigger stances. - // When nil, the legacy Codex fields above synthesize the one entry - // (Trigger always iff Codex is in RequiredBots) — see codexPolicy. CoReviewers []CoReviewerPolicy MinInterval time.Duration // global pacing between fires @@ -55,10 +52,10 @@ type Policy struct { RateLimitFallback time.Duration // block window when "available in" is unparseable RetryBackoff time.Duration // cooldown after a non-rate-limit retry (timeout, failure) - // RateLimitCodexDegrade lets an account-blocked round degrade to a - // Codex-only round (post the Codex command now, keep CodeRabbit queued - // for the window) instead of waiting the block out. CRQ_RL_CODEX_DEGRADE. - RateLimitCodexDegrade bool + // RateLimitCoDegrade lets an account-blocked round degrade to a + // co-reviewer-only round (post the triggers now, keep CodeRabbit queued + // for the window) instead of waiting the block out. CRQ_RL_CO_DEGRADE. + RateLimitCoDegrade bool } func (p Policy) rateLimitFallback() time.Duration { @@ -129,29 +126,16 @@ type Observation struct { Events []dialect.BotEvent // Commands are adoptable trigger comments (cutoff-filtered by observe). Commands []CommandSeen - // CodexCommands are adoptable Codex trigger comments present for the head - // (cutoff-filtered by observe like Commands). A non-empty list means a live - // `@codex review` already exists, so crq must not post a duplicate. - CodexCommands []CommandSeen // Reacted reports a configured-bot reaction on the round's fired command. Reacted bool // CodexThumbsUp reports a current Codex +1 on the PR or the fired command // (pre-fetched only when a Codex-gated completion needs it). CodexThumbsUp bool - // CodexAutoActive reports that Codex reviews this PR on its own: it has a - // review or clean summary that no `@codex review` command preceded. When - // true, crq never posts the Codex command — Codex will review unprompted. - CodexAutoActive bool - // CodexActiveThisRound reports Codex activity bound to the current round (a - // head review, a round-window comment/clean summary, or a thumbs-up). It - // drives the dynamic completion gate when Codex is not configured-required. - CodexActiveThisRound bool // Checks are the head's classified co-reviewer check runs. Checks []CheckSeen // Co carries each co-reviewer's per-bot observation slice, keyed by - // normalized login. The legacy Codex* fields above remain the fallback for - // callers that predate the map — see Observation.co. + // normalized login. Co map[string]CoSeen } @@ -159,17 +143,9 @@ type Observation struct { // (trigger adoption at fire time shares the engine's view of live commands). func (o Observation) CoSeenFor(login string) CoSeen { return o.co(login) } -// co returns login's observation slice, falling back to the legacy Codex -// fields when the map has no entry (engine tests and pre-migration callers -// still populate those directly). +// co returns login's observation slice (zero value when unobserved). func (o Observation) co(login string) CoSeen { - if c, ok := o.Co[dialect.NormalizeBotName(login)]; ok { - return c - } - if dialect.IsCodexBot(login) { - return CoSeen{Commands: o.CodexCommands, AutoActive: o.CodexAutoActive, ActiveThisRound: o.CodexActiveThisRound} - } - return CoSeen{} + return o.Co[dialect.NormalizeBotName(login)] } // SummaryOnlyPlan reports whether the configured bot has declared a diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 13f2835..00224e5 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -184,7 +184,7 @@ func TestInflightTimeoutCarriesCooldown(t *testing.T) { func TestReviewingRoundDeadlineBoundsCoReviewWait(t *testing.T) { codexReq := policy codexReq.RequiredBots = []string{"coderabbitai[bot]", dialect.CodexBotLogin} - codexReq.CodexCommand = "@codex review" + codexReq = withCodex(codexReq, "@codex review") reviewing := func() state.Round { r := firedRound(t, "abcdef123") @@ -371,14 +371,14 @@ func TestCommandHasCompletionReply(t *testing.T) { } } -// TestDecideCodexPost is the PostCodex decision matrix: crq posts its Codex +// TestDecideCodexPost is the Codex trigger-post decision matrix: crq posts its Codex // command only for a configured-required Codex that does not auto-review and has // not already been asked (evidence, an existing command, or a recorded id). func TestDecideCodexPost(t *testing.T) { codexReq := Policy{ Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]", dialect.CodexBotLogin}, - CodexCommand: "@codex review", + CoReviewers: []CoReviewerPolicy{{Login: dialect.CodexBotLogin, Command: "@codex review", Trigger: TriggerAlways}}, } head := "abcdef123" base := Observation{Head: head, Open: true} @@ -393,7 +393,7 @@ func TestDecideCodexPost(t *testing.T) { want bool }{ {name: "required, no auto, first fire", round: state.Round{Head: head}, obs: base, policy: codexReq, want: true}, - {name: "auto-active never posts", round: state.Round{Head: head}, obs: Observation{Head: head, Open: true, CodexAutoActive: true}, policy: codexReq, want: false}, + {name: "auto-active never posts", round: state.Round{Head: head}, obs: Observation{Head: head, Open: true, Co: codexSeen(CoSeen{AutoActive: true})}, policy: codexReq, want: false}, {name: "already reviewed head", round: state.Round{Head: head}, obs: Observation{Head: head, Open: true, Reviews: []ReviewSeen{codexReviewHead}}, policy: codexReq, want: false}, {name: "command already present", round: state.Round{Head: head}, obs: base, policy: codexReq, commandPresent: true, want: false}, {name: "not required", round: state.Round{Head: head}, obs: base, policy: policy, want: false}, @@ -402,7 +402,7 @@ func TestDecideCodexPost(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := DecideCodexPost(tc.round, tc.obs, tc.policy, tc.commandPresent); got != tc.want { + if got := DecideCoPost(tc.round, tc.obs, codexCP(tc.policy), tc.commandPresent, time.Time{}, time.Time{}); got != tc.want { t.Fatalf("DecideCodexPost = %v, want %v", got, tc.want) } }) @@ -417,10 +417,10 @@ func TestCodexAutoActive(t *testing.T) { return ReviewSeen{Bot: dialect.CodexBotLogin, Commit: "abcdef1234567890", SubmittedAt: at} } codexCommand := func(at time.Time) dialect.BotEvent { - return dialect.BotEvent{Kind: dialect.EvCodexCommand, Bot: "kristofferR", CommentID: 1, CreatedAt: at, UpdatedAt: at} + return dialect.BotEvent{Kind: dialect.EvCoCommand, Bot: "kristofferR", For: dialect.CodexBotLogin, CommentID: 1, CreatedAt: at, UpdatedAt: at} } codexClean := func(at time.Time) dialect.BotEvent { - return dialect.BotEvent{Kind: dialect.EvCodexClean, Bot: dialect.CodexBotLogin, SHA: "abcdef1234", CommentID: 2, CreatedAt: at, UpdatedAt: at} + return dialect.BotEvent{Kind: dialect.EvCoClean, Bot: dialect.CodexBotLogin, SHA: "abcdef1234", CommentID: 2, CreatedAt: at, UpdatedAt: at} } t1 := t0.Add(time.Hour) @@ -459,8 +459,8 @@ func TestCodexAutoActive(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if got := CodexAutoActive(tc.obs); got != tc.want { - t.Fatalf("CodexAutoActive = %v, want %v", got, tc.want) + if got := CoAutoActive(tc.obs, dialect.CodexBotLogin); got != tc.want { + t.Fatalf("CoAutoActive(codex) = %v, want %v", got, tc.want) } }) } @@ -476,7 +476,7 @@ func TestDecideFireCodexDedupe(t *testing.T) { queued := state.Round{Repo: "owner/repo", PR: 448, Head: head, Phase: state.PhaseQueued, Seq: 1} codexReq := policy codexReq.RequiredBots = []string{"coderabbitai[bot]", dialect.CodexBotLogin} - codexReq.CodexCommand = "@codex review" + codexReq = withCodex(codexReq, "@codex review") crReviewed := ReviewSeen{Bot: "coderabbitai", Commit: "abcdef1234567890", SubmittedAt: now} codexReviewed := ReviewSeen{Bot: dialect.CodexBotLogin, Commit: "abcdef1234567890", SubmittedAt: now} @@ -484,18 +484,18 @@ func TestDecideFireCodexDedupe(t *testing.T) { // CodeRabbit reviewed the head; Codex required with no evidence and crq may // post → command Codex alone. obs := Observation{Head: head, Open: true, Reviews: []ReviewSeen{crReviewed}} - if d := DecideFire(free, queued, obs, now, codexReq); d.Verdict != FireCodexOnly { + if d := DecideFire(free, queued, obs, now, codexReq); d.Verdict != FireCoOnly { t.Fatalf("coderabbit-reviewed head with a gating codex must command codex, got %+v", d) } // Same, but Codex auto-reviews: crq must not post; wait for its own review, // bounded (FireCoReviewWait) rather than left queued with no deadline. - autoObs := Observation{Head: head, Open: true, CodexAutoActive: true, Reviews: []ReviewSeen{crReviewed}} + autoObs := Observation{Head: head, Open: true, Co: codexSeen(CoSeen{AutoActive: true}), Reviews: []ReviewSeen{crReviewed}} if d := DecideFire(free, queued, autoObs, now, codexReq); d.Verdict != FireCoReviewWait { t.Fatalf("auto-active codex must wait (bounded), not dedupe, got %+v", d) } // A live `@codex review` command already on the PR: crq must not repost it; // wait for its answer, bounded. - cmdObs := Observation{Head: head, Open: true, Reviews: []ReviewSeen{crReviewed}, CodexCommands: []CommandSeen{{ID: 55, CreatedAt: now}}} + cmdObs := Observation{Head: head, Open: true, Reviews: []ReviewSeen{crReviewed}, Co: codexSeen(CoSeen{Commands: []CommandSeen{{ID: 55, CreatedAt: now}}})} if d := DecideFire(free, queued, cmdObs, now, codexReq); d.Verdict != FireCoReviewWait { t.Fatalf("an outstanding codex command must wait (bounded), got %+v", d) } @@ -512,7 +512,7 @@ func TestDecideFireCodexDedupe(t *testing.T) { // obtain a Codex review, so it must dedupe rather than wedge the round waiting // forever — the feedback gate surfaces Codex as still pending. noCmd := codexReq - noCmd.CodexCommand = "" + noCmd = withCodex(noCmd, "") if d := DecideFire(free, queued, obs, now, noCmd); d.Verdict != FireDedupe { t.Fatalf("a required-but-uncommandable codex must dedupe, not wedge, got %+v", d) } @@ -523,27 +523,30 @@ func TestDecideFireCodexDedupe(t *testing.T) { // disengages that dynamic gate, and a configured-required Codex is left gating // regardless of the usage limit. func TestDynamicCodexGate(t *testing.T) { + // Codex is enabled (wanted) but NOT required: the gate must come from + // observed participation, not configuration. + codexEnabled := withCodex(policy, "@codex review") r := firedRound(t, "abcdef123") cutoff := r.FiredAt.UTC() crReview := ReviewSeen{Bot: "coderabbitai[bot]", Commit: "abcdef1234567890", SubmittedAt: cutoff.Add(time.Minute)} codexReview := ReviewSeen{Bot: dialect.CodexBotLogin, Commit: "abcdef1234567890", SubmittedAt: cutoff.Add(time.Minute)} - usageLimit := dialect.BotEvent{Kind: dialect.EvCodexUsageLimit, Bot: dialect.CodexBotLogin, CommentID: 700, + usageLimit := dialect.BotEvent{Kind: dialect.EvCoUnable, Bot: dialect.CodexBotLogin, CommentID: 700, CreatedAt: cutoff.Add(30 * time.Second), UpdatedAt: cutoff.Add(30 * time.Second)} // Codex auto-reviews the PR but hasn't reviewed the head yet: the dynamic gate // holds even though only CodeRabbit is configured-required. - held := Observation{Head: "abcdef123", Open: true, CodexAutoActive: true, Reviews: []ReviewSeen{crReview}} - if got := Completion(r, held, policy); got.Done { + held := Observation{Head: "abcdef123", Open: true, Co: codexSeen(CoSeen{AutoActive: true}), Reviews: []ReviewSeen{crReview}} + if got := Completion(r, held, codexEnabled); got.Done { t.Fatalf("an active Codex must gate the round until it reviews the head: %+v", got) } // Once Codex reviews the head, it converges. held.Reviews = append(held.Reviews, codexReview) - if got := Completion(r, held, policy); !got.Done { + if got := Completion(r, held, codexEnabled); !got.Done { t.Fatalf("the dynamic gate must converge once Codex reviews the head: %+v", got) } // A usage-limit notice disengages the DYNAMIC gate: CodeRabbit alone converges. - limited := Observation{Head: "abcdef123", Open: true, CodexAutoActive: true, Reviews: []ReviewSeen{crReview}, Events: []dialect.BotEvent{usageLimit}} - if got := Completion(r, limited, policy); !got.Done { + limited := Observation{Head: "abcdef123", Open: true, Co: codexSeen(CoSeen{AutoActive: true}), Reviews: []ReviewSeen{crReview}, Events: []dialect.BotEvent{usageLimit}} + if got := Completion(r, limited, codexEnabled); !got.Done { t.Fatalf("a Codex usage limit must disengage the dynamic gate: %+v", got) } // The configured-required gate is unchanged by a usage limit: it still waits. @@ -556,12 +559,15 @@ func TestDynamicCodexGate(t *testing.T) { // TestCodexGatesCleanSummary ports the codexInactiveOrThumbed rules. func TestCodexGatesCleanSummary(t *testing.T) { + // Codex is enabled (wanted) but NOT required: the gate must come from + // observed participation, not configuration. + codexEnabled := withCodex(policy, "@codex review") r := firedRound(t, "abcdef123") noAction := dialect.BotEvent{Kind: dialect.EvNoAction, Bot: "coderabbitai[bot]", CommentID: 2000, CreatedAt: t0.Add(30 * time.Second), UpdatedAt: t0.Add(30 * time.Second)} // Codex inactive: the clean summary converges alone. - if got := Completion(r, Observation{Head: "abcdef123", Open: true, Events: []dialect.BotEvent{noAction}}, policy); !got.Done { + if got := Completion(r, Observation{Head: "abcdef123", Open: true, Events: []dialect.BotEvent{noAction}}, codexEnabled); !got.Done { t.Fatalf("codex-inactive clean summary must converge: %+v", got) } @@ -570,13 +576,13 @@ func TestCodexGatesCleanSummary(t *testing.T) { codexComment := dialect.BotEvent{Kind: dialect.EvOther, Bot: dialect.CodexBotLogin, CommentID: 2001, CreatedAt: t0.Add(20 * time.Second), UpdatedAt: t0.Add(20 * time.Second)} obs := Observation{Head: "abcdef123", Open: true, Events: []dialect.BotEvent{noAction, codexComment}} - if got := Completion(r, obs, policy); got.Done { + if got := Completion(r, obs, codexEnabled); got.Done { t.Fatalf("active codex without review must block: %+v", got) } // A thumbs-up unblocks it. obs.CodexThumbsUp = true - if got := Completion(r, obs, policy); !got.Done { + if got := Completion(r, obs, codexEnabled); !got.Done { t.Fatalf("codex thumbs-up must unblock: %+v", got) } @@ -584,7 +590,7 @@ func TestCodexGatesCleanSummary(t *testing.T) { // Codex gates the round, flips its ReviewedBy too. gated := policy gated.RequiredBots = []string{"coderabbitai[bot]", dialect.CodexBotLogin} - codexClean := dialect.BotEvent{Kind: dialect.EvCodexClean, Bot: dialect.CodexBotLogin, SHA: "abcdef1234", + codexClean := dialect.BotEvent{Kind: dialect.EvCoClean, Bot: dialect.CodexBotLogin, SHA: "abcdef1234", CommentID: 2002, CreatedAt: t0.Add(40 * time.Second), UpdatedAt: t0.Add(40 * time.Second)} got := Completion(r, Observation{Head: "abcdef123", Open: true, Events: []dialect.BotEvent{noAction, codexClean}}, gated) if !got.Done { @@ -599,7 +605,7 @@ func TestCodexGatesCleanSummary(t *testing.T) { func TestCodexResolutionBypassesAccountBlock(t *testing.T) { gated := policy gated.RequiredBots = []string{policy.Bot, dialect.CodexBotLogin} - gated.CodexCommand = "@codex review" + gated = withCodex(gated, "@codex review") now := t0.Add(10 * time.Minute) blocked := now.Add(30 * time.Minute) last := now.Add(-time.Second) @@ -609,7 +615,7 @@ func TestCodexResolutionBypassesAccountBlock(t *testing.T) { obs := Observation{Head: "abcdef123", Open: true, Reviews: []ReviewSeen{{Bot: policy.Bot, ReviewID: 1, Commit: "abcdef1234567890", SubmittedAt: now}}} - if d := DecideFire(g, queued, obs, now, gated); d.Verdict != FireCodexOnly { + if d := DecideFire(g, queued, obs, now, gated); d.Verdict != FireCoOnly { t.Fatalf("blocked account must not delay a codex-only fire, got %+v", d) } // Codex satisfied → plain dedupe, also unblocked. @@ -645,10 +651,10 @@ func TestCodexOnlyEligible(t *testing.T) { now := t0.Add(5 * time.Minute) blocked := now.Add(25 * time.Minute) expired := now.Add(-time.Minute) - usageLimit := dialect.BotEvent{Kind: dialect.EvCodexUsageLimit, Bot: dialect.CodexBotLogin, CommentID: 700, + usageLimit := dialect.BotEvent{Kind: dialect.EvCoUnable, Bot: dialect.CodexBotLogin, CommentID: 700, CreatedAt: r.FiredAt.Add(30 * time.Second), UpdatedAt: r.FiredAt.Add(30 * time.Second)} - active := Observation{Head: "abcdef123", Open: true, CodexActiveThisRound: true} + active := Observation{Head: "abcdef123", Open: true, Co: codexSeen(CoSeen{ActiveThisRound: true})} if CodexOnlyEligible(r, active, nil, now) { t.Fatal("no block must not degrade") } @@ -660,15 +666,15 @@ func TestCodexOnlyEligible(t *testing.T) { } // Auto-activity alone predicts evidence; it does not qualify until Codex // actually responds to this head/round. - auto := Observation{Head: "abcdef123", Open: true, CodexAutoActive: true} + auto := Observation{Head: "abcdef123", Open: true, Co: codexSeen(CoSeen{AutoActive: true})} if CodexOnlyEligible(r, auto, &blocked, now) { t.Fatal("auto-activity without current evidence must not degrade") } - configOnly := Observation{Head: "abcdef123", Open: true, CodexCommands: []CommandSeen{{ID: 55, CreatedAt: now}}} + configOnly := Observation{Head: "abcdef123", Open: true, Co: codexSeen(CoSeen{Commands: []CommandSeen{{ID: 55, CreatedAt: now}}})} if CodexOnlyEligible(r, configOnly, &blocked, now) { t.Fatal("a live command without observed codex evidence must not degrade") } - limited := Observation{Head: "abcdef123", Open: true, CodexActiveThisRound: true, Events: []dialect.BotEvent{usageLimit}} + limited := Observation{Head: "abcdef123", Open: true, Co: codexSeen(CoSeen{ActiveThisRound: true}), Events: []dialect.BotEvent{usageLimit}} if CodexOnlyEligible(r, limited, &blocked, now) { t.Fatal("a codex usage limit since the fire must disengage the degrade") } @@ -730,15 +736,15 @@ func TestDecideFireBlockedCodexDeferred(t *testing.T) { degrade := policy degrade.RequiredBots = []string{"coderabbitai[bot]", dialect.CodexBotLogin} - degrade.CodexCommand = "@codex review" - degrade.RateLimitCodexDegrade = true + degrade = withCodex(degrade, "@codex review") + degrade.RateLimitCoDegrade = true - if d := DecideFire(g, queued, open, now, degrade); d.Verdict != FireCodexDeferred { + if d := DecideFire(g, queued, open, now, degrade); d.Verdict != FireCoDeferred { t.Fatalf("blocked + degrade + postable codex must defer to codex, got %+v", d) } // Flag off → today's behavior. off := degrade - off.RateLimitCodexDegrade = false + off.RateLimitCoDegrade = false if d := DecideFire(g, queued, open, now, off); d.Verdict != FireNo { t.Fatalf("degrade off must stay FireNo blocked, got %+v", d) } @@ -750,22 +756,23 @@ func TestDecideFireBlockedCodexDeferred(t *testing.T) { } // A live command on the PR is adopted as this round's Codex anchor rather // than re-posted or left unrecorded. - cmdObs := Observation{Head: head, Open: true, CodexCommands: []CommandSeen{{ID: 55, CreatedAt: now}}} - if d := DecideFire(g, queued, cmdObs, now, degrade); d.Verdict != FireCodexDeferred || - d.AdoptCommandID != 55 || !d.AdoptAt.Equal(now) || d.PostCodex { + cmdObs := Observation{Head: head, Open: true, Co: codexSeen(CoSeen{Commands: []CommandSeen{{ID: 55, CreatedAt: now}}})} + if d := DecideFire(g, queued, cmdObs, now, degrade); d.Verdict != FireCoDeferred || + d.AdoptCo[dialect.CodexBotLogin].ID != 55 || + !d.AdoptCo[dialect.CodexBotLogin].CreatedAt.Equal(now) || codexPosted(d) { t.Fatalf("a live codex command must be adopted without re-posting, got %+v", d) } // Auto-active Codex reviews unprompted → nothing to post; blocked FireNo. - autoObs := Observation{Head: head, Open: true, CodexAutoActive: true} + autoObs := Observation{Head: head, Open: true, Co: codexSeen(CoSeen{AutoActive: true})} if d := DecideFire(g, queued, autoObs, now, degrade); d.Verdict != FireNo { t.Fatalf("auto-active codex must not be commanded, got %+v", d) } // Unblocked → the normal fire path is untouched. - if d := DecideFire(Global{SlotFree: true}, queued, open, now, degrade); d.Verdict != FirePost || !d.PostCodex { + if d := DecideFire(Global{SlotFree: true}, queued, open, now, degrade); d.Verdict != FirePost || !codexPosted(d) { t.Fatalf("unblocked fire must stay FirePost with codex, got %+v", d) } // A busy fire slot defers to Codex the same way — Codex needs no slot. - if d := DecideFire(Global{SlotFree: false}, queued, open, now, degrade); d.Verdict != FireCodexDeferred { + if d := DecideFire(Global{SlotFree: false}, queued, open, now, degrade); d.Verdict != FireCoDeferred { t.Fatalf("slot-busy + degrade + postable codex must defer to codex, got %+v", d) } if d := DecideFire(Global{SlotFree: false}, queued, open, now, off); d.Verdict != FireNo { @@ -781,7 +788,7 @@ func TestCodexOnlyEligibleUnfiredRound(t *testing.T) { queued := state.Round{Repo: "owner/repo", PR: 448, Head: "abcdef123", Phase: state.PhaseQueued, Seq: 1} // A round-window flag computed from a zero cutoff (an old SHA-less review) // must not qualify an unfired, uncommanded round. - stale := Observation{Head: "abcdef123", Open: true, CodexActiveThisRound: true} + stale := Observation{Head: "abcdef123", Open: true, Co: codexSeen(CoSeen{ActiveThisRound: true})} if CodexOnlyEligible(queued, stale, &blocked, now) { t.Fatal("stale round-window evidence must not defer an unfired round") } @@ -790,13 +797,13 @@ func TestCodexOnlyEligibleUnfiredRound(t *testing.T) { if !CodexOnlyEligible(queued, headReviewed, &blocked, now) { t.Fatal("a codex review of the current head must defer an unfired round") } - auto := Observation{Head: "abcdef123", Open: true, CodexAutoActive: true} + auto := Observation{Head: "abcdef123", Open: true, Co: codexSeen(CoSeen{AutoActive: true})} if CodexOnlyEligible(queued, auto, &blocked, now) { t.Fatal("auto-activity without current evidence must not defer an unfired round") } cleanAt := now.Add(-time.Minute) - cleanHead := Observation{Head: "abcdef123", Open: true, CodexAutoActive: true, - Events: []dialect.BotEvent{{Kind: dialect.EvCodexClean, Bot: dialect.CodexBotLogin, + cleanHead := Observation{Head: "abcdef123", Open: true, Co: codexSeen(CoSeen{AutoActive: true}), + Events: []dialect.BotEvent{{Kind: dialect.EvCoClean, Bot: dialect.CodexBotLogin, SHA: "abcdef1234567890", CommentID: 601, CreatedAt: cleanAt, UpdatedAt: cleanAt}}} if !CodexOnlyEligible(queued, cleanHead, &blocked, now) { t.Fatal("a clean codex summary naming the current head must defer an unfired round") @@ -808,8 +815,8 @@ func TestCodexOnlyEligibleUnfiredRound(t *testing.T) { commanded := queued commanded.CodexCommandID = 77 commanded.CodexCommandedAt = &commandedAt - answered := Observation{Head: "abcdef123", Open: true, CodexActiveThisRound: true, - Events: []dialect.BotEvent{{Kind: dialect.EvCodexUsageLimit, Bot: dialect.CodexBotLogin, + answered := Observation{Head: "abcdef123", Open: true, Co: codexSeen(CoSeen{ActiveThisRound: true}), + Events: []dialect.BotEvent{{Kind: dialect.EvCoUnable, Bot: dialect.CodexBotLogin, CommentID: 600, CreatedAt: commandedAt.Add(-time.Hour), UpdatedAt: commandedAt.Add(-time.Hour)}}} if !CodexOnlyEligible(commanded, answered, &blocked, now) { t.Fatal("command-bound activity must defer a commanded unfired round despite an old usage-limit notice") @@ -827,7 +834,7 @@ func TestCompletionBindsPreFireCodexAnswer(t *testing.T) { gated := policy gated.RequiredBots = []string{"coderabbitai[bot]", dialect.CodexBotLogin} crReview := ReviewSeen{Bot: "coderabbitai[bot]", Commit: "abcdef1234567890", SubmittedAt: r.FiredAt.Add(time.Minute)} - cleanBeforeFire := dialect.BotEvent{Kind: dialect.EvCodexClean, Bot: dialect.CodexBotLogin, CommentID: 900, + cleanBeforeFire := dialect.BotEvent{Kind: dialect.EvCoClean, Bot: dialect.CodexBotLogin, CommentID: 900, CreatedAt: commandedAt.Add(2 * time.Minute), UpdatedAt: commandedAt.Add(2 * time.Minute)} obs := Observation{Head: "abcdef123", Open: true, Reviews: []ReviewSeen{crReview}, @@ -844,3 +851,44 @@ func TestCompletionBindsPreFireCodexAnswer(t *testing.T) { t.Fatalf("evidence older than the codex command must not count: %+v", got) } } + +// --- Codex migration helpers ------------------------------------------------ +// The pre-registry Codex tests below were written against Policy.CodexCommand +// and the Observation.Codex* fields. Those shims are gone: Codex is now one +// registry entry like any other co-reviewer, so these helpers express the same +// setup through CoReviewers / Observation.Co and the rules under test are +// unchanged. + +// withCodex gives p the single Codex co-reviewer entry the legacy tests +// assumed: crq posts its trigger at fire time (the historical +// "configured-required Codex that does not auto-review" behavior). +func withCodex(p Policy, command string) Policy { + p.CoReviewers = []CoReviewerPolicy{{Login: dialect.CodexBotLogin, Command: command, Trigger: TriggerAlways}} + return p +} + +// codexSeen builds an Observation.Co map holding just Codex's slice. +func codexSeen(c CoSeen) map[string]CoSeen { + return map[string]CoSeen{dialect.NormalizeBotName(dialect.CodexBotLogin): c} +} + +// codexPosted reports whether the decision asks the apply layer to post the +// Codex trigger (the old FireDecision.PostCodex mirror). +func codexPosted(d FireDecision) bool { + for _, login := range d.PostCo { + if dialect.IsCodexBot(login) { + return true + } + } + return false +} + +// codexCP extracts p's Codex co-reviewer policy (zero-command entry if absent). +func codexCP(p Policy) CoReviewerPolicy { + for _, cp := range p.CoReviewers { + if dialect.IsCodexBot(cp.Login) { + return cp + } + } + return CoReviewerPolicy{Login: dialect.CodexBotLogin} +} diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 34a2121..7eb61c9 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -25,18 +25,10 @@ const ( FireDrop // PR closed/merged — abandon the round ) -// Deprecated: Codex-named aliases for the generic co-reviewer verdicts, kept -// while crq migrates. -const ( - FireCodexOnly = FireCoOnly - FireCodexDeferred = FireCoDeferred -) - type FireDecision struct { Verdict FireVerdict Reason string - // Adopt fields identify the existing command comment (FireAdopt), or the - // adopted Codex command on the legacy FireCoDeferred adopt path. + // Adopt fields identify the existing command comment (FireAdopt). AdoptCommandID int64 AdoptAt time.Time // PostCo lists the co-reviewer logins whose trigger commands the apply @@ -46,10 +38,6 @@ type FireDecision struct { // AdoptCo identifies live co-reviewer trigger comments to record as this // round's command anchors instead of posting duplicates (FireCoDeferred). AdoptCo map[string]CommandSeen - // PostCodex mirrors "Codex ∈ PostCo". - // - // Deprecated: consume PostCo instead. - PostCodex bool } // Global is the cross-PR state a fire decision needs. @@ -59,15 +47,8 @@ type Global struct { LastFired *time.Time // global pacing anchor } -// coReviewers resolves the effective co-reviewer policies: the explicit list -// when set, otherwise the one synthesized Codex entry (trigger always iff -// configured-required) that preserves the pre-registry behavior. -func (p Policy) coReviewers() []CoReviewerPolicy { - if p.CoReviewers != nil { - return p.CoReviewers - } - return []CoReviewerPolicy{codexPolicy(p)} -} +// coReviewers resolves the effective co-reviewer policies. +func (p Policy) coReviewers() []CoReviewerPolicy { return p.CoReviewers } // CoReviewerPolicies exposes the effective co-reviewer list to the apply // layer (self-heal sweeps, trigger posting), which shares DecideCoPost with @@ -103,15 +84,6 @@ func decideCoPosts(r state.Round, obs Observation, p Policy, now time.Time) []st return out } -func hasCodexLogin(logins []string) bool { - for _, login := range logins { - if dialect.IsCodexBot(login) { - return true - } - } - return false -} - // DecideFire consolidates v2's scattered fire guards, in order: PR open → // head readable → head current → round eligible (phase + RetryAt cooldown) → // slot free → account quota → global pacing → not already reviewed → adopt @@ -207,9 +179,9 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic if at.IsZero() { at = newest.UpdatedAt } - return FireDecision{Verdict: FireAdopt, Reason: "review command already posted", AdoptCommandID: newest.ID, AdoptAt: at, PostCo: postCo, PostCodex: hasCodexLogin(postCo)} + return FireDecision{Verdict: FireAdopt, Reason: "review command already posted", AdoptCommandID: newest.ID, AdoptAt: at, PostCo: postCo} } - return FireDecision{Verdict: FirePost, PostCo: postCo, PostCodex: hasCodexLogin(postCo)} + return FireDecision{Verdict: FirePost, PostCo: postCo} } // decideCoDeferred starts or adopts the co-reviewer half of a round while @@ -219,7 +191,7 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic // post. The legacy Adopt fields mirror the Codex entry for pre-migration // consumers. func decideCoDeferred(r state.Round, obs Observation, p Policy, now time.Time, reason string) (FireDecision, bool) { - if !p.RateLimitCodexDegrade { + if !p.RateLimitCoDegrade { return FireDecision{}, false } var post []string @@ -245,7 +217,7 @@ func decideCoDeferred(r state.Round, obs Observation, p Policy, now time.Time, r if len(post) == 0 && len(adopt) == 0 { return FireDecision{}, false } - d := FireDecision{Verdict: FireCoDeferred, PostCo: post, PostCodex: hasCodexLogin(post)} + d := FireDecision{Verdict: FireCoDeferred, PostCo: post} if len(adopt) > 0 { d.AdoptCo = adopt } @@ -257,14 +229,6 @@ func decideCoDeferred(r state.Round, obs Observation, p Policy, now time.Time, r default: d.Reason = reason + "; adopting existing co-review command, coderabbit deferred" } - if cmd, ok := adopt[dialect.CodexBotLogin]; ok { - at := cmd.CreatedAt - if at.IsZero() { - at = cmd.UpdatedAt - } - d.AdoptCommandID = cmd.ID - d.AdoptAt = at - } return d, true } @@ -321,7 +285,7 @@ func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time, summ delivered = "coderabbit plan is summary-only" } if len(post) > 0 { - return FireDecision{Verdict: FireCoOnly, Reason: delivered + "; co-review still required", PostCo: post, PostCodex: hasCodexLogin(post)} + return FireDecision{Verdict: FireCoOnly, Reason: delivered + "; co-review still required", PostCo: post} } if wait { return FireDecision{Verdict: FireCoReviewWait, Reason: "awaiting co-review"} From 793b110d8f03e11ec1596460a75550a50070a142 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 23:05:16 +0200 Subject: [PATCH 10/31] Document the co-reviewer abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes step 8 (Ref #34). README replaces the Codex-only section with the co-reviewer model: wanted vs required, the never/selfheal/always trigger modes and why Bugbot and Macroscope default to selfheal, the check-run evidence path (a clean Bugbot round posts nothing on the timeline), the co_reviewers report shape, and the two bot quirks crq absorbs — Macroscope resolving by editing its own comment, Bugbot re-reporting the same BUGBOT_BUG_ID in a fresh thread each push. The env table gains CRQ_COBOTS, the four per-bot keys, and CRQ_RL_CO_DEGRADE. llms.txt gets the agent-facing contract, including that Macroscope's approvability verdict is informational and never moves an exit code. AGENTS.md documents the registry in the package map and adds an "Adding a new co-reviewer" recipe alongside the message-format one. crq help needed no change: it is bot-agnostic. --- AGENTS.md | 65 +++++++++++++++++++++++++++++++++++++++++-------------- README.md | 62 +++++++++++++++++++++++++++++++++++++++++++--------- llms.txt | 24 +++++++++++++++----- 3 files changed, 119 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2cbe851..f54ae46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,22 +11,36 @@ Dependency rule (Go-enforced, no cycles): `dialect ← engine ← crq`, `state `gh ← {state, crq}`. The engine does no I/O by construction. - `internal/dialect/` — ALL bot-text knowledge, zero deps. CodeRabbit/Codex - completion, rate-limit, paused, in-progress, failed and clean-review - classifiers; finding parsers; the decline-reply verdict classifiers - (`IsReviewFindingWithdrawn`/`IsReviewFindingRetained`) that let crq read a - bot's rebuttal to a declined finding; SHA/severity vocabulary; the `Finding` - type (frozen JSON tags); the typed `BotEvent`/`Classifier`. The only place a - bot's literal wording may appear. -- `internal/gh/` — GitHub REST/GraphQL transport. Owns the "GitHub REST quota" - concept under the name **Throttle** (`ThrottleWait`/`IsThrottled`). The only - package (besides dialect) allowed to say "rate limit". + completion, rate-limit, paused, in-progress, failed, summary-only-plan and + clean-review classifiers; finding parsers; the decline-reply verdict + classifiers (`IsReviewFindingWithdrawn`/`IsReviewFindingRetained`) that let + crq read a bot's rebuttal to a declined finding; SHA/severity vocabulary; the + `Finding` type (frozen JSON tags); the typed `BotEvent`/`Classifier`. Also the + **co-reviewer registry** (`coreviewer.go` + `bugbot.go`/`macroscope.go`): the + static `KnownCoReviewers()` list — Codex, Cursor Bugbot, Macroscope — each + carrying its login, config name, check-run app slug, trigger command, and its + wording hooks (`ClassifyComment`, `ClassifyCheck`, `ResolvedInSHA`, + `FindingDedupeKey`, …). The only place a bot's literal wording may appear. +- `internal/gh/` — GitHub REST/GraphQL transport, bot-agnostic. Owns the "GitHub + REST quota" concept under the name **Throttle** (`ThrottleWait`/`IsThrottled`). + The only package (besides dialect) allowed to say "rate limit". + `ListCheckRuns` fetches a ref's check runs (envelope-paged, ETag'd); matching + them to a bot is dialect's `ClassifyCheckRun`, never gh's. - `internal/state/` — persisted schema v3: one `Round` per PR, one global `FireSlot`, the CodeRabbit `AccountQuota`, an `Archive` ring. Round transition - methods, the CAS store, and dashboard rendering. + methods, the CAS store, and dashboard rendering. `Round.CoBots` holds per- + co-reviewer trigger bookkeeping; Codex's entry is **dual-written** to the + legacy `Codex*` round fields because the fleet shares one state ref across + binary versions (`Normalize` folds them back on load). - `internal/engine/` — PURE decision logic, `now` passed in, no ctx/gh: `DecideFire` (the single fire owner), `Progress` (fired/reviewing round transitions), `Completion` (the one "is the round done?"), `BlockingFindings` - /`FindingsOnHead`/`Converged`, `Policy`. Every rule is table-tested. + /`FindingsOnHead`/`Converged`, `Policy`. `coreview.go` holds the co-reviewer + evidence/gate algebra, keyed by login and bot-shape-generic (participated, + clean at SHA, cannot finish, was commanded) — including `DecideCoPost` and its + `never|selfheal|always` trigger modes. `codex.go` keeps only what is genuinely + Codex-specific (the thumbs-up quirk, the rate-limit degrade). Every rule is + table-tested. - `internal/crq/` — orchestration only: `service.go` (Enqueue/Pump/Wait/Cancel), `observe.go`, `auto.go`, `feedback.go` (Loop/Feedback assembly), `config.go`, `calibration`/`preflight`/`init`. Holds `Service` and wires the packages. @@ -87,12 +101,31 @@ When a bot ships a new phrasing that crq must recognise, change three things and nothing else: 1. the matching classifier/parser in `internal/dialect` (`coderabbit.go`, - `codex.go`, or `common.go`); -2. one corpus file under `internal/dialect/testdata/{coderabbit,codex}/` holding - the real message; -3. one row in `TestGoldenClassification` (`golden_test.go`) — the row IS the - spec for how that file classifies. + `codex.go`, `bugbot.go`, `macroscope.go`, or `common.go`); +2. one corpus file under `internal/dialect/testdata//` holding the real + message, captured verbatim; +3. one row in `TestGoldenClassification` / `TestGoldenCoReviewers` / + `TestGoldenCheckRuns` (`golden_test.go`) — the row IS the spec for how that + file classifies. Convergence/fire rules that consume those classifications live in `internal/engine` and are table-tested in `engine_test.go`; orchestration stays in `internal/crq`. Keep bot wording out of engine/state/crq. + +## Adding a new co-reviewer + +A **co-reviewer** is a review bot that is not the configured primary and spends +no CodeRabbit quota — so its rounds never take the `FireSlot` (a per-round +`CoBots[login].ClaimedAt` CAS claim serializes its trigger post instead). Adding +one is deliberately contained: + +1. one entry in `dialect.KnownCoReviewers()` with its login, config name, check + app slug, trigger command, and whichever wording hooks it needs; +2. its wording helpers in a new `internal/dialect/.go`; +3. corpus files + golden rows as above; +4. a default in `parseCoBots` (`internal/crq/config.go`) if it should ship on. + +Nothing in `engine`/`state`/`crq` enumerates bots: they key on the login and +consume the registry's hooks. That is why a bot's identity travels as data +(`BotEvent.For`, `CheckSeen.Bot`, `Observation.Co[login]`) and never as a +per-bot event kind or verdict. diff --git a/README.md b/README.md index d181ad9..646b03b 100644 --- a/README.md +++ b/README.md @@ -426,8 +426,13 @@ Set these in `~/.config/crq/env` (sourced automatically) or as environment varia | `CRQ_AUTOREVIEW_SKIP_AUTHORS` | `dependabot[bot]` | PR authors `autoreview` never enqueues (comma-separated; case and `[bot]` suffix don't matter) — set to empty to auto-review bot PRs too; manual `crq review` is unaffected | | `CRQ_AUTOREVIEW_SKIP_MARKER` | `` | exact PR-body marker that suppresses fleet auto-review; set empty to disable; manual `crq loop` is unaffected | | `CRQ_REQUIRED_BOTS` | `coderabbitai[bot]` | bots that must review the head for convergence (crq waits for all of them) | -| `CRQ_CODEX_CMD` | `@codex review` | Codex trigger crq posts alongside the CodeRabbit command when Codex is in `CRQ_REQUIRED_BOTS` and does not auto-review the PR; empty disables Codex firing | -| `CRQ_FEEDBACK_BOTS` | required bots + `chatgpt-codex-connector[bot]` | bots whose findings are surfaced — a superset of required bots, so Codex reviews show up without gating convergence on repos where Codex isn't installed | +| `CRQ_COBOTS` | `codex,bugbot,macroscope` | co-reviewers crq surfaces and (optionally) triggers; set empty to disable all | +| `CRQ_COBOT__REQUIRED` | `0` | make that co-reviewer gate convergence (folds it into `CRQ_REQUIRED_BOTS`); `` ∈ `CODEX`, `BUGBOT`, `MACROSCOPE` | +| `CRQ_COBOT__TRIGGER` | codex: `always` iff required, else `never`; bugbot/macroscope: `selfheal` | when crq posts that bot's command — `never`, `selfheal` (only nudge an active bot that missed the head past its grace), or `always` (post in the fire step) | +| `CRQ_COBOT__CMD` | `@codex review` / `bugbot run` / `@macroscope-app review` | that bot's trigger comment; empty forces `never` | +| `CRQ_COBOT__GRACE` | `10m` | how long a `selfheal` trigger waits for the bot to show up on its own before nudging | +| `CRQ_RL_CO_DEGRADE` | on | while CodeRabbit is rate-limited, run co-reviewer-only rounds instead of waiting the window out; set `0` to disable (legacy alias: `CRQ_RL_CODEX_DEGRADE`) | +| `CRQ_FEEDBACK_BOTS` | required bots + enabled co-reviewers | bots whose findings are surfaced — a superset of required bots, so co-reviewer findings show up without gating convergence on repos where those bots aren't installed | | `CRQ_TZ` | `UTC` | dashboard display timezone (IANA name, e.g. `Europe/Oslo`) | | `CRQ_MIN_INTERVAL` | `90s` | minimum time between fired reviews | | `CRQ_POLL` | `15s` | how often `crq loop` checks its place in line | @@ -450,14 +455,51 @@ them coming!`. crq recognizes that text as a successful, non-actionable review. before it satisfies the Codex gate; otherwise the clean summary is simply ignored rather than emitted as a false finding. -**Codex firing and auto-detection:** when Codex is in `CRQ_REQUIRED_BOTS`, crq posts `CRQ_CODEX_CMD` -in the same fire step as the CodeRabbit command — unless it detects Codex auto-review on the PR (any -Codex review that no `@codex review` command preceded), in which case it never posts and simply waits -for Codex's own review. Conversely, when Codex is *not* required but joins a round on its own (an -actionable comment or review mid-round), the round gates on Codex dynamically: convergence waits for -its review too. A Codex usage-limit notice releases that dynamic gate so an exhausted Codex can't -stall rounds it volunteered for; an explicitly required Codex is still bounded only by the normal -feedback deadline. +Adding a fourth co-reviewer is a contained change: one entry in `dialect.KnownCoReviewers()`, one +corpus file per message shape under `internal/dialect/testdata/`, and one golden row pinning how it +classifies. The engine and the orchestration are bot-agnostic — they key on the login and never on +any bot's wording. + +**Co-reviewers (Codex, Cursor Bugbot, Macroscope):** a co-reviewer is a review bot that isn't the +configured primary and spends no CodeRabbit quota — so its rounds never take the fire slot. All three +are enabled by default (`CRQ_COBOTS`), which means crq *surfaces their findings* and waits for a bot +that is demonstrably participating; it does not make them required. Add one to `CRQ_REQUIRED_BOTS` +(or set `CRQ_COBOT__REQUIRED=1`) to gate convergence on it unconditionally. + +Whether crq ever *posts* a bot's trigger is the separate `CRQ_COBOT__TRIGGER` knob. Codex keeps +its historical behavior — `always` when it is required, so the `@codex review` command goes out in +the same fire step as the CodeRabbit one. Bugbot and Macroscope default to `selfheal`, because they +already auto-review every push: crq stays silent unless a bot it has seen working misses the current +head for longer than `CRQ_COBOT__GRACE`. In every mode crq suppresses the trigger when the bot +auto-reviews, has already reviewed the head, has a check run in flight, or has a live command on the +PR — so no bot is ever double-asked. + +A co-reviewer that joins a round on its own (an actionable comment, a review, a check run) gates that +round dynamically: convergence waits for it even though it isn't required. An exhaustion notice — for +Codex, its usage-limit message — releases that dynamic gate so a bot that cannot finish can't stall a +round it volunteered for. An explicitly required bot is bounded only by the normal feedback deadline. + +**Check runs.** Bugbot and Macroscope publish verdicts as commit check runs, not just comments, and a +clean Bugbot round posts *nothing* on the timeline — its `Cursor Bugbot` check run is the only +evidence it ran. crq therefore reads check runs for the head (one extra ETag'd REST call, so repeat +polls are free 304s) and counts a completed one as that bot's review. `crq feedback --json` reports +this per bot under `co_reviewers`: + +```json +"co_reviewers": { + "cursor": { "reviewed": true, "check_state": "clean" }, + "macroscopeapp": { "reviewed": true, "check_state": "issues", "verdict": "needs_human_review" } +} +``` + +`check_state` is `clean`, `issues`, `in_progress` or `unknown`. Macroscope's approvability `verdict` +is **informational only** — it never gates convergence and never changes an exit code. + +**Bot-specific quirks crq handles for you.** Macroscope never resolves a thread; it *edits* its own +comment to append `✅ Resolved in ` (or `No longer relevant as of `), so crq treats that +edit as the resolution and drops the finding. Bugbot re-reports the same bug in a fresh thread after +each push, so crq dedupes on its stable `BUGBOT_BUG_ID` instead of the thread id — one finding, not +one per push. **Summary-only plans (CodeRabbit Free on private repos):** a Free plan reviews public repos in full but only *summarizes* private ones — CodeRabbit posts its walkthrough, tags it `🎁 Summarized by diff --git a/llms.txt b/llms.txt index 0cd0078..691c9e3 100644 --- a/llms.txt +++ b/llms.txt @@ -64,10 +64,22 @@ crq loop OWNER/REPO PR_NUMBER > crq-feedback.json rc=$? ``` -`crq loop` queues the PR and triggers CodeRabbit when the account can spend a review. If Codex is in -`CRQ_REQUIRED_BOTS`, crq also posts `CRQ_CODEX_CMD` (default `@codex review`) in the same fire step — -never when Codex auto-reviews the PR on its own. A Codex that joins a round uninvited gates that -round's convergence dynamically (its usage-limit notice releases the dynamic gate). If a bot reports +`crq loop` queues the PR and triggers CodeRabbit when the account can spend a review. Alongside it +crq runs its **co-reviewers** — Codex, Cursor Bugbot and Macroscope (`CRQ_COBOTS`, all enabled by +default). None of them spends CodeRabbit quota or takes the fire slot. Each has a trigger mode +(`CRQ_COBOT__TRIGGER`): `always` posts its command in the same fire step, `selfheal` (the +Bugbot/Macroscope default) only nudges a bot that reviews on its own but missed the head past +`CRQ_COBOT__GRACE`, and `never` posts nothing. crq never triggers a bot that auto-reviews, has +already reviewed the head, or has a check run in flight. A co-reviewer that joins a round uninvited +gates that round's convergence dynamically (an exhaustion notice such as Codex's usage limit +releases the dynamic gate); one listed in `CRQ_REQUIRED_BOTS` always gates. + +Bugbot and Macroscope report through **check runs** as well as comments — a clean Bugbot round posts +nothing at all on the timeline, so its `Cursor Bugbot` check IS the evidence crq converges on. +`.co_reviewers[]` reports each one's `reviewed` / `check_state` +(`clean|issues|in_progress|unknown`) / `verdict`. Macroscope's approvability `verdict` +(`approved|needs_human_review`) is informational only: it never gates convergence and never changes +an exit code. If a bot reports actionable findings first, it emits them immediately while leaving the queued round alive. Fix and validate locally and resolve or decline each addressed thread immediately. While any required bot's `.reviewed_by` value is false, do not commit or push — unless `.coderabbit_deferred` is true and all @@ -91,8 +103,8 @@ Exit codes: Rate-limit degrade: when CodeRabbit is rate-limited ("Review limit reached … Next review available in: NN minutes") and Codex demonstrably reviews the PR, the loop degrades to Codex-only rounds -instead of waiting the window out, and the pump posts `CRQ_CODEX_CMD` for blocked rounds while -keeping the CodeRabbit review queued. Disable with `CRQ_RL_CODEX_DEGRADE=0` (default on). +instead of waiting the window out, and the pump posts the co-reviewer triggers for blocked rounds +while keeping the CodeRabbit review queued. Disable with `CRQ_RL_CO_DEGRADE=0` (default on). Summary-only plans: on a private repo under a Free CodeRabbit plan the bot posts a walkthrough and never a line-by-line review ("Summarized by CodeRabbit Free … upgrade … for a comprehensive From cfb63cdb3891415e89db9c33d3a37beaccbecd1f Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 23:20:22 +0200 Subject: [PATCH 11/31] Stop starving quota-free rounds behind a blocked queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by dogfooding on a private CodeRabbit-Free repo. A summary-only round needs no CodeRabbit quota and no fire slot, but sat queued behind another PR's rate-limited round: Pump's bounded rescue scan observed it, computed its verdict, then discarded it because it accepted only FireCoDeferred. The round stayed queued with an empty note while its Codex review went unused. The scan now applies any quota-free verdict (FireCoDeferred, FireCoOnly, FireCoReviewWait, FireDedupe) — none of which posts the CodeRabbit command, reserves the slot, or spends account quota. It is verdict-driven rather than flag-driven: decideCoDeferred already honours RateLimitCoDegrade internally, so a disabled degrade simply never yields FireCoDeferred, while a summary-only round is rescued regardless. The regression test reproduces the production symptom exactly — without the fix Pump returns the front PR's "account blocked" and leaves the summary-only round queued. --- internal/crq/service.go | 40 ++++++++++++---- internal/crq/service_test.go | 88 ++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/internal/crq/service.go b/internal/crq/service.go index aa6cf7a..c8eff30 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -267,20 +267,33 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { if err != nil { return result, err } - // A blocked or slot-busy front of the queue whose co-reviewer triggers are - // already posted must not starve later PRs of THEIR early co-review round — - // scan a bounded number of following queued rounds for a postable defer - // (each costs one observation; ETag caching keeps it cheap). + // A blocked or slot-busy front of the queue must not starve later PRs of + // resolutions that spend NO CodeRabbit quota — a co-reviewer defer, or a + // summary-only round whose review is never coming from CodeRabbit at all. + // Scan a bounded number of following queued rounds and apply any quota-free + // verdict (each costs one observation; ETag caching keeps it cheap). + // + // The scan is deliberately verdict-driven rather than flag-driven: + // `decideCoDeferred` already honours RateLimitCoDegrade internally, so a + // disabled degrade simply never yields FireCoDeferred, while a summary-only + // round must be rescued regardless of that flag. accountBlocked := global.BlockedUntil != nil && global.BlockedUntil.After(now) - if s.cfg.RateLimitCoDegrade && decision.Verdict == engine.FireNo && - (accountBlocked || !global.SlotFree) { + if decision.Verdict == engine.FireNo && (accountBlocked || !global.SlotFree) { scanned := 0 policy := s.policy() for _, r := range st.QueuedRounds(now) { if r.Repo == next.Repo && r.PR == next.PR { continue } - if !anyCoUncommanded(r, policy) || scanned >= 3 { + if scanned >= 3 { + break + } + // Cheap pre-filter: a round with every trigger already posted has + // nothing left for this scan to start. (A summary-only round in + // that state is merely delayed, not starved — it resolves when it + // reaches the front of the queue; recognising it here would cost an + // observation for every queued round on every pump.) + if !anyCoUncommanded(r, policy) { continue } scanned++ @@ -290,7 +303,7 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { continue } d := engine.DecideFire(s.global(st, now), round, robs.eng, now, policy) - if d.Verdict != engine.FireCoDeferred { + if !quotaFreeVerdict(d.Verdict) { continue } return s.applyFire(ctx, round, robs.eng, d, now) @@ -299,6 +312,17 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { return result, nil } +// quotaFreeVerdict reports whether a fire verdict can be applied while another +// PR holds the slot or the account is blocked: none of these post +// `@coderabbitai review`, reserve the FireSlot, or spend account quota. +func quotaFreeVerdict(v engine.FireVerdict) bool { + switch v { + case engine.FireCoDeferred, engine.FireCoOnly, engine.FireCoReviewWait, engine.FireDedupe: + return true + } + return false +} + // anyCoUncommanded reports whether some triggerable co-reviewer has no // command recorded on the round yet — the cheap pre-filter for the deferred // scan above (a round with every trigger already posted cannot defer again). diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 0eb48d8..c1e7524 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -2333,3 +2333,91 @@ func TestFireCoDeferredAdoptionHonorsDryRunAndActiveClaim(t *testing.T) { t.Fatalf("the recovered adoption must replace the stale claim, got %#v", got) } } + +// TestPumpRescuesSummaryOnlyRoundBehindBlockedQueue pins a starvation bug found +// by dogfooding on a private CodeRabbit-Free repo. A summary-only round needs no +// CodeRabbit quota and no fire slot — no review is ever coming from CodeRabbit — +// yet it sat queued behind a rate-limited round from another PR. Pump's bounded +// rescue scan DID observe it and DID compute its verdict, then threw the verdict +// away because it only accepted FireCoDeferred, leaving the round queued with an +// empty note while its Codex review went unused. +func TestPumpRescuesSummaryOnlyRoundBehindBlockedQueue(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() + cfg.RequiredBots = []string{"coderabbitai[bot]", dialect.CodexBotLogin} + cfg.CoBots = codexCoBots(cfg.RequiredBots) + cfg.FeedbackBots = cfg.RequiredBots + gh := newFakeGitHub() + gh.graphQL = noForcePush + now := time.Now().UTC() + + // Front of the queue: another PR, blocked, whose Codex trigger is already + // posted — so it yields FireNo and nothing more can be done for it. + frontSHA := "1111111111111111" + frontPull := ghapi.Pull{State: "open"} + frontPull.Head.SHA = frontSHA + gh.pulls[fakeKey("o/front", 10)] = frontPull + + // Behind it: a summary-only PR. CodeRabbit posted only its Free-plan + // walkthrough, which is the whole review it will ever produce. + backSHA := "2222222222222222" + backPull := ghapi.Pull{State: "open"} + backPull.Head.SHA = backSHA + gh.pulls[fakeKey("o/back", 20)] = backPull + walkthrough := ghapi.IssueComment{ + ID: 900, + Body: corpusMessage(t, "coderabbit/summary-only-free-plan.md"), + CreatedAt: now.Add(-time.Minute), + UpdatedAt: now.Add(-time.Minute), + } + walkthrough.User.Login = "coderabbitai[bot]" + gh.comments[fakeKey("o/back", 20)] = []ghapi.IssueComment{walkthrough} + + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + svc.now = func() time.Time { return now } + + if _, err := svc.Enqueue(ctx, "o/front", 10); err != nil { + t.Fatal(err) + } + if _, err := svc.Enqueue(ctx, "o/back", 20); err != nil { + t.Fatal(err) + } + blockedUntil := now.Add(30 * time.Minute) + if _, err := store.Update(ctx, func(st *State) error { + st.Account.BlockedUntil = &blockedUntil + // The front round has already asked Codex, so the degrade has nothing + // left to post for it and it can only report FireNo while blocked. + r := st.Round("o/front", 10) + r.SetCoCommand(dialect.CodexBotLogin, 701, now.Add(-2*time.Minute)) + st.PutRound(*r) + return nil + }); err != nil { + t.Fatal(err) + } + + res, err := svc.Pump(ctx) + if err != nil { + t.Fatal(err) + } + // The blocked front round must not swallow the pump: the summary-only round + // behind it gets resolved instead of staying queued. + if res.Repo != "o/back" || res.PR != 20 { + t.Fatalf("the summary-only round behind the block must be rescued, got %#v", res) + } + st, _, _ := store.Load(ctx) + back := st.Round("o/back", 20) + if back == nil || back.Phase == PhaseQueued { + t.Fatalf("the summary-only round must leave the queue, got %#v", back) + } + // Whatever the resolution, it must never have spent CodeRabbit quota: no + // `@coderabbitai review` posted and no fire slot taken. + for _, p := range gh.posted { + if strings.Contains(p, cfg.ReviewCommand) { + t.Fatalf("a summary-only round must never post the CodeRabbit command, posted=%v", gh.posted) + } + } + if st.FireSlot != nil { + t.Fatalf("a summary-only round must not take the fire slot, got %#v", st.FireSlot) + } +} From 3296a604752fc464002a5e909ceb010d15dc97dc Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 23:25:15 +0200 Subject: [PATCH 12/31] Take quota-free rounds out of the review queue entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A summary-only round was still a queue citizen: crq loop advanced its PR only through the global Pump, which selects the lowest-Seq round fleet-wide and does one thing per pass. So a CodeRabbit-Free private repo — where no CodeRabbit review is ever coming — waited behind other PRs and behind an account block that cannot apply to it. Widening Pump's rescue scan (cfb63cd) only softened that: the scan is bounded, and Pump short-circuits on the slot holder before ever reaching it. The Seq FIFO and the FireSlot exist for exactly one purpose, serializing CodeRabbit's account-wide review limit. A round that will never spend that limit has no business being ordered by it. Wait now resolves its own round directly whenever the verdict is quota-free, falling back to the global pump otherwise, so the only thing such a round waits for is its own co-reviewers. The test stages the starvation at its worst — another PR HOLDS the fire slot, so Pump returns after progressing the slot holder and never consults the FIFO or its scan. Without the bypass the loop spins until the context deadline, which is exactly what the live krisHQ loops did. --- internal/crq/service.go | 53 +++++++++++++++++++++++- internal/crq/service_test.go | 79 ++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/internal/crq/service.go b/internal/crq/service.go index c8eff30..c144e63 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -312,6 +312,43 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { return result, nil } +// advanceQuotaFree resolves ONE PR's round directly, bypassing the account-wide +// FIFO, when its verdict spends no CodeRabbit quota. It is the loop's escape +// hatch from a queue it does not belong in: a summary-only round has no review +// coming from CodeRabbit ever, so neither the fire slot, the global pacing +// interval, nor an account block has any authority over it — the only thing it +// waits for is its own co-reviewers. +// +// handled is false when the round is gone, not fire-eligible, or its verdict +// would spend quota; the caller then falls back to the normal global pump. +func (s *Service) advanceQuotaFree(ctx context.Context, repo string, pr int) (PumpResult, bool, error) { + now := s.clock() + st, _, err := s.store.Load(ctx) + if err != nil { + return PumpResult{}, false, err + } + round := st.Round(repo, pr) + if round == nil || !round.FireEligible(now) { + return PumpResult{}, false, nil + } + obs, err := s.observe(ctx, repo, pr, round, now) + if err != nil { + return PumpResult{}, false, err + } + d := engine.DecideFire(s.global(st, now), *round, obs.eng, now, s.policy()) + if !quotaFreeVerdict(d.Verdict) { + return PumpResult{}, false, nil + } + res, err := s.applyFire(ctx, *round, obs.eng, d, now) + if err != nil { + return PumpResult{}, false, err + } + if s.log != nil { + s.log.Printf("%s#%d resolved without the review queue: %s", repo, pr, d.Reason) + } + return res, true, nil +} + // quotaFreeVerdict reports whether a fire verdict can be applied while another // PR holds the slot or the account is blocked: none of these post // `@coderabbitai review`, reserve the FireSlot, or spend account quota. @@ -1763,10 +1800,24 @@ func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, in return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: report.Head, Reason: "codex answered; coderabbit deferred"}, 3, nil } } - result, err := s.Pump(ctx) + // A round that spends no CodeRabbit quota is not a queue citizen. The Seq + // FIFO and the FireSlot exist for exactly one purpose: serializing + // CodeRabbit's account-wide review limit. A summary-only plan (CodeRabbit + // Free on a private repo) never produces a review at all, so parking it + // behind other PRs — or behind an account block that cannot apply to it — + // is pure latency on a round whose co-reviewers are ready to run now. + // Resolve THIS PR's round directly instead of waiting for the global pump + // to select it. + result, handled, err := s.advanceQuotaFree(ctx, repo, pr) if err != nil { return PumpResult{}, 1, err } + if !handled { + result, err = s.Pump(ctx) + if err != nil { + return PumpResult{}, 1, err + } + } state, _, err := s.store.Load(ctx) if err != nil { return PumpResult{}, 1, err diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index c1e7524..943268c 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -2421,3 +2421,82 @@ func TestPumpRescuesSummaryOnlyRoundBehindBlockedQueue(t *testing.T) { t.Fatalf("a summary-only round must not take the fire slot, got %#v", st.FireSlot) } } + +// TestWaitResolvesSummaryOnlyWithoutTheQueue pins the architectural rule the +// dogfood exposed: a summary-only round is NOT a queue citizen. The Seq FIFO and +// the FireSlot exist solely to serialize CodeRabbit's account-wide review limit, +// and a CodeRabbit-Free private repo never produces a review at all — so neither +// an account block, nor another PR holding the slot, may delay it. Its +// co-reviewers are ready now; the loop must run them now. +// +// The starvation is staged at its worst: another PR HOLDS the fire slot, so Pump +// short-circuits on the slot holder and never reaches the FIFO or its bounded +// rescue scan at all. Only the direct quota-free path can resolve this round. +func TestWaitResolvesSummaryOnlyWithoutTheQueue(t *testing.T) { + cfg := firingConfig() + cfg.RequiredBots = []string{"coderabbitai[bot]", dialect.CodexBotLogin} + cfg.CoBots = codexCoBots(cfg.RequiredBots) + cfg.FeedbackBots = cfg.RequiredBots + cfg.PollInterval = 10 * time.Millisecond + gh := newFakeGitHub() + gh.graphQL = noForcePush + now := time.Now().UTC() + + // Another PR holds the fire slot: every Pump pass returns after progressing + // it, so the FIFO below is never consulted. + frontPull := ghapi.Pull{State: "open"} + frontPull.Head.SHA = "1111111111111111" + gh.pulls[fakeKey("o/front", 10)] = frontPull + + sha := "2222222222222222" + pull := ghapi.Pull{State: "open"} + pull.Head.SHA = sha + gh.pulls[fakeKey("o/private", 20)] = pull + walkthrough := ghapi.IssueComment{ + ID: 900, + Body: corpusMessage(t, "coderabbit/summary-only-free-plan.md"), + CreatedAt: now.Add(-time.Minute), + UpdatedAt: now.Add(-time.Minute), + } + walkthrough.User.Login = "coderabbitai[bot]" + gh.comments[fakeKey("o/private", 20)] = []ghapi.IssueComment{walkthrough} + + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + svc.now = func() time.Time { return now } + + seedRound(t, store, cfg, "o/front", 10, "111111111", PhaseFired, now.Add(-time.Minute), 500) + blockedUntil := now.Add(45 * time.Minute) + if _, err := store.Update(context.Background(), func(st *State) error { + st.Account.BlockedUntil = &blockedUntil + st.FireSlot = &FireSlot{Key: QueueKey("o/front", 10), Token: "seedtok", Since: now.Add(-time.Minute)} + return nil + }); err != nil { + t.Fatal(err) + } + + // A bounded context turns the pre-fix behavior (spin until the block clears) + // into a prompt failure instead of a hung test. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + res, code, err := svc.Wait(ctx, "o/private", 20) + if err != nil { + t.Fatalf("the summary-only round must resolve without the queue, got err=%v res=%#v", err, res) + } + if code == 2 { + t.Fatalf("the summary-only round must not wait out the account block, got %#v code=%d", res, code) + } + st, _, _ := store.Load(context.Background()) + if r := st.Round("o/private", 20); r == nil || r.Phase == PhaseQueued { + t.Fatalf("the summary-only round must leave the queue, got %#v", r) + } + for _, p := range gh.posted { + if strings.Contains(p, cfg.ReviewCommand) { + t.Fatalf("summary-only must never post the CodeRabbit command, posted=%v", gh.posted) + } + } + // The other PR keeps the slot — resolving ours never touched it. + if st.FireSlot == nil || st.FireSlot.Key != QueueKey("o/front", 10) { + t.Fatalf("the front PR must keep the fire slot, got %#v", st.FireSlot) + } +} From 451e549880396c72619c2a3ffe811dfed46a258a Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 23:40:34 +0200 Subject: [PATCH 13/31] Recognize CodeRabbit's "Review skipped" as its own state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from iptvchecker#214, where a 119-file PR exceeded the 100-file plan limit. CodeRabbit's "Review skipped" callout embeds its rate-limit marker, so crq classified it EvRateLimited with no parseable window. The consequences compounded: Progress fabricated a 15-minute fallback block, parked the round for retry, and re-fired the same head — which produced the identical refusal, forever. Worse, the fabricated block is written to the ACCOUNT-wide quota, so one oversized PR stalled every other PR in the fleet on a condition none of them shared. A skip is not a rate limit: no window clears it, and it is per-head rather than per-account. It is the same shape as a summary-only plan — no primary review is coming for this head — so both now resolve through one predicate, PrimaryReviewUnavailable, and the round converges on its co-reviewers. The refusal binds to the SHA the notice names, so narrowing the PR yields a head that fires normally instead of permanently disabling the reviewer. And because a silently absent reviewer is worse than a loud one, the skip is surfaced as a major finding carrying CodeRabbit's own reason, which tells the agent to split the PR. Classification is anchored to the callout heading rather than loose prose: a false positive here would stop crq reviewing a PR it could review. The golden row pins the real captured message, and flipping the classifier order back reproduces the old EvRateLimited misread. --- AGENTS.md | 4 +- README.md | 10 + internal/crq/feedback.go | 21 ++ internal/dialect/coderabbit.go | 50 +++++ internal/dialect/event.go | 9 + internal/dialect/golden_test.go | 41 ++++ .../review-skipped-too-many-files.md | 212 ++++++++++++++++++ internal/engine/completion.go | 16 +- internal/engine/coreview_test.go | 94 ++++++++ internal/engine/engine.go | 27 +++ internal/engine/fire.go | 40 ++-- llms.txt | 6 + 12 files changed, 501 insertions(+), 29 deletions(-) create mode 100644 internal/dialect/testdata/coderabbit/review-skipped-too-many-files.md diff --git a/AGENTS.md b/AGENTS.md index f54ae46..802ee96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,8 @@ Dependency rule (Go-enforced, no cycles): `dialect ← engine ← crq`, `state `gh ← {state, crq}`. The engine does no I/O by construction. - `internal/dialect/` — ALL bot-text knowledge, zero deps. CodeRabbit/Codex - completion, rate-limit, paused, in-progress, failed, summary-only-plan and - clean-review classifiers; finding parsers; the decline-reply verdict + completion, rate-limit, paused, in-progress, failed, summary-only-plan, + review-skipped and clean-review classifiers; finding parsers; the decline-reply verdict classifiers (`IsReviewFindingWithdrawn`/`IsReviewFindingRetained`) that let crq read a bot's rebuttal to a declined finding; SHA/severity vocabulary; the `Finding` type (frozen JSON tags); the typed `BotEvent`/`Classifier`. Also the diff --git a/README.md b/README.md index 646b03b..9027380 100644 --- a/README.md +++ b/README.md @@ -495,6 +495,16 @@ this per bot under `co_reviewers`: `check_state` is `clean`, `issues`, `in_progress` or `unknown`. Macroscope's approvability `verdict` is **informational only** — it never gates convergence and never changes an exit code. +**Skipped reviews.** CodeRabbit sometimes refuses a head outright — too many files for the plan's +limit, no usage credits, an unsupported diff — with a `Review skipped` callout. That notice ships +with CodeRabbit's *rate-limit marker embedded*, so a naive reading files it as a rate limit: crq +would invent a retry window that never clears, re-fire the same PR forever, and park the +**account-wide** quota so every other PR in the fleet stalls behind one oversized diff. crq +classifies it as its own state instead — no account block, no retry loop. The round resolves on the +co-reviewers, and the skip is surfaced as a `major` finding so the PR actually gets narrowed. The +refusal binds to the SHA the notice names, so splitting the PR yields a head crq fires normally +again. + **Bot-specific quirks crq handles for you.** Macroscope never resolves a thread; it *edits* its own comment to append `✅ Resolved in ` (or `No longer relevant as of `), so crq treats that edit as the resolution and drops the finding. Bugbot re-reports the same bug in a fresh thread after diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 3cfa80a..6be838c 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -244,6 +244,27 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe continue } if s.isConfiguredBot(comment.User.Login) { + // A "Review skipped" notice is the one configured-bot comment that IS + // actionable: the review did not happen and never will for this head, + // so the round converges on the co-reviewers alone. Without surfacing + // it the agent silently loses its primary reviewer and never learns + // the PR needs narrowing. + if s.cr.IsReviewSkipped(comment.Body) { + reason := dialect.ReviewSkippedReason(comment.Body) + if reason == "" { + reason = "CodeRabbit skipped the review for this head." + } + report.Findings = append(report.Findings, dialect.Finding{ + Bot: comment.User.Login, + Severity: "major", + Title: "CodeRabbit skipped this review — narrow the PR to get one: " + reason, + Body: strings.TrimSpace(comment.Body), + CommentID: comment.ID, + URL: comment.URL, + Source: "issue_comment", + CreatedAt: comment.CreatedAt, + }) + } continue } if dialect.IsNonActionableText(comment.Body) { diff --git a/internal/dialect/coderabbit.go b/internal/dialect/coderabbit.go index 2a4b884..4bbc038 100644 --- a/internal/dialect/coderabbit.go +++ b/internal/dialect/coderabbit.go @@ -119,6 +119,56 @@ func (d CodeRabbit) IsSummaryOnlyPlan(body string) bool { strings.Contains(l, "high-level summary and a walkthrough")) } +var ( + // reviewSkippedRE matches CodeRabbit's "Review skipped" callout heading. + // Anchored to the heading (optionally inside a blockquote callout) rather + // than matched as loose prose: a false positive stops crq firing CodeRabbit + // on a PR it could really review. + reviewSkippedRE = regexp.MustCompile(`(?mi)^\s*>?\s*#{1,4}\s*review skipped\s*$`) + // reviewSkippedHeadRE captures the head SHA a skip was evaluated against, + // from "Reviewing files that changed ... between and .". + reviewSkippedHeadRE = regexp.MustCompile(`(?i)between\s+[0-9a-f]{7,40}\s+and\s+([0-9a-f]{7,40})`) + // reviewSkippedReasonRE captures the human-readable reason CodeRabbit gives + // under the heading (the first non-empty, non-heading callout line). + reviewSkippedReasonRE = regexp.MustCompile(`(?mi)^\s*>\s*(?:##+\s*)?([A-Z][^\n]*?[.!])\s*$`) +) + +// IsReviewSkipped reports whether body is CodeRabbit's "Review skipped" notice: +// it declined to review this head outright (too many files, no usage credits, +// an unsupported diff). This is NOT a rate limit even though the notice carries +// the rate-limit marker — there is no window after which it clears, so waiting +// and re-firing produce the same refusal forever while poisoning the +// account-wide quota with a fabricated block. Treat it as "no CodeRabbit review +// is coming for this head" and let the co-reviewers decide the round. +func (d CodeRabbit) IsReviewSkipped(body string) bool { + return reviewSkippedRE.MatchString(body) +} + +// ReviewSkippedHeadSHA returns the head commit the skip was evaluated against, +// or "" when the notice names none. The refusal is per-head — splitting the PR +// produces a new head that CodeRabbit may well review — so binding to this SHA +// is what lets the next head fire normally. +func ReviewSkippedHeadSHA(body string) string { + if match := reviewSkippedHeadRE.FindStringSubmatch(body); len(match) == 2 { + return strings.ToLower(match[1]) + } + return "" +} + +// ReviewSkippedReason returns the first explanatory sentence CodeRabbit gives +// for skipping ("Too many files!", "This PR contains 119 files, ..."), for +// surfacing to the agent as actionable work. Empty when none parses. +func ReviewSkippedReason(body string) string { + for _, line := range reviewSkippedReasonRE.FindAllStringSubmatch(body, -1) { + text := strings.TrimSpace(line[1]) + if text == "" || strings.EqualFold(text, "Review skipped") { + continue + } + return text + } + return "" +} + // IsReviewInProgress reports whether body is CodeRabbit's editable top-summary // state for a review that has started but has not finished. CodeRabbit can post // a "Review finished" command reply before this summary leaves the processing diff --git a/internal/dialect/event.go b/internal/dialect/event.go index 21511a3..47eb266 100644 --- a/internal/dialect/event.go +++ b/internal/dialect/event.go @@ -28,6 +28,7 @@ const ( EvInProgress // editable top summary: review still processing EvFailed // editable top summary: review failed EvAlreadyReviewed // "does not re-review already reviewed commits" claim + EvSkipped // "Review skipped": the bot refused this head outright EvNoAction // CodeRabbit clean-review summary (no actionable comments) EvCoClean // co-reviewer clean-summary issue comment EvCoUnable // co-reviewer cannot finish this round (Codex usage limits) @@ -130,6 +131,14 @@ func (c Classifier) Classify(author, body string, id int64, createdAt, updatedAt ev.AutoReply = c.CodeRabbit.IsAutoReply(body) ev.SummaryOnly = c.CodeRabbit.IsSummaryOnlyPlan(body) switch { + case c.CodeRabbit.IsReviewSkipped(body): + // Ordered BEFORE the rate limit deliberately: the skip notice ships with + // the rate-limit marker embedded, but it is a refusal of THIS head, not + // a timed account block. Classifying it as a rate limit fabricates a + // window that never clears — crq would re-fire forever and block the + // whole account's quota on one oversized PR. + ev.Kind = EvSkipped + ev.SHA = ReviewSkippedHeadSHA(body) case c.CodeRabbit.IsRateLimited(body): ev.Kind = EvRateLimited ev.Window = ParseAvailableIn(body, updatedAt) diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index 9fe0c44..9c1f6e8 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -36,6 +36,7 @@ func TestGoldenClassification(t *testing.T) { inProgress bool failed bool alreadyDone bool + reviewSkipped bool completionReply bool autoReply bool noAction bool @@ -65,6 +66,12 @@ func TestGoldenClassification(t *testing.T) { // output — no review object ever follows, so crq must run the // co-reviewers alone rather than firing (and waiting on) CodeRabbit. {file: "coderabbit/summary-only-free-plan.md", summaryOnly: true}, + // "Review skipped": CodeRabbit refused this head outright. It ships WITH + // the rate-limit marker embedded, so it must classify as EvSkipped and + // NOT as a rate limit — otherwise crq fabricates a window that never + // clears, re-fires forever, and blocks the whole account's quota. + {file: "coderabbit/review-skipped-too-many-files.md", reviewSkipped: true, rateLimited: true, + author: "coderabbitai[bot]", wantKind: EvSkipped}, {file: "coderabbit/already-reviewed.md", alreadyDone: true, autoReply: true}, {file: "coderabbit/completion-reply.md", completionReply: true, autoReply: true}, // The standalone trailer is an ack; a real finding CARRYING the trailer @@ -98,6 +105,7 @@ func TestGoldenClassification(t *testing.T) { {"IsAutoReply", goldenCR.IsAutoReply(body), tc.autoReply}, {"IsNoActionReviewCompletion", IsNoActionReviewCompletion(body), tc.noAction}, {"IsSummaryOnlyPlan", goldenCR.IsSummaryOnlyPlan(body), tc.summaryOnly}, + {"IsReviewSkipped", goldenCR.IsReviewSkipped(body), tc.reviewSkipped}, {"IsCodexNoActionReviewCompletion", IsCodexNoActionReviewCompletion(body), tc.codexClean}, {"IsCodexUsageLimit", IsCodexUsageLimit(body), tc.codexUsageLimit}, {"IsNonActionableText", IsNonActionableText(body), tc.nonActionable}, @@ -414,3 +422,36 @@ func TestGoldenCheckRuns(t *testing.T) { t.Errorf("non-review cursor check classified as %q,%v", login, verdict) } } + +// TestGoldenReviewSkipped pins the parts of the "Review skipped" notice crq +// acts on: the head it binds to (so a reworked PR fires again) and the reason +// it surfaces to the agent (so the PR actually gets narrowed). +func TestGoldenReviewSkipped(t *testing.T) { + body := readGolden(t, "coderabbit/review-skipped-too-many-files.md") + + if got := ReviewSkippedHeadSHA(body); got != "56150a0423a243224b03f355c3a3ba6941011b5b" { + t.Errorf("ReviewSkippedHeadSHA = %q", got) + } + if got := ReviewSkippedReason(body); !strings.Contains(got, "Too many files") { + t.Errorf("ReviewSkippedReason = %q, want the file-count reason", got) + } + // The skip must never be mistaken for a timed block: no window parses, so + // treating it as a rate limit would invent a fallback that never clears. + if at := ParseAvailableIn(body, time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)); at != nil { + t.Errorf("ParseAvailableIn = %v, want none (a skip has no window)", at) + } + // A normal rate limit must NOT read as a skip. + if got := goldenCR.IsReviewSkipped(readGolden(t, "coderabbit/rate-limit-fair-usage.md")); got { + t.Error("a real rate-limit notice must not classify as a skipped review") + } + // Nor may ordinary prose mentioning the words trip it — a false positive + // stops crq firing CodeRabbit on a PR it could really review. + for _, prose := range []string{ + "The review skipped nothing important.", + "> Some files had their review skipped for brevity.", + } { + if goldenCR.IsReviewSkipped(prose) { + t.Errorf("prose must not classify as skipped: %q", prose) + } + } +} diff --git a/internal/dialect/testdata/coderabbit/review-skipped-too-many-files.md b/internal/dialect/testdata/coderabbit/review-skipped-too-many-files.md new file mode 100644 index 0000000..f297ad0 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/review-skipped-too-many-files.md @@ -0,0 +1,212 @@ + + + +> [!IMPORTANT] +> ## Review skipped +> +> Too many files! +> +> This PR contains 119 files, which is 19 over the limit of 100. +> +> To get a review, narrow the scope: +> • coderabbit review --committed # exclude uncommitted changes +> • coderabbit review --dir # limit to a subdirectory +> • coderabbit review --base # compare against a closer base +> +> Upgrade to a paid plan to raise the limit. +> +> This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the [billing tab](https://app.coderabbit.ai/settings/billing?tab=usage&orgId=c5049a78-cc9d-4254-a553-d725b981bcca), then retry. +> +>
+> ⚙️ Run configuration +> +> **Configuration used**: Organization UI +> +> **Review profile**: ASSERTIVE +> +> **Plan**: Pro Plus +> +> **Run ID**: `72478289-4b6b-4c59-b012-cf5dbd7c1d5a` +> +>
+> +>
+> 📥 Commits +> +> Reviewing files that changed from the base of the PR and between a47f71d4a8d125f7c01668f17043eb090617b728 and 56150a0423a243224b03f355c3a3ba6941011b5b. +> +>
+> +>
+> ⛔ Files ignored due to path filters (2) +> +> * `bun.lock` is excluded by `!**/*.lock` +> * `src-tauri/Cargo.lock` is excluded by `!**/*.lock` +> +>
+> +>
+> 📒 Files selected for processing (119) +> +> * `.github/workflows/ci.yml` +> * `.github/workflows/release.yml` +> * `biome.jsonc` +> * `docs/ui-performance-baseline.md` +> * `package.json` +> * `scripts/benchmark-result-batching.ts` +> * `scripts/benchmark-ui-filter.ts` +> * `scripts/patch-updater-manifest.sh` +> * `src-tauri/Cargo.toml` +> * `src-tauri/examples/backend_bench.rs` +> * `src-tauri/src/commands/history.rs` +> * `src-tauri/src/commands/mod.rs` +> * `src-tauri/src/commands/playlist.rs` +> * `src-tauri/src/commands/recent.rs` +> * `src-tauri/src/commands/saved.rs` +> * `src-tauri/src/commands/scan.rs` +> * `src-tauri/src/commands/server_test.rs` +> * `src-tauri/src/commands/settings.rs` +> * `src-tauri/src/commands/updater.rs` +> * `src-tauri/src/engine/cast_proxy.rs` +> * `src-tauri/src/engine/checker.rs` +> * `src-tauri/src/engine/chromecast.rs` +> * `src-tauri/src/engine/ffmpeg.rs` +> * `src-tauri/src/engine/macos_dmg_update.rs` +> * `src-tauri/src/engine/mod.rs` +> * `src-tauri/src/engine/parser.rs` +> * `src-tauri/src/engine/playlist_score.rs` +> * `src-tauri/src/engine/proxy_common.rs` +> * `src-tauri/src/engine/resume.rs` +> * `src-tauri/src/engine/stream_proxy.rs` +> * `src-tauri/src/engine/xtream.rs` +> * `src-tauri/src/lib.rs` +> * `src-tauri/src/models/scan.rs` +> * `src-tauri/src/models/settings.rs` +> * `src-tauri/src/state.rs` +> * `src-tauri/tauri.conf.json` +> * `src-tauri/tauri.conf.release.json` +> * `src-tauri/tests/scan_pipeline_integration.rs` +> * `src/App.tsx` +> * `src/SettingsWindow.tsx` +> * `src/components/AppBanners.tsx` +> * `src/components/CastMenu.tsx` +> * `src/components/ChannelRow.tsx` +> * `src/components/ChannelTable.tsx` +> * `src/components/ErrorBoundary.tsx` +> * `src/components/ExportMenu.tsx` +> * `src/components/FilterBar.tsx` +> * `src/components/HistoryPanel.tsx` +> * `src/components/KeyboardShortcutsDialog.tsx` +> * `src/components/LogWindowContent.tsx` +> * `src/components/OpenSourceDialog.tsx` +> * `src/components/PasswordField.tsx` +> * `src/components/PlaylistReportPanel.tsx` +> * `src/components/ProgressBar.tsx` +> * `src/components/SFSymbols.tsx` +> * `src/components/SavedPlaylistEditorDialog.tsx` +> * `src/components/SavedPlaylistsDialog.tsx` +> * `src/components/SettingsPanel.tsx` +> * `src/components/StartScreen.tsx` +> * `src/components/StatsPanel.tsx` +> * `src/components/StatusBadge.tsx` +> * `src/components/StreamPlayer.tsx` +> * `src/components/ThumbnailPanel.tsx` +> * `src/components/Toolbar.tsx` +> * `src/hooks/useChromecast.ts` +> * `src/hooks/useMenuEventBridge.ts` +> * `src/hooks/usePlaylistSources.ts` +> * `src/hooks/useScan.helpers.ts` +> * `src/hooks/useScan.ts` +> * `src/hooks/useStreamPlayer.ts` +> * `src/hooks/useUpdateCheck.ts` +> * `src/index.css` +> * `src/lib/cast.ts` +> * `src/lib/channelResults.ts` +> * `src/lib/duplicates.ts` +> * `src/lib/errors.ts` +> * `src/lib/extinf.ts` +> * `src/lib/filters.ts` +> * `src/lib/format.ts` +> * `src/lib/haptics.ts` +> * `src/lib/languageDistribution.ts` +> * `src/lib/logger.ts` +> * `src/lib/logoCache.ts` +> * `src/lib/perf.ts` +> * `src/lib/playback.ts` +> * `src/lib/playlistReportVisibility.ts` +> * `src/lib/recentPlaylists.ts` +> * `src/lib/runtimeMonitor.ts` +> * `src/lib/savedPlaylists.ts` +> * `src/lib/scanState.ts` +> * `src/lib/shortcuts.ts` +> * `src/lib/sourceFilter.ts` +> * `src/lib/tableColumns.ts` +> * `src/lib/tauri.ts` +> * `src/lib/thumbnailState.ts` +> * `src/lib/types.ts` +> * `src/lib/updateState.ts` +> * `src/lib/workerSupport.ts` +> * `src/main.tsx` +> * `src/store/index.ts` +> * `src/store/slices/scanSlice.ts` +> * `src/store/slices/settingsSlice.ts` +> * `src/store/slices/uiSlice.ts` +> * `src/store/store.ts` +> * `src/store/types.ts` +> * `tests/channelResults.test.ts` +> * `tests/duplicates.test.ts` +> * `tests/errors.test.ts` +> * `tests/exportScope.test.ts` +> * `tests/filters.logic.test.ts` +> * `tests/format.test.ts` +> * `tests/playlistReportVisibility.test.ts` +> * `tests/savedPlaylists.test.ts` +> * `tests/scanErrorEvents.test.ts` +> * `tests/sourceFilter.test.ts` +> * `tests/tableColumns.test.ts` +> * `tests/updateState.test.ts` +> * `tests/useScan.helpers.test.ts` +> * `tests/useStreamPlayer.test.ts` +> +>
+> +> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file. + + + + + +
+✨ Finishing Touches + +
+🧪 Generate unit tests (beta) + +- [ ] Create PR with unit tests +- [ ] Commit unit tests in branch `feat/auto-updater-and-quality-gates` + +
+ +
+ + + + +--- + +Thanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=kristofferR/IPTVChecker&utm_content=214)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. + +
+❤️ Share + +- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai) +- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai) +- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai) +- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code) + +
+ + +Comment `@coderabbitai help` to get the list of available commands. + + diff --git a/internal/engine/completion.go b/internal/engine/completion.go index 946d2fb..6a4a629 100644 --- a/internal/engine/completion.go +++ b/internal/engine/completion.go @@ -48,7 +48,7 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { if r.FiredAt != nil { cutoff = r.FiredAt.UTC() } - summaryOnly := SummaryOnlyPlan(obs, p) + primaryUnavailable := PrimaryReviewUnavailable(obs, p, r.Head) // Dynamic co-reviewer gate: a fired round that a co-bot participates in — // reviewing the PR on its own (AutoActive) or acting this round @@ -58,12 +58,12 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { // finish this round); configured-required bots are left to the wait // deadline as before. // - // A summary-only round need not have fired to engage this gate: CodeRabbit - // cannot review, so crq resolves the round without ever posting its command - // and FiredAt stays nil while the co-reviewers do all of the work. The gate + // Such a round need not have fired to engage this gate: CodeRabbit cannot + // review, so crq resolves the round without ever posting its command and + // FiredAt stays nil while the co-reviewers do all of the work. The gate // still keys on observed participation, never on mere configuration — an // enabled-but-absent co-bot must not wedge a round nothing else will finish. - if r.FiredAt != nil || summaryOnly { + if r.FiredAt != nil || primaryUnavailable { for _, cp := range p.coReviewers() { if requiredBot(p, cp.Login) { continue @@ -145,14 +145,14 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { } } - // 3b. Summary-only plan: the walkthrough IS CodeRabbit's entire output for - // this PR — no review object will ever follow, however often it is asked. + // 3b. No primary review is coming for this head — a summary-only plan whose + // walkthrough IS the entire output, or an explicit "Review skipped". // Waiting for one wedges the round until the in-flight timeout re-fires it, // forever. Count the bot as having delivered once every gating co-reviewer // is satisfied, exactly as the clean-review summary does above; unlike that // path it needs no fire, since the round can resolve without crq ever // posting the review command. - if summaryOnly && needsBotReview(reviewedBy, p.Bot) && + if primaryUnavailable && needsBotReview(reviewedBy, p.Bot) && coReviewersSatisfied(r, obs, p, cutoff, reviewedBy) { markReviewed(reviewedBy, p.Bot) } diff --git a/internal/engine/coreview_test.go b/internal/engine/coreview_test.go index 54ce466..e8bc17d 100644 --- a/internal/engine/coreview_test.go +++ b/internal/engine/coreview_test.go @@ -358,3 +358,97 @@ func TestSummaryOnlyPlanRunsCoReviewersAlone(t *testing.T) { } }) } + +// skippedEvent builds the classified "Review skipped" notice for a head. +func skippedEvent(sha string, at time.Time) dialect.BotEvent { + return dialect.BotEvent{ + Kind: dialect.EvSkipped, Bot: "coderabbitai[bot]", SHA: sha, + CommentID: 7000, CreatedAt: at, UpdatedAt: at, + } +} + +// TestReviewSkippedNeverBlocksTheAccount is the load-bearing rule behind the +// "Review skipped" support: the notice ships WITH CodeRabbit's rate-limit +// marker, but it is a refusal of one head, not a timed account block. If +// Progress ever treats it as a rate limit it fabricates a window that never +// clears — crq re-fires the same oversized PR forever and, far worse, parks the +// ACCOUNT-wide quota so every other PR in the fleet stalls behind one bad PR. +func TestReviewSkippedNeverBlocksTheAccount(t *testing.T) { + fired := time.Date(2026, 7, 24, 20, 0, 0, 0, time.UTC) + head := "56150a042" + round := state.Round{Repo: "o/r", PR: 214, Head: head, Phase: state.PhaseFired, FiredAt: &fired} + p := Policy{Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]"}} + obs := Observation{Head: head, Open: true, + Events: []dialect.BotEvent{skippedEvent("56150a0423a243224b03f355c3a3ba6941011b5b", fired.Add(time.Minute))}} + + tr := Progress(round, state.AccountQuota{}, obs, fired.Add(2*time.Minute), p) + if tr.Blocked != nil { + t.Fatalf("a skipped review must never record an account block, got %+v", tr.Blocked) + } + if tr.Outcome == OutRetry { + t.Fatalf("a skipped review must not park the round for retry — waiting changes nothing: %+v", tr) + } + // With no co-reviewer left outstanding the round is finished, not wedged. + if tr.Outcome != OutComplete { + t.Fatalf("outcome = %v, want OutComplete", tr.Outcome) + } +} + +// TestReviewSkippedBindsToItsHead: the refusal is per-head. Splitting the PR +// produces a new head CodeRabbit may well review, so a skip naming an older +// commit must not suppress the new one — otherwise fixing the problem would +// permanently disable the primary reviewer for that PR. +func TestReviewSkippedBindsToItsHead(t *testing.T) { + p := Policy{Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]"}} + at := time.Date(2026, 7, 24, 20, 0, 0, 0, time.UTC) + skipped := skippedEvent("56150a0423a243224b03f355c3a3ba6941011b5b", at) + + sameHead := Observation{Head: "56150a042", Open: true, Events: []dialect.BotEvent{skipped}} + if !PrimaryReviewUnavailable(sameHead, p, sameHead.Head) { + t.Fatal("the skipped head must read as unreviewable") + } + newHead := Observation{Head: "abcdef123", Open: true, Events: []dialect.BotEvent{skipped}} + if PrimaryReviewUnavailable(newHead, p, newHead.Head) { + t.Fatal("a skip of an older head must not suppress CodeRabbit on the reworked head") + } + // A SHA-less skip is read conservatively: it binds to whatever head is observed. + noSHA := Observation{Head: "abcdef123", Open: true, Events: []dialect.BotEvent{skippedEvent("", at)}} + if !PrimaryReviewUnavailable(noSHA, p, noSHA.Head) { + t.Fatal("a SHA-less skip must bind to the observed head") + } + // Another bot's skip is not the configured reviewer's problem. + other := skippedEvent("56150a0423a243224b03f355c3a3ba6941011b5b", at) + other.Bot = "someone-else[bot]" + foreign := Observation{Head: "56150a042", Open: true, Events: []dialect.BotEvent{other}} + if PrimaryReviewUnavailable(foreign, p, foreign.Head) { + t.Fatal("only the configured bot's skip counts") + } +} + +// TestReviewSkippedRunsCoReviewersInsteadOfFiring: the round must resolve on the +// co-reviewers rather than spending a fire on a review that cannot happen. +func TestReviewSkippedRunsCoReviewersInsteadOfFiring(t *testing.T) { + now := time.Date(2026, 7, 24, 20, 0, 0, 0, time.UTC) + head := "56150a042" + queued := state.Round{Repo: "o/r", PR: 214, Head: head, Phase: state.PhaseQueued} + p := Policy{Bot: "coderabbitai[bot]", + RequiredBots: []string{"coderabbitai[bot]", dialect.CodexBotLogin}, + CoReviewers: []CoReviewerPolicy{{Login: dialect.CodexBotLogin, Command: "@codex review", Trigger: TriggerAlways}}} + obs := Observation{Head: head, Open: true, + Events: []dialect.BotEvent{skippedEvent("56150a0423a243224b03f355c3a3ba6941011b5b", now)}} + + // Even with a free slot and no block, crq must not fire CodeRabbit. + d := DecideFire(Global{SlotFree: true}, queued, obs, now, p) + if d.Verdict == FirePost || d.Verdict == FireAdopt { + t.Fatalf("crq must never fire CodeRabbit at a head it already skipped: %+v", d) + } + if d.Verdict != FireCoOnly || len(d.PostCo) != 1 { + t.Fatalf("verdict = %v PostCo = %v, want FireCoOnly asking codex", d.Verdict, d.PostCo) + } + // And it must still resolve while the account is blocked — the block has no + // authority over a round that will never spend the quota. + blocked := now.Add(time.Hour) + if d := DecideFire(Global{SlotFree: true, BlockedUntil: &blocked}, queued, obs, now, p); d.Verdict != FireCoOnly { + t.Fatalf("a blocked account must not delay a skipped round: %+v", d) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index f081b70..78f58d8 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -167,6 +167,33 @@ func SummaryOnlyPlan(obs Observation, p Policy) bool { return false } +// ReviewSkippedHead reports whether the configured bot explicitly SKIPPED this +// head — too many files, no usage credits, an unsupported diff. Unlike a rate +// limit there is no window after which it clears: re-firing the same head buys +// the same refusal forever. Unlike a summary-only plan it is per-head, so it +// binds to the SHA the notice names and a reworked head (a split PR) fires +// normally again. A notice naming no SHA binds to whatever head is observed, +// which is the conservative reading for an in-place-edited comment. +func ReviewSkippedHead(obs Observation, p Policy, head string) bool { + for _, ev := range obs.Events { + if ev.Kind != dialect.EvSkipped || !sameBot(ev.Bot, p.Bot) { + continue + } + if ev.SHA == "" || head == "" || dialect.SHAPrefixMatch(ev.SHA, head) { + return true + } + } + return false +} + +// PrimaryReviewUnavailable reports that the configured bot will not produce a +// review for this head however long crq waits — its plan only summarizes, or it +// skipped this head outright. Both collapse to the same decision: stop waiting +// on a review that cannot arrive and let the co-reviewers resolve the round. +func PrimaryReviewUnavailable(obs Observation, p Policy, head string) bool { + return SummaryOnlyPlan(obs, p) || ReviewSkippedHead(obs, p, head) +} + // notBefore mirrors v2: GitHub timestamps are second-granular, so a bot // completion in the same second as the trigger must still count. func notBefore(t, baseline time.Time) bool { return !t.Before(baseline) } diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 7eb61c9..acd17f5 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -105,13 +105,13 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic } return FireDecision{Verdict: FireNo, Reason: reason} } - // The configured bot's plan produces a walkthrough only — no review of this - // head is coming, ever. Resolve the round on the co-reviewers alone, before - // the slot / account-quota / pacing gates: none of them should delay (or be - // spent on) a review that cannot happen. coAwareDedupe posts the triggers - // crq may, waits bounded for a co-bot that answers on its own, and dedupes - // only when there is no co-reviewer to ask. - if SummaryOnlyPlan(obs, p) { + // No review of this head is coming from the configured bot, ever — its plan + // produces a walkthrough only, or it skipped this head outright. Resolve the + // round on the co-reviewers alone, before the slot / account-quota / pacing + // gates: none of them should delay (or be spent on) a review that cannot + // happen. coAwareDedupe posts the triggers crq may, waits bounded for a + // co-bot that answers on its own, and dedupes when there is none to ask. + if PrimaryReviewUnavailable(obs, p, obs.Head) { return coAwareDedupe(r, obs, p, now, true) } reviewedHead := false @@ -243,9 +243,10 @@ func newestCommand(commands []CommandSeen) *CommandSeen { return newest } -// coAwareDedupe resolves what to do when CodeRabbit has delivered everything it -// is ever going to for this head — either it already reviewed the head, or -// (summaryOnly) its plan will never produce a review at all. +// coAwareDedupe resolves what to do when the primary bot has delivered +// everything it is ever going to for this head — either it already reviewed the +// head, or (primaryUnavailable) no review is coming: its plan only summarizes, +// or it skipped this head outright. // If no gating co-reviewer is still outstanding, the round is genuinely done // (FireDedupe). If a required-or-auto-active co-bot has no review of this head // yet, the round is not done: post the triggers crq may (FireCoOnly). When crq @@ -260,15 +261,16 @@ func newestCommand(commands []CommandSeen) *CommandSeen { // Completion counts the existing CodeRabbit review, so a FireCoOnly round // waits on the co-reviewers alone. // -// Under summaryOnly every ENABLED co-reviewer gates, not just the required or -// auto-active ones: they are the round's only reviewers, so a co-bot crq would -// otherwise treat as optional is the difference between a review and none. -func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time, summaryOnly bool) FireDecision { +// Under primaryUnavailable every ENABLED co-reviewer gates, not just the +// required or auto-active ones: they are the round's only reviewers, so a +// co-bot crq would otherwise treat as optional is the difference between a +// review and none. +func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time, primaryUnavailable bool) FireDecision { var post []string wait := false for _, cp := range p.coReviewers() { co := obs.co(cp.Login) - gates := requiredBot(p, cp.Login) || co.AutoActive || summaryOnly + gates := requiredBot(p, cp.Login) || co.AutoActive || primaryUnavailable if !gates || coReviewedHead(obs, cp.Login) { continue } @@ -280,9 +282,9 @@ func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time, summ wait = true } } - delivered := "coderabbit reviewed head" - if summaryOnly { - delivered = "coderabbit plan is summary-only" + delivered := "primary reviewed head" + if primaryUnavailable { + delivered = "primary review unavailable for this head" } if len(post) > 0 { return FireDecision{Verdict: FireCoOnly, Reason: delivered + "; co-review still required", PostCo: post} @@ -290,7 +292,7 @@ func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time, summ if wait { return FireDecision{Verdict: FireCoReviewWait, Reason: "awaiting co-review"} } - if summaryOnly { + if primaryUnavailable { return FireDecision{Verdict: FireDedupe, Reason: delivered + "; no co-review outstanding"} } return FireDecision{Verdict: FireDedupe, Reason: "bot already reviewed head"} diff --git a/llms.txt b/llms.txt index 691c9e3..efaf150 100644 --- a/llms.txt +++ b/llms.txt @@ -106,6 +106,12 @@ in: NN minutes") and Codex demonstrably reviews the PR, the loop degrades to Cod instead of waiting the window out, and the pump posts the co-reviewer triggers for blocked rounds while keeping the CodeRabbit review queued. Disable with `CRQ_RL_CO_DEGRADE=0` (default on). +Skipped reviews: CodeRabbit may refuse a head outright (`Review skipped` — too many files, no usage +credits). That notice embeds the rate-limit marker but is NOT a timed block: nothing clears by +waiting. crq treats it as "no primary review is coming for this head", converges on the co-reviewers, +never records an account block, and returns the skip as a `major` finding telling you to narrow the +PR. It binds to the SHA it names, so a split PR is reviewed normally. + Summary-only plans: on a private repo under a Free CodeRabbit plan the bot posts a walkthrough and never a line-by-line review ("Summarized by CodeRabbit Free … upgrade … for a comprehensive line-by-line review"). crq reads that notice and runs the co-reviewers alone there — no From b469b2f0fbd55f5d68f3cee9921f4cc1952c8ddb Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 23:42:58 +0200 Subject: [PATCH 14/31] Surface a skipped review past the rate-limit guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of skipped-review support never reached its own code: the notice embeds CodeRabbit's rate-limit marker, so the "an account-quota notice is never a finding" guard dropped it first. Verified live on iptvchecker#214 — the round reported converged with coderabbitai marked reviewed and zero findings, i.e. a silently absent primary reviewer, which is worse than a loud one. The skip check now runs before that guard and the round reports the work instead: one major finding carrying CodeRabbit's own reason ("Too many files!"), which also keeps the round from reporting a false pass. --- internal/crq/feedback.go | 43 +++++++++++++------------- internal/crq/feedback_test.go | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 6be838c..330855e 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -233,6 +233,28 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe if !dialect.InBots(extractBots, comment.User.Login) { continue } + if s.cr.IsReviewSkipped(comment.Body) && s.isConfiguredBot(comment.User.Login) { + // Checked BEFORE the rate-limit guard below: the skip notice embeds + // the rate-limit marker, so that guard would drop it and the round + // would converge with a silently absent primary reviewer — worse than + // a loud one. The review did not happen and never will for this head, + // so surface it as work: narrow the PR to get a review. + reason := dialect.ReviewSkippedReason(comment.Body) + if reason == "" { + reason = "CodeRabbit skipped the review for this head." + } + report.Findings = append(report.Findings, dialect.Finding{ + Bot: comment.User.Login, + Severity: "major", + Title: "CodeRabbit skipped this review — narrow the PR to get one: " + reason, + Body: strings.TrimSpace(dialect.CompactReviewBody(comment.Body)), + CommentID: comment.ID, + URL: comment.URL, + Source: "issue_comment", + CreatedAt: comment.CreatedAt, + }) + continue + } if s.cr.IsRateLimited(comment.Body) { continue // an account-quota notice is never a finding } @@ -244,27 +266,6 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe continue } if s.isConfiguredBot(comment.User.Login) { - // A "Review skipped" notice is the one configured-bot comment that IS - // actionable: the review did not happen and never will for this head, - // so the round converges on the co-reviewers alone. Without surfacing - // it the agent silently loses its primary reviewer and never learns - // the PR needs narrowing. - if s.cr.IsReviewSkipped(comment.Body) { - reason := dialect.ReviewSkippedReason(comment.Body) - if reason == "" { - reason = "CodeRabbit skipped the review for this head." - } - report.Findings = append(report.Findings, dialect.Finding{ - Bot: comment.User.Login, - Severity: "major", - Title: "CodeRabbit skipped this review — narrow the PR to get one: " + reason, - Body: strings.TrimSpace(comment.Body), - CommentID: comment.ID, - URL: comment.URL, - Source: "issue_comment", - CreatedAt: comment.CreatedAt, - }) - } continue } if dialect.IsNonActionableText(comment.Body) { diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index 40e40d9..5046d02 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -1968,3 +1968,61 @@ func TestThreadRebuttalSurfacesContestedResolvedThreads(t *testing.T) { t.Fatalf("unresolved threads are handled by threadFindings, got %#v", got) } } + +// TestFeedbackSurfacesSkippedReviewDespiteRateLimitMarker pins an ordering trap +// that bit the first implementation: CodeRabbit's "Review skipped" notice embeds +// its rate-limit marker, and the "an account-quota notice is never a finding" +// guard therefore swallowed it. The round then converged with the primary +// reviewer marked done and NOTHING telling the agent the review never happened — +// a silently absent reviewer, which is worse than a loud one. +func TestFeedbackSurfacesSkippedReviewDespiteRateLimitMarker(t *testing.T) { + cfg := Config{ + Bot: "coderabbitai[bot]", + RequiredBots: []string{"coderabbitai[bot]"}, + FeedbackBots: []string{"coderabbitai[bot]"}, + RateLimitMarker: "rate limited by coderabbit.ai", + CalibrationMarker: "auto-generated reply by CodeRabbit", + } + body := corpusMessage(t, "coderabbit/review-skipped-too-many-files.md") + probe := dialect.CodeRabbit{RateLimitMarker: cfg.RateLimitMarker, CalibrationMarker: cfg.CalibrationMarker} + if !probe.IsRateLimited(body) { + t.Fatal("precondition: the skip notice must still carry the rate-limit marker") + } + + gh := newFakeGitHub() + sha := "56150a0423a243224b03f355c3a3ba6941011b5b" + pull := ghapi.Pull{State: "open"} + pull.Head.SHA = sha + gh.pulls[fakeKey("o/repo", 214)] = pull + skip := ghapi.IssueComment{ID: 5074197614, Body: body, + CreatedAt: time.Now().UTC().Add(-time.Hour), UpdatedAt: time.Now().UTC().Add(-time.Minute)} + skip.User.Login = "coderabbitai[bot]" + gh.comments[fakeKey("o/repo", 214)] = []ghapi.IssueComment{skip} + + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + + rep, err := svc.Feedback(context.Background(), "o/repo", 214) + if err != nil { + t.Fatal(err) + } + var skipFinding *dialect.Finding + for i := range rep.Findings { + if rep.Findings[i].CommentID == 5074197614 { + skipFinding = &rep.Findings[i] + } + } + if skipFinding == nil { + t.Fatalf("the skipped-review notice must surface as actionable work, got %#v", rep.Findings) + } + if skipFinding.Severity != "major" { + t.Errorf("severity = %q, want major", skipFinding.Severity) + } + if !strings.Contains(skipFinding.Title, "Too many files") { + t.Errorf("the finding must carry CodeRabbit's own reason, got %q", skipFinding.Title) + } + // Findings block convergence, so the loop reports work rather than a false pass. + if rep.Converged { + t.Error("a skipped review with an open finding must not report converged") + } +} From d6bd153078169618ed810117673d5c30bda2cc2d Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 24 Jul 2026 23:53:56 +0200 Subject: [PATCH 15/31] Stop reporting the account block on rounds it cannot apply to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents driving krisHQ PRs kept reasoning about CodeRabbit's rate limit on a repo CodeRabbit never reviews, concluding "hold the head until CodeRabbit lands" — which on those PRs means never push and never converge. They were reading it from crq. Even with the primary correctly marked delivered, the loop still consulted the account-wide block for the round: it extended the wait deadline, slowed the poll to the block window (up to 5 minutes, on rounds whose co-reviewers answer in minutes), and logged "account blocked until X; waiting". None of that can apply to a round that never spends the quota. The loop now leaves the block out of it entirely when no primary review is coming, and feedback reports primary_review_unavailable plus a reason naming the cause, so an agent can see the primary is out of the picture instead of inferring a wait from a pending reviewer. The held-head message says the same in words: only co-reviewers are outstanding. Verified against the live krisHQ#1022, which reports the head-bound skip. --- README.md | 7 ++++ internal/crq/feedback.go | 30 +++++++++++++-- internal/crq/feedback_test.go | 69 +++++++++++++++++++++++++++++++++++ internal/engine/engine.go | 18 ++++++++- llms.txt | 7 ++++ 5 files changed, 127 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9027380..ea1cc10 100644 --- a/README.md +++ b/README.md @@ -505,6 +505,13 @@ co-reviewers, and the skip is surfaced as a `major` finding so the PR actually g refusal binds to the SHA the notice names, so splitting the PR yields a head crq fires normally again. +Both cases set `primary_review_unavailable` in `crq feedback --json`, with a +`primary_review_unavailable_reason` naming which. That flag is load-bearing for agents: without it +they see a pending reviewer, reason about the account-quota window, and "hold the head until +CodeRabbit lands" — on a repo where it never lands, so the PR never pushes and never converges. crq +also stops consulting the account block for such a round: no deadline extension, no poll slowdown to +the block window, and no log line about a limit that cannot apply to it. + **Bot-specific quirks crq handles for you.** Macroscope never resolves a thread; it *edits* its own comment to append `✅ Resolved in ` (or `No longer relevant as of `), so crq treats that edit as the resolution and drops the finding. Bugbot re-reports the same bug in a fresh thread after diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 330855e..f315857 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -36,6 +36,13 @@ type FeedbackReport struct { // normalized login. Informational only — the Verdict never gates // convergence or changes exit codes. CoReviewers map[string]CoReviewerStatus `json:"co_reviewers,omitempty"` + // PrimaryUnavailable reports that the configured reviewer will not review + // this head at all (its plan only summarizes, or it skipped the head), with + // PrimaryUnavailableReason saying which. The co-reviewers alone resolve the + // round: do NOT hold the head for the primary, and ignore the account-quota + // window entirely — it cannot apply to a round that never spends quota. + PrimaryUnavailable bool `json:"primary_review_unavailable,omitempty"` + PrimaryUnavailableReason string `json:"primary_review_unavailable_reason,omitempty"` } // CoReviewerStatus is one co-reviewer's observed state for the current head. @@ -100,6 +107,10 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe completion := engine.Completion(completionRound, obs.eng, s.policy()) report.ReviewedBy = completion.ReviewedBy report.CoReviewers = coReviewerStatuses(s.cfg, obs.eng) + if why := engine.PrimaryUnavailableReason(obs.eng, s.policy(), head); why != "" { + report.PrimaryUnavailable = true + report.PrimaryUnavailableReason = s.cfg.Bot + " " + why + } // extractBots is the broader set whose findings we surface — a superset that // includes Codex — so a bot that reviews without being required (and would @@ -449,6 +460,12 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport // With ANOTHER required bot still pending, the hold-head branch // below applies instead: pushing would restart its checks. report.Reason = "codex findings during a coderabbit rate-limit window; fix, push, and loop again — the coderabbit review stays queued and fires when the window opens" + } else if report.PrimaryUnavailable { + // Only co-reviewers are outstanding: naming them (and the fact that + // the primary is out of the picture) stops the agent inferring it + // must wait on the primary or on an account-quota window. + report.Reason = "hold current head for the remaining co-reviewers only — " + + report.PrimaryUnavailableReason + ", so nothing here waits on it or on the account quota" } else { // A required reviewer is still pending (e.g. Codex posted a finding // before CodeRabbit reviewed). Return the findings to work on, but leave @@ -497,9 +514,16 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport poll := s.cfg.PollInterval var blockedUntil *time.Time now := s.clock() - if st, _, lerr := s.store.Load(ctx); lerr == nil { - if until, ok := st.AccountBlockedUntil(repo, pr, head, now); ok { - blockedUntil = &until + // A round the primary will never review neither spends the account + // quota nor waits on it. Consulting the block would extend this round's + // deadline, slow its poll to the block window, and narrate a limit that + // has nothing to do with it — while the co-reviewers it IS waiting for + // answer in minutes. Leave blockedUntil nil so none of that applies. + if !report.PrimaryUnavailable { + if st, _, lerr := s.store.Load(ctx); lerr == nil { + if until, ok := st.AccountBlockedUntil(repo, pr, head, now); ok { + blockedUntil = &until + } } } // A degraded round waits for Codex, not for the window: keep the normal diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index 5046d02..1b79498 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -2026,3 +2026,72 @@ func TestFeedbackSurfacesSkippedReviewDespiteRateLimitMarker(t *testing.T) { t.Error("a skipped review with an open finding must not report converged") } } + +// TestLoopIgnoresAccountBlockWhenPrimaryWillNotReview pins the rule that stopped +// agents reasoning about CodeRabbit on repos it never reviews. On a summary-only +// round the account block is meaningless: it must not extend the wait deadline, +// must not slow the poll to the block window, and must not be narrated. Agents +// were reading that narration and concluding "hold the head until CodeRabbit +// lands" — on a repo where CodeRabbit never lands, i.e. never push, never +// converge. +func TestLoopIgnoresAccountBlockWhenPrimaryWillNotReview(t *testing.T) { + now := time.Now().UTC() + blocked := now.Add(42 * time.Minute) + deadline := now.Add(5 * time.Minute) + + // The block must not buy a summary-only round any extra deadline... + if got := extendDeadlineForBlock(deadline, nil, now, 20*time.Minute); !got.Equal(deadline) { + t.Fatalf("a nil block must leave the deadline alone, got %v", got) + } + // ...whereas a round that genuinely awaits CodeRabbit still gets it. + if got := extendDeadlineForBlock(deadline, &blocked, now, 20*time.Minute); !got.After(deadline) { + t.Fatalf("a real block must still extend a round that waits on it, got %v", got) + } + // And the poll must stay fast rather than sliding to the block window. + if got := blockedPollInterval(blocked, now, time.Second); got < time.Minute { + t.Fatalf("precondition: a real block slows the poll, got %v", got) + } + + cfg := Config{ + Bot: "coderabbitai[bot]", + RequiredBots: []string{"coderabbitai[bot]"}, + FeedbackBots: []string{"coderabbitai[bot]"}, + RateLimitMarker: "rate limited by coderabbit.ai", + CalibrationMarker: "auto-generated reply by CodeRabbit", + } + gh := newFakeGitHub() + sha := "abcdef1234567890" + pull := ghapi.Pull{State: "open"} + pull.Head.SHA = sha + gh.pulls[fakeKey("o/private", 1022)] = pull + walkthrough := ghapi.IssueComment{ID: 900, + Body: corpusMessage(t, "coderabbit/summary-only-free-plan.md"), + CreatedAt: now.Add(-time.Hour), UpdatedAt: now.Add(-time.Hour)} + walkthrough.User.Login = "coderabbitai[bot]" + gh.comments[fakeKey("o/private", 1022)] = []ghapi.IssueComment{walkthrough} + + store := NewMemoryStore(cfg) + if _, err := store.Update(context.Background(), func(st *State) error { + st.Account.BlockedUntil = &blocked + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + + rep, err := svc.Feedback(context.Background(), "o/private", 1022) + if err != nil { + t.Fatal(err) + } + if !rep.PrimaryUnavailable { + t.Fatal("a summary-only head must report primary_review_unavailable so agents stop waiting on it") + } + if !strings.Contains(rep.PrimaryUnavailableReason, "summary") { + t.Errorf("the reason must say why, got %q", rep.PrimaryUnavailableReason) + } + // The primary counts as delivered, so the agent is never told to hold the + // head for it while an unrelated account block ticks down. + if !rep.ReviewedBy["coderabbitai[bot]"] { + t.Fatalf("the primary must read as delivered, got %#v", rep.ReviewedBy) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 78f58d8..fee4786 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -191,7 +191,23 @@ func ReviewSkippedHead(obs Observation, p Policy, head string) bool { // skipped this head outright. Both collapse to the same decision: stop waiting // on a review that cannot arrive and let the co-reviewers resolve the round. func PrimaryReviewUnavailable(obs Observation, p Policy, head string) bool { - return SummaryOnlyPlan(obs, p) || ReviewSkippedHead(obs, p, head) + return PrimaryUnavailableReason(obs, p, head) != "" +} + +// PrimaryUnavailableReason names WHY no primary review is coming, for surfacing +// to agents. Without it an agent sees only a pending reviewer and reasons about +// account quota that cannot apply — waiting, and holding the head, for a review +// that will never arrive. Empty when a review is still expected. +func PrimaryUnavailableReason(obs Observation, p Policy, head string) string { + // The skip is the more specific and more actionable of the two: it names a + // fixable cause (narrow the PR) and binds to this head only. + if ReviewSkippedHead(obs, p, head) { + return "skipped the review for this head" + } + if SummaryOnlyPlan(obs, p) { + return "plan produces a summary only, never a review" + } + return "" } // notBefore mirrors v2: GitHub timestamps are second-granular, so a bot diff --git a/llms.txt b/llms.txt index efaf150..c6d3031 100644 --- a/llms.txt +++ b/llms.txt @@ -106,6 +106,13 @@ in: NN minutes") and Codex demonstrably reviews the PR, the loop degrades to Cod instead of waiting the window out, and the pump posts the co-reviewer triggers for blocked rounds while keeping the CodeRabbit review queued. Disable with `CRQ_RL_CO_DEGRADE=0` (default on). +`.primary_review_unavailable` (with `.primary_review_unavailable_reason`) means the configured +reviewer will NOT review this head at all — its plan only summarizes, or it skipped the head. When +true: do not hold the head for it, do not wait for it, and IGNORE the account-quota window entirely +(`crq status` may show the account blocked; that block is fleet state and cannot apply to a round +that never spends quota). The round is resolved by the co-reviewers alone, so treat +`.reviewed_by` for the primary as already delivered and act on `.findings` immediately. + Skipped reviews: CodeRabbit may refuse a head outright (`Review skipped` — too many files, no usage credits). That notice embeds the rate-limit marker but is NOT a timed block: nothing clears by waiting. crq treats it as "no primary review is coming for this head", converges on the co-reviewers, From 85196352b7d34983182ca0cd284b468aced95aab Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 00:00:14 +0200 Subject: [PATCH 16/31] Address Codex review: check evidence, head binding, and settled findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight findings from the review of this PR, all real: Check runs said too little. The verdict vocabulary could not express "this bot's check, but not its review", so a Bugbot run that crashed (status completed, conclusion failure/cancelled/timed_out) and Macroscope's Approvability and repo-custom checks all read as finished reviews. Either let a round converge with no findings while the real review was still running — or had never run. Added CheckAuxiliary and CheckFailed: both are participation, neither is completion evidence. A failed run also no longer suppresses a self-heal trigger, since re-asking is exactly the right response to it. coAwareDedupe completed rounds out from under an in-flight co-review: with a check already running crq cannot post, AutoActive is still false, and the round deduped while findings were pending. ActiveThisRound now holds it open. The skipped-review finding ignored which head was skipped. Loop treats findings as blocking before it enqueues, so the notice from an oversized head survived onto the narrowed replacement and stopped it from ever being submitted — the repair permanently disabled the reviewer it restored. Macroscope verdicts leaked across heads: between a push and its new Approvability comment the newest verdict describes the PREVIOUS head, but co_reviewers[].verdict is documented as current-head state. Two findings could never settle. The REST fallback ignored settled-marker edits, so a Macroscope finding it had already resolved stayed actionable on that path forever. And a Bugbot bug re-reported across threads only collapsed among findings emitted together — resolving the emitted thread promoted a sibling on the next poll, so one bug blocked convergence until every duplicate was resolved by hand. Stable ids settled in any thread now settle the whole family. --- internal/crq/feedback.go | 59 ++++++++++++++++++++++--- internal/crq/feedback_test.go | 78 +++++++++++++++++++++++++++++++-- internal/dialect/bugbot.go | 14 ++++-- internal/dialect/coreviewer.go | 28 +++++++++++- internal/dialect/golden_test.go | 51 +++++++++++++++++++-- internal/dialect/macroscope.go | 23 +++++++--- internal/engine/coreview.go | 15 +++++-- internal/engine/fire.go | 6 ++- 8 files changed, 247 insertions(+), 27 deletions(-) diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index f315857..940b74b 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -106,7 +106,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe } completion := engine.Completion(completionRound, obs.eng, s.policy()) report.ReviewedBy = completion.ReviewedBy - report.CoReviewers = coReviewerStatuses(s.cfg, obs.eng) + report.CoReviewers = coReviewerStatuses(s.cfg, obs.eng, anchorCutoff) if why := engine.PrimaryUnavailableReason(obs.eng, s.policy(), head); why != "" { report.PrimaryUnavailable = true report.PrimaryUnavailableReason = s.cfg.Bot + " " + why @@ -156,7 +156,25 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe } suppressPromptAt := map[string]bool{} + // Bugbot re-reports one BUGBOT_BUG_ID across several threads. Dedupe alone + // collapses only what is emitted together, so resolving the emitted thread + // simply promotes a sibling on the next poll and the "single" finding + // resurfaces until every duplicate is resolved by hand. Collect the stable + // ids settled in ANY thread first and suppress the whole family. + settledStableIDs := map[string]bool{} if threads, err := s.reviewThreads(ctx, repo, pr); err == nil { + for _, thread := range threads { + if !thread.IsResolved && !thread.IsOutdated { + continue + } + for _, c := range thread.Comments.Nodes { + if co, ok := dialect.CoReviewerByName(c.Author.Login); ok && co.FindingDedupeKey != nil { + if stable, ok := co.FindingDedupeKey(c.Body); ok { + settledStableIDs[dialect.NormalizeBotName(c.Author.Login)+"|"+stable] = true + } + } + } + } for _, thread := range threads { report.Findings = append(report.Findings, threadFindings(thread, extractBots)...) // A resolved/outdated inline thread emits no finding, but CodeRabbit's @@ -190,6 +208,14 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe if !dialect.InBots(extractBots, comment.User.Login) { continue } + // The GraphQL path drops findings the bot settled by EDITING its own + // comment (Macroscope's "✅ Resolved in "). This fallback must + // apply the same registry hook, or under it the resolved finding + // stays actionable indefinitely and convergence never comes. + if co, ok := dialect.CoReviewerByName(comment.User.Login); ok && + co.ResolvedInSHA != nil && co.ResolvedInSHA(comment.Body) != "" { + continue + } commit := dialect.ShortOID(firstNonEmpty(comment.CommitID, comment.OriginalCommitID)) if head != "" && commit != "" && commit != head { continue @@ -244,7 +270,8 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe if !dialect.InBots(extractBots, comment.User.Login) { continue } - if s.cr.IsReviewSkipped(comment.Body) && s.isConfiguredBot(comment.User.Login) { + if s.cr.IsReviewSkipped(comment.Body) && s.isConfiguredBot(comment.User.Login) && + skipAppliesToHead(comment.Body, head) { // Checked BEFORE the rate-limit guard below: the skip notice embeds // the rate-limit marker, so that guard would drop it and the round // would converge with a silently absent primary reviewer — worse than @@ -297,7 +324,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe }) } - report.Findings = dedupeFindings(report.Findings, suppressPromptAt) + report.Findings = dedupeFindings(report.Findings, suppressPromptAt, settledStableIDs) sort.Slice(report.Findings, func(i, j int) bool { if dialect.RankSeverity(report.Findings[i].Severity) != dialect.RankSeverity(report.Findings[j].Severity) { return dialect.RankSeverity(report.Findings[i].Severity) > dialect.RankSeverity(report.Findings[j].Severity) @@ -1110,7 +1137,7 @@ func promptSuppressKeys(thread reviewThread, bots map[string]struct{}) []string return keys } -func dedupeFindings(in []dialect.Finding, suppressPromptAt map[string]bool) []dialect.Finding { +func dedupeFindings(in []dialect.Finding, suppressPromptAt, settledStableIDs map[string]bool) []dialect.Finding { seen := map[string]bool{} structuredAtLocation := map[string]bool{} for _, finding := range in { @@ -1137,6 +1164,9 @@ func dedupeFindings(in []dialect.Finding, suppressPromptAt map[string]bool) []di // push must collapse to one finding. if co, ok := dialect.CoReviewerByName(finding.Bot); ok && co.FindingDedupeKey != nil { if stable, ok := co.FindingDedupeKey(finding.Body); ok { + if settledStableIDs[dialect.NormalizeBotName(finding.Bot)+"|"+stable] { + continue // settled in some thread — every sibling is settled + } key = dialect.NormalizeBotName(finding.Bot) + "|" + stable } } @@ -1151,10 +1181,23 @@ func dedupeFindings(in []dialect.Finding, suppressPromptAt map[string]bool) []di return out } +// skipAppliesToHead reports whether a "Review skipped" notice concerns the +// head under review. The refusal is per-head and narrowing the PR is the fix, +// so a skip of an EARLIER head must not keep surfacing: Loop treats findings as +// blocking before it enqueues, which would stop the replacement head from ever +// being submitted — the repair would permanently disable the reviewer. +func skipAppliesToHead(body, head string) bool { + skipped := dialect.ReviewSkippedHeadSHA(body) + if skipped == "" || head == "" { + return true // names no commit: read conservatively as current + } + return dialect.SHAPrefixMatch(skipped, head) +} + // coReviewerStatuses summarizes each enabled co-reviewer's observed state for // the current head: head review evidence, check-run state, and Macroscope's // approvability verdict. Informational only — nothing here gates convergence. -func coReviewerStatuses(cfg Config, obs engine.Observation) map[string]CoReviewerStatus { +func coReviewerStatuses(cfg Config, obs engine.Observation, since time.Time) map[string]CoReviewerStatus { if len(cfg.CoBots) == 0 { return nil } @@ -1191,6 +1234,12 @@ func coReviewerStatuses(cfg Config, obs engine.Observation) map[string]CoReviewe if ev.Kind != dialect.EvCoVerdict || ev.Approved == nil { continue } + // Verdicts are documented as current-head state. Between a push and + // the bot's new verdict the newest comment on the PR describes the + // PREVIOUS head, so anything older than this round is not reported. + if !since.IsZero() && ev.ObservedTime().Before(since) { + continue + } if dialect.NormalizeBotName(firstNonEmpty(ev.For, ev.Bot)) != key { continue } diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index 1b79498..cd258a1 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) @@ -28,10 +29,10 @@ func TestDedupeSuppressesResolvedThreadPromptDuplicate(t *testing.T) { {Bot: "coderabbitai", Path: "internal/x.go", Line: 10, Title: "dup", Body: "do x", Source: "review_prompt"}, } suppress := map[string]bool{"coderabbitai|internal/x.go|10": true} - if got := dedupeFindings(findings, suppress); len(got) != 0 { + if got := dedupeFindings(findings, suppress, nil); len(got) != 0 { t.Fatalf("expected the prompt duplicate at a resolved-thread location to be suppressed, got %#v", got) } - if got := dedupeFindings(findings, nil); len(got) != 1 { + if got := dedupeFindings(findings, nil, nil); len(got) != 1 { t.Fatalf("expected the prompt finding to survive when not suppressed, got %#v", got) } } @@ -1219,7 +1220,7 @@ func TestDedupeFindingsDropsNonActionableBotArtifacts(t *testing.T) { {Bot: "coderabbitai", Title: "Past review finding", Body: "The previously flagged issue is now fixed. No further action is needed.", Source: "review_body"}, {Bot: "coderabbitai", Title: "Biometric flow", Body: "Worth confirming this is the intended UX.", Source: "review_body"}, } - if got := dedupeFindings(findings, nil); len(got) != 0 { + if got := dedupeFindings(findings, nil, nil); len(got) != 0 { t.Fatalf("expected non-actionable bot artifacts to be dropped, got %#v", got) } } @@ -2095,3 +2096,74 @@ func TestLoopIgnoresAccountBlockWhenPrimaryWillNotReview(t *testing.T) { t.Fatalf("the primary must read as delivered, got %#v", rep.ReviewedBy) } } + +// TestSkippedFindingBindsToTheSkippedHead: the skip is a per-head refusal whose +// fix is narrowing the PR. Loop treats findings as blocking BEFORE it enqueues, +// so a skip of an earlier head that keeps surfacing would stop the narrowed +// replacement head from ever being submitted — the repair would permanently +// disable the reviewer it was meant to restore. +func TestSkippedFindingBindsToTheSkippedHead(t *testing.T) { + skipBody := corpusMessage(t, "coderabbit/review-skipped-too-many-files.md") + skippedSHA := "56150a0423a243224b03f355c3a3ba6941011b5b" + + if !skipAppliesToHead(skipBody, skippedSHA[:9]) { + t.Error("the skipped head itself must still surface the notice") + } + if skipAppliesToHead(skipBody, "abcdef123") { + t.Error("a narrowed replacement head must not inherit the old skip") + } + // A notice naming no commit is read conservatively as current. + if !skipAppliesToHead("> ## Review skipped\n>\n> No commits to review.", "abcdef123") { + t.Error("a SHA-less skip must bind to the observed head") + } +} + +// TestCoReviewerVerdictScopedToRound: co_reviewers[].verdict is documented as +// current-head state. Between a push and Macroscope's new Approvability comment +// the newest verdict on the PR describes the PREVIOUS head, and reporting it +// would misstate the current one. +func TestCoReviewerVerdictScopedToRound(t *testing.T) { + cfg := Config{Bot: "coderabbitai[bot]", CoBots: []CoBotConfig{ + {Login: dialect.MacroscopeLogin, Name: "macroscope"}, + }} + firedAt := time.Now().UTC() + approved := true + stale := dialect.BotEvent{Kind: dialect.EvCoVerdict, Bot: dialect.MacroscopeLogin, + For: dialect.MacroscopeLogin, Approved: &approved, CommentID: 1, + CreatedAt: firedAt.Add(-time.Hour), UpdatedAt: firedAt.Add(-time.Hour)} + obs := engine.Observation{Head: "abcdef123", Open: true, Events: []dialect.BotEvent{stale}} + + key := dialect.NormalizeBotName(dialect.MacroscopeLogin) + if got := coReviewerStatuses(cfg, obs, firedAt)[key].Verdict; got != "" { + t.Fatalf("a verdict from the previous head must not be reported, got %q", got) + } + // The same verdict inside the round is reported normally. + if got := coReviewerStatuses(cfg, obs, firedAt.Add(-2*time.Hour))[key].Verdict; got != "approved" { + t.Fatalf("an in-round verdict must be reported, got %q", got) + } +} + +// TestBugbotStableIDSettledInAnySiblingThread: Bugbot re-reports one +// BUGBOT_BUG_ID across several threads after each push. Deduplication alone +// collapses only the findings emitted together, so resolving the emitted thread +// promotes a sibling on the next poll and the "single" finding resurfaces — +// blocking convergence until every duplicate is resolved by hand. +func TestBugbotStableIDSettledInAnySiblingThread(t *testing.T) { + body := corpusMessage(t, "bugbot/inline-finding-high.md") + stable, ok := dialect.BugbotFindingDedupeKey(body) + if !ok { + t.Fatal("precondition: the corpus finding must carry a BUGBOT_BUG_ID") + } + findings := []dialect.Finding{ + {Bot: dialect.BugbotLogin, Path: "a.ts", Line: 1, Title: "dup", Body: body, ThreadID: "PRRT_2", Source: "review_thread"}, + } + // Nothing settled yet: the surviving sibling is still actionable. + if got := dedupeFindings(findings, nil, nil); len(got) != 1 { + t.Fatalf("an unsettled bug must surface, got %d", len(got)) + } + // The same bug resolved in ANOTHER thread settles the whole family. + settled := map[string]bool{dialect.NormalizeBotName(dialect.BugbotLogin) + "|" + stable: true} + if got := dedupeFindings(findings, nil, settled); len(got) != 0 { + t.Fatalf("a bug settled in a sibling thread must not resurface, got %#v", got) + } +} diff --git a/internal/dialect/bugbot.go b/internal/dialect/bugbot.go index d02585b..eac4e0f 100644 --- a/internal/dialect/bugbot.go +++ b/internal/dialect/bugbot.go @@ -69,9 +69,14 @@ func CleanBugbotCommentText(text string) string { // ClassifyBugbotCheck classifies one check run owned by the "cursor" app. // Only the check named "Cursor Bugbot" is its review: clean rounds conclude // `success` with "no issues found" in the summary, findings rounds conclude -// `neutral` (verified live). Classification never reads the conclusion — -// summary wording decides clean, so a conclusion-vocabulary change degrades to -// CheckDone (findings still gate via threads) instead of misreading clean. +// `neutral` (verified live). Clean-ness is decided by summary wording, not by +// the conclusion, so a conclusion-vocabulary change degrades to CheckDone +// (findings still gate via threads) instead of misreading clean. +// +// A run that terminated WITHOUT delivering a review (failure, cancelled, +// timed_out) is still status "completed", so it must be rejected explicitly: +// counting it as a finished review lets a round converge with no findings +// even though Bugbot never actually reviewed the code. func ClassifyBugbotCheck(name, title, summary, status, conclusion string) CheckVerdict { if name != bugbotCheckName { return CheckUnrelated @@ -79,6 +84,9 @@ func ClassifyBugbotCheck(name, title, summary, status, conclusion string) CheckV if !strings.EqualFold(status, "completed") { return CheckInProgress } + if checkRunFailed(conclusion) { + return CheckFailed + } if strings.Contains(NormalizeReviewText(summary), "no issues found") || strings.Contains(NormalizeReviewText(title), "no issues found") { return CheckDoneClean diff --git a/internal/dialect/coreviewer.go b/internal/dialect/coreviewer.go index 8699bd6..697ad8f 100644 --- a/internal/dialect/coreviewer.go +++ b/internal/dialect/coreviewer.go @@ -1,5 +1,7 @@ package dialect +import "strings" + // A co-reviewer is a review bot that is not the configured primary reviewer // and does not spend CodeRabbit account quota: Codex, Cursor Bugbot, // Macroscope. This file is the static registry of the co-reviewers crq knows — @@ -7,14 +9,36 @@ package dialect // wording helpers in its own file, corpus files, and golden rows; engine/state/ // crq consume only the registry's structure, never the wording. +// checkRunFailed reports whether a completed check run's conclusion means the +// run did not produce a review. GitHub reports these alongside status +// "completed", so status alone cannot distinguish a finished review from a +// crashed one. +func checkRunFailed(conclusion string) bool { + switch strings.ToLower(strings.TrimSpace(conclusion)) { + case "failure", "cancelled", "canceled", "timed_out", "action_required", "stale": + return true + } + return false +} + // CheckVerdict classifies one check run for co-reviewer evidence purposes. type CheckVerdict int const ( CheckUnrelated CheckVerdict = iota // not this bot's review check CheckInProgress // review still running — evidence it is active, not done - CheckDone // review finished (findings, if any, gate via threads) - CheckDoneClean // review finished and explicitly found nothing + // CheckAuxiliary is one of this bot's checks that is NOT its review verdict + // (Macroscope publishes Approvability and repo-custom checks beside its + // Correctness Check). It proves participation, never completion: counting + // it as a review lets a round converge while the real review is still + // running and its findings have not landed. + CheckAuxiliary + // CheckFailed is a review run that terminated without producing a review + // (conclusion failure/cancelled/timed_out/action_required). The bot tried + // and did not deliver, so it is activity but never review evidence. + CheckFailed + CheckDone // review finished (findings, if any, gate via threads) + CheckDoneClean // review finished and explicitly found nothing ) // CoEvent is the classification a co-reviewer gives one of its own issue diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index 9c1f6e8..5d4ce9f 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -396,10 +396,13 @@ func TestGoldenCheckRuns(t *testing.T) { {"bugbot/check-in-progress.json", BugbotLogin, CheckInProgress}, {"macroscope/check-correctness-clean.json", MacroscopeLogin, CheckDoneClean}, {"macroscope/check-correctness-issues.json", MacroscopeLogin, CheckDone}, - // Approvability is informational: its checks never read as clean. - {"macroscope/check-approvability-approved.json", MacroscopeLogin, CheckDone}, - {"macroscope/check-approvability-not-eligible.json", MacroscopeLogin, CheckDone}, - {"macroscope/check-custom.json", MacroscopeLogin, CheckDone}, + // Only the Correctness Check is Macroscope's REVIEW. Approvability and + // repo-custom checks routinely complete first, so counting them as a + // finished review would let the round converge while correctness is + // still running — they are auxiliary: participation, never completion. + {"macroscope/check-approvability-approved.json", MacroscopeLogin, CheckAuxiliary}, + {"macroscope/check-approvability-not-eligible.json", MacroscopeLogin, CheckAuxiliary}, + {"macroscope/check-custom.json", MacroscopeLogin, CheckAuxiliary}, } for _, tc := range cases { t.Run(tc.file, func(t *testing.T) { @@ -455,3 +458,43 @@ func TestGoldenReviewSkipped(t *testing.T) { } } } + +// TestCheckRunsThatAreNotReviewEvidence pins the two ways a check run can be +// present and completed without meaning "this bot reviewed the code". Both were +// reported by Codex on the co-reviewer PR: counting either as completion lets a +// round converge with no findings while the real review is still running (or +// never ran at all). +func TestCheckRunsThatAreNotReviewEvidence(t *testing.T) { + cases := []struct { + name string + check string + status string + conclusion string + want CheckVerdict + }{ + // A crashed review is still status "completed". + {"bugbot failure", bugbotCheckName, "completed", "failure", CheckFailed}, + {"bugbot cancelled", bugbotCheckName, "completed", "cancelled", CheckFailed}, + {"bugbot timed out", bugbotCheckName, "completed", "timed_out", CheckFailed}, + // The good paths must keep working. + {"bugbot findings", bugbotCheckName, "completed", "neutral", CheckDone}, + {"bugbot running", bugbotCheckName, "in_progress", "", CheckInProgress}, + // Macroscope's non-correctness checks are auxiliary whatever they conclude. + {"macroscope approvability", "Macroscope - Approvability Check", "completed", "success", CheckAuxiliary}, + {"macroscope custom", "Macroscope - Effect Service Conventions", "completed", "failure", CheckAuxiliary}, + {"macroscope correctness failed", macroscopeCorrectnessCheck, "completed", "failure", CheckFailed}, + {"macroscope correctness done", macroscopeCorrectnessCheck, "completed", "neutral", CheckDone}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + slug := "cursor" + if strings.HasPrefix(tc.check, macroscopeCheckPrefix) { + slug = "macroscopeapp" + } + _, got := ClassifyCheckRun(slug, tc.check, "", "", tc.status, tc.conclusion) + if got != tc.want { + t.Fatalf("verdict = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/dialect/macroscope.go b/internal/dialect/macroscope.go index a42fb2f..d01340b 100644 --- a/internal/dialect/macroscope.go +++ b/internal/dialect/macroscope.go @@ -86,18 +86,31 @@ func ClassifyMacroscopeComment(body string) CoEvent { // ClassifyMacroscopeCheck classifies one check run owned by the // "macroscopeapp" app. Any "Macroscope - " check is Macroscope's (repos // add custom ones whose output titles can be garbled — classify by name prefix -// only, never title), but only the Correctness Check may read as clean, via -// its "No issues identified" title/summary. +// only, never title). +// +// Only the Correctness Check is Macroscope's REVIEW. It publishes Approvability +// and repo-defined custom checks beside it, and those routinely complete first; +// treating them as completion evidence lets the round converge while the +// correctness review is still running and its findings have not landed. They +// are auxiliary: participation, never completion. func ClassifyMacroscopeCheck(name, title, summary, status, conclusion string) CheckVerdict { if !strings.HasPrefix(name, macroscopeCheckPrefix) { return CheckUnrelated } + if name != macroscopeCorrectnessCheck { + if !strings.EqualFold(status, "completed") { + return CheckInProgress + } + return CheckAuxiliary + } if !strings.EqualFold(status, "completed") { return CheckInProgress } - if name == macroscopeCorrectnessCheck && - (strings.Contains(NormalizeReviewText(title), "no issues identified") || - strings.Contains(NormalizeReviewText(summary), "no issues identified")) { + if checkRunFailed(conclusion) { + return CheckFailed + } + if strings.Contains(NormalizeReviewText(title), "no issues identified") || + strings.Contains(NormalizeReviewText(summary), "no issues identified") { return CheckDoneClean } return CheckDone diff --git a/internal/engine/coreview.go b/internal/engine/coreview.go index 07cf33e..2e8577c 100644 --- a/internal/engine/coreview.go +++ b/internal/engine/coreview.go @@ -82,11 +82,18 @@ func coChecks(obs Observation, login string) []CheckSeen { return out } -// coCheckAny reports whether ANY check run of login's exists for the head, -// including an in-progress one — a running check will deliver evidence, so -// posting a trigger alongside it would double-review. +// coCheckAny reports whether a check run of login's is engaged with this head: +// running, auxiliary, or finished. Posting a trigger alongside one would +// double-review. A FAILED run is deliberately excluded — the bot tried and did +// not deliver, so a self-heal trigger is exactly the right response to it, and +// the round's recorded command id still bounds crq to one post. func coCheckAny(obs Observation, login string) bool { - return len(coChecks(obs, login)) > 0 + for _, c := range coChecks(obs, login) { + if c.Verdict != dialect.CheckFailed { + return true + } + } + return false } // coCheckReviewedAt reports the newest COMPLETED check verdict for the head diff --git a/internal/engine/fire.go b/internal/engine/fire.go index acd17f5..9ed5062 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -278,7 +278,11 @@ func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time, prim post = append(post, cp.Login) continue } - if co.AutoActive || len(co.Commands) > 0 || roundCoCommandID(r, cp.Login) != 0 { + // ActiveThisRound covers the case crq cannot post for: a check already + // running on this head. Without it the round dedupes to completed while + // the co-review is still in flight, discarding the findings it is about + // to publish. + if co.AutoActive || co.ActiveThisRound || len(co.Commands) > 0 || roundCoCommandID(r, cp.Login) != 0 { wait = true } } From 73db535c99dfe69973b6c496d48b87eaff3738e2 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 00:21:03 +0200 Subject: [PATCH 17/31] Never read "auto reviews are disabled" as a refusal to review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression I introduced in b469b2f, caught by an agent driving krisHQ#1023 whose loop could not run at all. CodeRabbit reuses the "Review skipped" heading for a notice that means the opposite of a refusal: "Auto reviews are disabled on this repository … To trigger a single review, invoke the @coderabbitai review command." It says a review IS available on request. crq REQUIRES auto-review to be off, so this notice lands on every push of every repo crq manages — its steady state, not an error. Classifying it as a refusal made PrimaryReviewUnavailable true everywhere: crq would stop firing CodeRabbit on every repo, and because the loop treats findings as blocking and this one has no thread to resolve, every loop exited 10 immediately, forever, without ever waiting for a review. IsReviewSkipped now excludes it explicitly, so the discrimination is between two notices sharing a heading rather than a blanket disable — the too-many-files refusal still classifies. The corpus pins both, and the golden row for the disabled-notice is the one that guards the fleet. Also applies the head-commit cutoff to a skip that names no SHA, so a stale notice cannot block a later head: SHA binding decides when the notice names a commit, time binding when it does not. --- internal/crq/feedback.go | 16 ++++++- internal/dialect/coderabbit.go | 29 ++++++++---- internal/dialect/golden_test.go | 36 +++++++++++++++ .../review-skipped-auto-reviews-disabled.md | 44 +++++++++++++++++++ 4 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 internal/dialect/testdata/coderabbit/review-skipped-auto-reviews-disabled.md diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 940b74b..952b726 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -271,7 +271,8 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe continue } if s.cr.IsReviewSkipped(comment.Body) && s.isConfiguredBot(comment.User.Login) && - skipAppliesToHead(comment.Body, head) { + skipAppliesToHead(comment.Body, head) && + !skipPredatesHead(comment, headCutoffOf) { // Checked BEFORE the rate-limit guard below: the skip notice embeds // the rate-limit marker, so that guard would drop it and the round // would converge with a silently absent primary reviewer — worse than @@ -1186,6 +1187,19 @@ func dedupeFindings(in []dialect.Finding, suppressPromptAt, settledStableIDs map // so a skip of an EARLIER head must not keep surfacing: Loop treats findings as // blocking before it enqueues, which would stop the replacement head from ever // being submitted — the repair would permanently disable the reviewer. +// skipPredatesHead reports whether a skip notice was posted before the current +// head existed. A notice naming no commit cannot be bound by SHA, so it falls +// back to the same head-commit cutoff every other issue comment uses — without +// it a stale skip keeps surfacing as a blocking, thread-less finding and the +// loop exits 10 forever without ever waiting for a review. +func skipPredatesHead(comment ghapi.IssueComment, headCutoff func() time.Time) bool { + if dialect.ReviewSkippedHeadSHA(comment.Body) != "" { + return false // names its commit: SHA binding already decided this + } + cutoff := headCutoff() + return !cutoff.IsZero() && comment.CreatedAt.Before(cutoff) +} + func skipAppliesToHead(body, head string) bool { skipped := dialect.ReviewSkippedHeadSHA(body) if skipped == "" || head == "" { diff --git a/internal/dialect/coderabbit.go b/internal/dialect/coderabbit.go index 4bbc038..c40b1af 100644 --- a/internal/dialect/coderabbit.go +++ b/internal/dialect/coderabbit.go @@ -125,6 +125,11 @@ var ( // than matched as loose prose: a false positive stops crq firing CodeRabbit // on a PR it could really review. reviewSkippedRE = regexp.MustCompile(`(?mi)^\s*>?\s*#{1,4}\s*review skipped\s*$`) + // autoReviewsDisabledRE matches the routine "auto-review is off, ask me + // explicitly" notice that shares the "Review skipped" heading. crq REQUIRES + // auto-review to be off, so this fires on every push it manages: reading it + // as a refusal would stop crq firing CodeRabbit on every repo. + autoReviewsDisabledRE = regexp.MustCompile(`(?i)auto reviews are disabled`) // reviewSkippedHeadRE captures the head SHA a skip was evaluated against, // from "Reviewing files that changed ... between and .". reviewSkippedHeadRE = regexp.MustCompile(`(?i)between\s+[0-9a-f]{7,40}\s+and\s+([0-9a-f]{7,40})`) @@ -133,15 +138,23 @@ var ( reviewSkippedReasonRE = regexp.MustCompile(`(?mi)^\s*>\s*(?:##+\s*)?([A-Z][^\n]*?[.!])\s*$`) ) -// IsReviewSkipped reports whether body is CodeRabbit's "Review skipped" notice: -// it declined to review this head outright (too many files, no usage credits, -// an unsupported diff). This is NOT a rate limit even though the notice carries -// the rate-limit marker — there is no window after which it clears, so waiting -// and re-firing produce the same refusal forever while poisoning the -// account-wide quota with a fabricated block. Treat it as "no CodeRabbit review -// is coming for this head" and let the co-reviewers decide the round. +// IsReviewSkipped reports whether body is CodeRabbit REFUSING to review this +// head (too many files, no usage credits, an unsupported diff). This is NOT a +// rate limit even though the notice carries the rate-limit marker — there is no +// window after which it clears, so waiting and re-firing produce the same +// refusal forever while poisoning the account-wide quota with a fabricated +// block. Treat it as "no CodeRabbit review is coming for this head" and let the +// co-reviewers decide the round. +// +// CodeRabbit reuses the same "Review skipped" heading for a notice that is the +// exact OPPOSITE of a refusal: "Auto reviews are disabled on this repository … +// To trigger a single review, invoke the `@coderabbitai review` command." That +// one says a review IS available on request — it is what CodeRabbit posts on +// every push once auto-review is off, which is crq's REQUIRED prerequisite and +// therefore its steady state on every repo it manages. Reading it as a refusal +// stops crq firing CodeRabbit anywhere, so it is excluded explicitly. func (d CodeRabbit) IsReviewSkipped(body string) bool { - return reviewSkippedRE.MatchString(body) + return reviewSkippedRE.MatchString(body) && !autoReviewsDisabledRE.MatchString(body) } // ReviewSkippedHeadSHA returns the head commit the skip was evaluated against, diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index 5d4ce9f..b33dd4a 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -72,6 +72,13 @@ func TestGoldenClassification(t *testing.T) { // clears, re-fires forever, and blocks the whole account's quota. {file: "coderabbit/review-skipped-too-many-files.md", reviewSkipped: true, rateLimited: true, author: "coderabbitai[bot]", wantKind: EvSkipped}, + // The SAME "Review skipped" heading, meaning the opposite: auto-review is + // off and CodeRabbit is inviting the explicit trigger. That is crq's + // REQUIRED prerequisite, so this notice appears on every push crq + // manages — reading it as a refusal stops crq firing CodeRabbit + // everywhere. It must NOT be a skip. (This capture is from a Free + // private repo, so summaryOnly is true for an unrelated reason.) + {file: "coderabbit/review-skipped-auto-reviews-disabled.md", reviewSkipped: false, summaryOnly: true}, {file: "coderabbit/already-reviewed.md", alreadyDone: true, autoReply: true}, {file: "coderabbit/completion-reply.md", completionReply: true, autoReply: true}, // The standalone trailer is an ack; a real finding CARRYING the trailer @@ -498,3 +505,32 @@ func TestCheckRunsThatAreNotReviewEvidence(t *testing.T) { }) } } + +// TestAutoReviewsDisabledIsNotARefusal guards the regression that broke crq's +// core function: "Review skipped — Auto reviews are disabled on this +// repository … To trigger a single review, invoke the `@coderabbitai review` +// command" shares a heading with the real refusals but means the opposite. It +// says a review IS available on request, and because crq REQUIRES auto-review +// to be off it appears on every push crq manages. Classifying it as a refusal +// makes PrimaryReviewUnavailable true everywhere, so crq stops firing +// CodeRabbit on every repo it manages and every loop exits instantly on an +// unresolvable, thread-less finding. +func TestAutoReviewsDisabledIsNotARefusal(t *testing.T) { + body := readGolden(t, "coderabbit/review-skipped-auto-reviews-disabled.md") + if goldenCR.IsReviewSkipped(body) { + t.Fatal("the auto-review-disabled notice must never read as a refusal to review") + } + // The classifier must still see it as an ordinary CodeRabbit comment, not + // as the EvSkipped state. + classifier := Classifier{CodeRabbit: goldenCR, Bot: "coderabbitai[bot]", + ReviewCommand: "@coderabbitai review", CoReviewers: KnownCoReviewers()} + base := time.Date(2026, 7, 24, 21, 15, 52, 0, time.UTC) + if got := classifier.Classify("coderabbitai[bot]", body, 1, base, base).Kind; got == EvSkipped { + t.Fatal("Classify must not report EvSkipped for the auto-review-disabled notice") + } + // The genuine refusal still classifies, so the fix is a discrimination and + // not a blanket disable. + if !goldenCR.IsReviewSkipped(readGolden(t, "coderabbit/review-skipped-too-many-files.md")) { + t.Fatal("a real refusal must still be recognised") + } +} diff --git a/internal/dialect/testdata/coderabbit/review-skipped-auto-reviews-disabled.md b/internal/dialect/testdata/coderabbit/review-skipped-auto-reviews-disabled.md new file mode 100644 index 0000000..143b62e --- /dev/null +++ b/internal/dialect/testdata/coderabbit/review-skipped-auto-reviews-disabled.md @@ -0,0 +1,44 @@ + + + +> [!IMPORTANT] +> ## Review skipped +> +> Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command. +> +>
+> ⚙️ Run configuration +> +> **Configuration used**: Organization UI +> +> **Review profile**: ASSERTIVE +> +> **Plan**: Free +> +> **Run ID**: `3684843f-b902-4e27-80da-c1c817cb7c33` +> +>
+> +> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file. +> +> Use the checkbox below for a quick retry: +> - [ ] 🔍 Trigger review + + + + + +--- + +> [!NOTE] +>
+> 🎁 Summarized by CodeRabbit Free +> +> Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting . +> +>
+ + +Comment `@coderabbitai help` to get the list of available commands. + + From 64c31f933d5d3b0a6edb622419aad34e88119d58 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 01:14:48 +0200 Subject: [PATCH 18/31] Address Codex round two: check semantics, skip expiry, and gate gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine findings, all consequences of the previous round's fixes. Check verdicts were still too blunt. startup_failure joins the non-delivery conclusions, and `skipped` turns out not to be universally one: the live harvest recorded Macroscope's Correctness Check concluding `skipped` with "No code objects were reviewed.", a real clean verdict, so each bot now decides what its own skip means — Bugbot's did not review, Macroscope's reviewed and found nothing. Macroscope's auxiliary checks are auxiliary whether running or finished; reporting a running one as in-progress let it both suppress every trigger and engage a gate it could never satisfy, stranding the round with no recovery path. A SHA-less "Review skipped" applied to every later head forever, so a narrowed PR was never reviewed again — silently, since Feedback filters the same notice by commit time. It now expires against the head commit date, which observe supplies. Two gates were missing a case. A primary-unavailable round could post an optional co-reviewer's trigger and then immediately mark the primary delivered and report done, before its only actual reviewer answered; a command recorded this round now gates it. And an open Bugbot re-report sharing a BUGBOT_BUG_ID with an old resolved thread was dropped as already settled — the live occurrence now wins. CRQ_RL_CO_DEGRADE governed the busy-slot path as well as the account-block path it is named for, parking quota-free co-review work behind an unrelated PR's CodeRabbit slot. It now applies only to account blocks; the contract test is updated to the new behavior rather than the old. Also: the bounded rescue scan no longer treats "every trigger already posted" as proof nothing is left, since a primary-unavailable round in that state still needs its quota-free resolution; and a verdict falls back to the head commit cutoff when the round has not fired, so a previous head's Approvability is not reported as this head's. --- internal/crq/feedback.go | 38 +++++++++++++++++----- internal/crq/observe.go | 9 +++++ internal/crq/service.go | 14 ++++---- internal/dialect/bugbot.go | 3 +- internal/dialect/coreviewer.go | 8 ++++- internal/dialect/macroscope.go | 12 +++++-- internal/engine/completion.go | 7 +++- internal/engine/coreview.go | 21 ++++++++---- internal/engine/coreview_test.go | 56 ++++++++++++++++++++++++++++++++ internal/engine/engine.go | 16 ++++++++- internal/engine/engine_test.go | 12 +++++-- internal/engine/fire.go | 12 ++++--- 12 files changed, 172 insertions(+), 36 deletions(-) diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 952b726..47a771d 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -106,7 +106,14 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe } completion := engine.Completion(completionRound, obs.eng, s.policy()) report.ReviewedBy = completion.ReviewedBy - report.CoReviewers = coReviewerStatuses(s.cfg, obs.eng, anchorCutoff) + verdictCutoff := anchorCutoff + if verdictCutoff.IsZero() { + // Not fired yet (queued behind another PR): without a fire anchor the + // newest verdict on the PR describes the PREVIOUS head. Bound it by the + // head commit instead of reporting stale state as current. + verdictCutoff = obs.eng.HeadAt + } + report.CoReviewers = coReviewerStatuses(s.cfg, obs.eng, verdictCutoff) if why := engine.PrimaryUnavailableReason(obs.eng, s.policy(), head); why != "" { report.PrimaryUnavailable = true report.PrimaryUnavailableReason = s.cfg.Bot + " " + why @@ -163,18 +170,33 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe // ids settled in ANY thread first and suppress the whole family. settledStableIDs := map[string]bool{} if threads, err := s.reviewThreads(ctx, repo, pr); err == nil { + openStableIDs := map[string]bool{} for _, thread := range threads { - if !thread.IsResolved && !thread.IsOutdated { - continue - } + settled := thread.IsResolved || thread.IsOutdated for _, c := range thread.Comments.Nodes { - if co, ok := dialect.CoReviewerByName(c.Author.Login); ok && co.FindingDedupeKey != nil { - if stable, ok := co.FindingDedupeKey(c.Body); ok { - settledStableIDs[dialect.NormalizeBotName(c.Author.Login)+"|"+stable] = true - } + co, ok := dialect.CoReviewerByName(c.Author.Login) + if !ok || co.FindingDedupeKey == nil { + continue + } + stable, ok := co.FindingDedupeKey(c.Body) + if !ok { + continue + } + key := dialect.NormalizeBotName(c.Author.Login) + "|" + stable + if settled { + settledStableIDs[key] = true + } else { + openStableIDs[key] = true } } } + // reviewThreads spans the PR's whole history, so an old resolved thread + // can share an id with a CURRENT re-report — after a regression, or an + // incorrect manual resolve. The live occurrence wins: otherwise the loop + // converges while the bot has an open finding. + for key := range openStableIDs { + delete(settledStableIDs, key) + } for _, thread := range threads { report.Findings = append(report.Findings, threadFindings(thread, extractBots)...) // A resolved/outdated inline thread emits no finding, but CodeRabbit's diff --git a/internal/crq/observe.go b/internal/crq/observe.go index dff57b3..b0f83ec 100644 --- a/internal/crq/observe.go +++ b/internal/crq/observe.go @@ -41,6 +41,15 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round if o.eng.Open && len(pull.Head.SHA) >= 9 { o.eng.Head = pull.Head.SHA[:9] } + // The head's commit time bounds evidence that names no commit of its own + // (a SHA-less "Review skipped"), so such a notice cannot suppress a later + // head forever. Best-effort: an unreadable commit leaves it zero and the + // engine falls back to accepting the notice, the conservative reading. + if o.eng.Open && pull.Head.SHA != "" { + if c, cerr := s.gh.GetCommit(ctx, repo, pull.Head.SHA); cerr == nil { + o.eng.HeadAt = c.Committer.Date.UTC() + } + } // Reviews and issue comments are fetched even for a closed PR: the daemon's // Progress/DecideFire abandon it regardless, but Feedback still surfaces its diff --git a/internal/crq/service.go b/internal/crq/service.go index c144e63..da51b40 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -288,14 +288,12 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { if scanned >= 3 { break } - // Cheap pre-filter: a round with every trigger already posted has - // nothing left for this scan to start. (A summary-only round in - // that state is merely delayed, not starved — it resolves when it - // reaches the front of the queue; recognising it here would cost an - // observation for every queued round on every pump.) - if !anyCoUncommanded(r, policy) { - continue - } + // No cheap pre-filter here. "Every trigger already posted" is not + // proof that nothing is left to do: a primary-unavailable round in + // that state still needs its quota-free FireDedupe/FireCoReviewWait, + // and a co-review answer to an earlier deferred command needs + // collecting. Skipping those left them behind the account block for + // hours. The scan budget below bounds the cost instead. scanned++ round := r robs, oerr := s.observe(ctx, round.Repo, round.PR, &round, now) diff --git a/internal/dialect/bugbot.go b/internal/dialect/bugbot.go index eac4e0f..1c54f11 100644 --- a/internal/dialect/bugbot.go +++ b/internal/dialect/bugbot.go @@ -84,7 +84,8 @@ func ClassifyBugbotCheck(name, title, summary, status, conclusion string) CheckV if !strings.EqualFold(status, "completed") { return CheckInProgress } - if checkRunFailed(conclusion) { + // Bugbot has no benign skip: a skipped run simply did not review. + if checkRunFailed(conclusion) || strings.EqualFold(strings.TrimSpace(conclusion), "skipped") { return CheckFailed } if strings.Contains(NormalizeReviewText(summary), "no issues found") || diff --git a/internal/dialect/coreviewer.go b/internal/dialect/coreviewer.go index 697ad8f..aaf6625 100644 --- a/internal/dialect/coreviewer.go +++ b/internal/dialect/coreviewer.go @@ -13,9 +13,15 @@ import "strings" // run did not produce a review. GitHub reports these alongside status // "completed", so status alone cannot distinguish a finished review from a // crashed one. +// +// "skipped" is deliberately absent: it is not universally a non-delivery. +// Macroscope's Correctness Check concludes `skipped` for the benign +// "No code objects were reviewed." outcome, which is a real clean verdict. +// Each bot decides what its own skip means. func checkRunFailed(conclusion string) bool { switch strings.ToLower(strings.TrimSpace(conclusion)) { - case "failure", "cancelled", "canceled", "timed_out", "action_required", "stale": + case "failure", "cancelled", "canceled", "timed_out", "action_required", + "stale", "startup_failure": return true } return false diff --git a/internal/dialect/macroscope.go b/internal/dialect/macroscope.go index d01340b..c5bb848 100644 --- a/internal/dialect/macroscope.go +++ b/internal/dialect/macroscope.go @@ -98,9 +98,9 @@ func ClassifyMacroscopeCheck(name, title, summary, status, conclusion string) Ch return CheckUnrelated } if name != macroscopeCorrectnessCheck { - if !strings.EqualFold(status, "completed") { - return CheckInProgress - } + // Auxiliary whether running or finished. Reporting a running auxiliary + // check as CheckInProgress would let it suppress the trigger and engage + // the completion gate it can never satisfy, stranding the round. return CheckAuxiliary } if !strings.EqualFold(status, "completed") { @@ -109,6 +109,12 @@ func ClassifyMacroscopeCheck(name, title, summary, status, conclusion string) Ch if checkRunFailed(conclusion) { return CheckFailed } + // Correctness concludes `skipped` for "No code objects were reviewed." — + // it ran and had nothing to analyse, which is a clean verdict, not a + // non-delivery. Treating it as failed would strand docs-only PRs. + if strings.EqualFold(strings.TrimSpace(conclusion), "skipped") { + return CheckDoneClean + } if strings.Contains(NormalizeReviewText(title), "no issues identified") || strings.Contains(NormalizeReviewText(summary), "no issues identified") { return CheckDoneClean diff --git a/internal/engine/completion.go b/internal/engine/completion.go index 6a4a629..6646273 100644 --- a/internal/engine/completion.go +++ b/internal/engine/completion.go @@ -69,7 +69,12 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { continue } co := obs.co(cp.Login) - if (co.AutoActive || co.ActiveThisRound) && !coUnableSince(obs, cp.Login, cutoff) { + // A trigger crq recorded THIS round counts too. Without it a + // primary-unavailable round posts an optional bot's command and then + // immediately marks the primary delivered and reports done — before + // the only reviewer it actually has has said anything. + commanded := roundCoCommandID(r, cp.Login) != 0 + if (co.AutoActive || co.ActiveThisRound || commanded) && !coUnableSince(obs, cp.Login, cutoff) { reviewedBy[cp.Login] = false } } diff --git a/internal/engine/coreview.go b/internal/engine/coreview.go index 2e8577c..d91c40a 100644 --- a/internal/engine/coreview.go +++ b/internal/engine/coreview.go @@ -82,16 +82,23 @@ func coChecks(obs Observation, login string) []CheckSeen { return out } -// coCheckAny reports whether a check run of login's is engaged with this head: -// running, auxiliary, or finished. Posting a trigger alongside one would -// double-review. A FAILED run is deliberately excluded — the bot tried and did -// not deliver, so a self-heal trigger is exactly the right response to it, and -// the round's recorded command id still bounds crq to one post. +// coCheckAny reports whether login's REVIEW check is engaged with this head — +// running or finished. Posting a trigger alongside one would double-review. +// +// Two verdicts are deliberately excluded, because neither can ever satisfy the +// gate they would otherwise hold open. A FAILED run means the bot tried and did +// not deliver, so a self-heal trigger is the right response. An AUXILIARY check +// (Macroscope's Approvability or a repo-custom check) is not the review at all: +// treating it as engagement suppressed every trigger while the Correctness +// Check had not even started, leaving required rounds to time out with no +// recovery path. func coCheckAny(obs Observation, login string) bool { for _, c := range coChecks(obs, login) { - if c.Verdict != dialect.CheckFailed { - return true + switch c.Verdict { + case dialect.CheckFailed, dialect.CheckAuxiliary: + continue } + return true } return false } diff --git a/internal/engine/coreview_test.go b/internal/engine/coreview_test.go index e8bc17d..fed9ba6 100644 --- a/internal/engine/coreview_test.go +++ b/internal/engine/coreview_test.go @@ -452,3 +452,59 @@ func TestReviewSkippedRunsCoReviewersInsteadOfFiring(t *testing.T) { t.Fatalf("a blocked account must not delay a skipped round: %+v", d) } } + +// TestSkippedNoticeExpiresWithTheHead: a real "Review skipped" that names no +// commit must not suppress CodeRabbit forever. Accepting it indefinitely means +// a narrowed PR is never reviewed again — and silently, because Feedback +// filters the same notice by commit time and stops surfacing it. +func TestSkippedNoticeExpiresWithTheHead(t *testing.T) { + p := Policy{Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]"}} + postedAt := time.Date(2026, 7, 24, 20, 0, 0, 0, time.UTC) + skip := dialect.BotEvent{Kind: dialect.EvSkipped, Bot: "coderabbitai[bot]", + CommentID: 1, CreatedAt: postedAt, UpdatedAt: postedAt} + + // Head predates the notice: it describes this head, so it still applies. + current := Observation{Head: "abcdef123", Open: true, HeadAt: postedAt.Add(-time.Hour), + Events: []dialect.BotEvent{skip}} + if !PrimaryReviewUnavailable(current, p, current.Head) { + t.Fatal("a skip observed after the head appeared must apply to it") + } + // The PR was narrowed: the new head is NEWER than the notice, so the old + // refusal must not suppress it. + narrowed := Observation{Head: "9999abcde", Open: true, HeadAt: postedAt.Add(time.Hour), + Events: []dialect.BotEvent{skip}} + if PrimaryReviewUnavailable(narrowed, p, narrowed.Head) { + t.Fatal("a narrowed head must be reviewable again, not suppressed by the old skip") + } + // Unknown head time falls back to accepting it (conservative). + unknown := Observation{Head: "9999abcde", Open: true, Events: []dialect.BotEvent{skip}} + if !PrimaryReviewUnavailable(unknown, p, unknown.Head) { + t.Fatal("with no head time the skip is read conservatively as applying") + } +} + +// TestUnavailablePrimaryWaitsForTheBotItJustCommanded: when no primary review +// is coming, coAwareDedupe may post an optional co-reviewer's trigger. That bot +// is not yet AutoActive or ActiveThisRound, so without counting the recorded +// command the round marks the primary delivered and reports done immediately +// after asking — before its only real reviewer has said anything. +func TestUnavailablePrimaryWaitsForTheBotItJustCommanded(t *testing.T) { + head := "abcdef123" + p := Policy{Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]"}, + CoReviewers: []CoReviewerPolicy{{Login: bugbotLogin, Command: "bugbot run", Trigger: TriggerAlways}}} + summaryOnly := dialect.BotEvent{Kind: dialect.EvOther, Bot: "coderabbitai[bot]", + SummaryOnly: true, CommentID: 1} + obs := Observation{Head: head, Open: true, Events: []dialect.BotEvent{summaryOnly}} + + // Nothing commanded yet and the bot is silent: nothing to wait for. + bare := state.Round{Head: head} + if got := Completion(bare, obs, p); !got.Done { + t.Fatalf("with no co-reviewer engaged the round resolves: %+v", got) + } + // crq has now posted its trigger — the round must wait for the answer. + commanded := state.Round{Head: head} + commanded.SetCoCommand(bugbotLogin, 55, time.Now().UTC()) + if got := Completion(commanded, obs, p); got.Done { + t.Fatalf("a round must not complete right after commanding its only reviewer: %+v", got) + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index fee4786..381d2ea 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -132,6 +132,9 @@ type Observation struct { // (pre-fetched only when a Codex-gated completion needs it). CodexThumbsUp bool + // HeadAt is when the observed head commit was created, the cutoff for + // evidence that names no commit of its own. Zero when unknown. + HeadAt time.Time // Checks are the head's classified co-reviewer check runs. Checks []CheckSeen // Co carries each co-reviewer's per-bot observation slice, keyed by @@ -179,7 +182,18 @@ func ReviewSkippedHead(obs Observation, p Policy, head string) bool { if ev.Kind != dialect.EvSkipped || !sameBot(ev.Bot, p.Bot) { continue } - if ev.SHA == "" || head == "" || dialect.SHAPrefixMatch(ev.SHA, head) { + if ev.SHA != "" { + if head == "" || dialect.SHAPrefixMatch(ev.SHA, head) { + return true + } + continue + } + // A skip naming no commit cannot be bound by SHA, so bind it by time: + // only one observed at/after this head appeared may suppress it. + // Accepting it indefinitely means a narrowed PR is never reviewed + // again — and silently, since Feedback filters the same notice by + // commit time and stops surfacing it. + if obs.HeadAt.IsZero() || notBefore(ev.ObservedTime(), obs.HeadAt) { return true } } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 00224e5..bbe165b 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -775,8 +775,16 @@ func TestDecideFireBlockedCodexDeferred(t *testing.T) { if d := DecideFire(Global{SlotFree: false}, queued, open, now, degrade); d.Verdict != FireCoDeferred { t.Fatalf("slot-busy + degrade + postable codex must defer to codex, got %+v", d) } - if d := DecideFire(Global{SlotFree: false}, queued, open, now, off); d.Verdict != FireNo { - t.Fatalf("slot-busy with degrade off must stay FireNo, got %+v", d) + // CRQ_RL_CO_DEGRADE governs the ACCOUNT-LIMIT window only, which is what it + // is named and documented for. Turning it off must not park quota-free + // co-review work behind an unrelated PR's CodeRabbit slot — that would add a + // whole in-flight review timeout to bots that never touch the quota. + if d := DecideFire(Global{SlotFree: false}, queued, open, now, off); d.Verdict != FireCoDeferred { + t.Fatalf("degrade off must still let a busy slot defer to codex, got %+v", d) + } + // It does still suppress the deferral during an account block. + if d := DecideFire(g, queued, open, now, off); d.Verdict != FireNo { + t.Fatalf("degrade off must suppress the account-blocked deferral, got %+v", d) } } diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 9ed5062..8681600 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -130,7 +130,7 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic // resolution below once the slot frees (a queued round a co-bot answers // clean cannot complete, so deferring it here could wedge the wait). if !reviewedHead { - if d, ok := decideCoDeferred(r, obs, p, now, "fire slot busy"); ok { + if d, ok := decideCoDeferred(r, obs, p, now, "fire slot busy", false); ok { return d } } @@ -153,7 +153,7 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic // still fires the moment the window opens. DecideCoPost's guards // (command configured, no already-posted or live command) make this // idempotent per round. - if d, ok := decideCoDeferred(r, obs, p, now, "account blocked"); ok { + if d, ok := decideCoDeferred(r, obs, p, now, "account blocked", true); ok { return d } return FireDecision{Verdict: FireNo, Reason: "account blocked until " + g.BlockedUntil.UTC().Format(time.RFC3339)} @@ -190,8 +190,12 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic // be recorded as the round anchor rather than merely suppressing a duplicate // post. The legacy Adopt fields mirror the Codex entry for pre-migration // consumers. -func decideCoDeferred(r state.Round, obs Observation, p Policy, now time.Time, reason string) (FireDecision, bool) { - if !p.RateLimitCoDegrade { +func decideCoDeferred(r state.Round, obs Observation, p Policy, now time.Time, reason string, accountBlocked bool) (FireDecision, bool) { + // RateLimitCoDegrade is documented as governing behaviour during an + // account-limit window. Applying it to the busy-slot path too would park + // quota-free co-review work behind an unrelated PR's CodeRabbit review — + // adding a whole in-flight timeout to bots that never touch the quota. + if accountBlocked && !p.RateLimitCoDegrade { return FireDecision{}, false } var post []string From dc97ecfb1f2898caad0dca8ae6d256ccfb7d4461 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 01:18:39 +0200 Subject: [PATCH 19/31] Address CodeRabbit review: anchor floor, double observe, config typos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The co-review wait anchor was overwritten per co-reviewer instead of taking the earliest candidate, so it discarded the primary head-review floor and kept whichever policy config happened to list last. With Codex commanded at 10:00 and Bugbot at 10:30 the floor became 10:30 and a SHA-less Codex clean summary posted at 10:05 fell below it — the exact evidence loss the anchor exists to prevent. The quota-free bypass ran on every Wait tick: a full observation that usually returned handled=false, after which Pump observed the same PR again. Two round trips per tick on the hot polling loop for a bypass that applies to a minority of rounds. It is now gated on the signal it exists for, which each feedback pass already computes. A mistyped CRQ_COBOTS entry was silently dropped, disabling the very co-reviewer the operator asked for — and a bot that never runs looks nothing like a misspelling. LoadConfig now fails naming the typo and the known bots. Also: crq no longer branches on a bot identity when deriving round activity (the Codex thumbs-up quirk moved into engine, which owns quirks, per the no-bot-enumeration rule); errcheck silenced on the check-run body cleanup; the dead rescue-scan prefilter removed now that its call site is gone; and a test renamed to stop claiming Loop coverage it never had. --- internal/crq/config.go | 29 ++++++++++++++++---- internal/crq/config_test.go | 18 +++++++++++++ internal/crq/feedback_test.go | 4 +-- internal/crq/observe.go | 7 +---- internal/crq/service.go | 51 ++++++++++++++++++++--------------- internal/engine/coreview.go | 9 +++++-- internal/gh/checks.go | 8 +++--- 7 files changed, 85 insertions(+), 41 deletions(-) diff --git a/internal/crq/config.go b/internal/crq/config.go index 806c80b..1c1fcc6 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -3,6 +3,7 @@ package crq import ( "bufio" "errors" + "fmt" "os" "path/filepath" "strconv" @@ -105,7 +106,10 @@ func LoadConfig() (Config, error) { host, _ := os.Hostname() bot := stringEnv(env, "CRQ_BOT", "coderabbitai[bot]") requiredBots := listEnv(env, "CRQ_REQUIRED_BOTS", bot) - coBots := parseCoBots(env, requiredBots) + coBots, err := parseCoBots(env, requiredBots) + if err != nil { + return Config{}, err + } // A required co-reviewer gates convergence via RequiredBots membership — // that list stays the single source of required-ness. for _, cb := range coBots { @@ -290,12 +294,27 @@ type CoBotConfig struct { // legacy alias of CRQ_COBOT_CODEX_CMD. Bugbot/Macroscope default to // `selfheal` — they auto-review pushes, so crq only nudges one that went // silent on a head it should have covered. -func parseCoBots(env map[string]string, requiredBots []string) []CoBotConfig { +func parseCoBots(env map[string]string, requiredBots []string) ([]CoBotConfig, error) { enabled := map[string]bool{} + var unknown []string for _, item := range splitList(stringEnvAllowEmpty(env, "CRQ_COBOTS", "codex,bugbot,macroscope")) { - if co, ok := dialect.CoReviewerByName(item); ok { - enabled[co.Name] = true + co, ok := dialect.CoReviewerByName(item) + if !ok { + // Refuse rather than skip: silently dropping a typo disables the + // co-reviewer the operator asked for, and the symptom (a bot that + // never runs) looks nothing like its cause. + unknown = append(unknown, item) + continue } + enabled[co.Name] = true + } + if len(unknown) > 0 { + known := make([]string, 0, 3) + for _, co := range dialect.KnownCoReviewers() { + known = append(known, co.Name) + } + return nil, fmt.Errorf("CRQ_COBOTS: unknown co-reviewer %s (known: %s)", + strings.Join(unknown, ", "), strings.Join(known, ", ")) } requiredSet := map[string]bool{} for _, bot := range requiredBots { @@ -339,7 +358,7 @@ func parseCoBots(env map[string]string, requiredBots []string) []CoBotConfig { SelfHealGrace: durationEnv(env, "CRQ_COBOT_"+key+"_GRACE", 10*time.Minute), }) } - return out + return out, nil } // splitList splits a comma-separated list, dropping blanks (an all-blank or diff --git a/internal/crq/config_test.go b/internal/crq/config_test.go index 3952ee5..1016086 100644 --- a/internal/crq/config_test.go +++ b/internal/crq/config_test.go @@ -3,6 +3,7 @@ package crq import ( "os" "path/filepath" + "strings" "testing" "time" @@ -409,3 +410,20 @@ func TestLoadConfigRLCoDegradeAlias(t *testing.T) { t.Fatal("legacy CRQ_RL_CODEX_DEGRADE=0 must still disable the degrade") } } + +func TestLoadConfigRejectsUnknownCoBot(t *testing.T) { + t.Setenv("CRQ_CONFIG", filepath.Join(t.TempDir(), "missing-env")) + t.Setenv("CRQ_REQUIRED_BOTS", "") + t.Setenv("CRQ_COBOTS", "codex,buugbot") + + // Silently skipping a typo disabled the co-reviewer the operator asked for, + // and the symptom — a bot that simply never runs — looks nothing like a + // misspelling, so this fails loudly instead. + _, err := LoadConfig() + if err == nil { + t.Fatal("a misspelled co-reviewer must fail configuration, not be dropped") + } + if !strings.Contains(err.Error(), "buugbot") || !strings.Contains(err.Error(), "bugbot") { + t.Fatalf("the error must name the typo and the known bots, got %v", err) + } +} diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index cd258a1..5ed5913 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -2028,14 +2028,14 @@ func TestFeedbackSurfacesSkippedReviewDespiteRateLimitMarker(t *testing.T) { } } -// TestLoopIgnoresAccountBlockWhenPrimaryWillNotReview pins the rule that stopped +// TestAccountBlockIgnoredWhenPrimaryWillNotReview pins the rule that stopped // agents reasoning about CodeRabbit on repos it never reviews. On a summary-only // round the account block is meaningless: it must not extend the wait deadline, // must not slow the poll to the block window, and must not be narrated. Agents // were reading that narration and concluding "hold the head until CodeRabbit // lands" — on a repo where CodeRabbit never lands, i.e. never push, never // converge. -func TestLoopIgnoresAccountBlockWhenPrimaryWillNotReview(t *testing.T) { +func TestAccountBlockIgnoredWhenPrimaryWillNotReview(t *testing.T) { now := time.Now().UTC() blocked := now.Add(42 * time.Minute) deadline := now.Add(5 * time.Minute) diff --git a/internal/crq/observe.go b/internal/crq/observe.go index b0f83ec..119e0d5 100644 --- a/internal/crq/observe.go +++ b/internal/crq/observe.go @@ -156,12 +156,7 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round for _, cb := range s.cfg.CoBots { seen := engine.CoSeen{AutoActive: engine.CoAutoActive(o.eng, cb.Login)} if round != nil { - if dialect.IsCodexBot(cb.Login) { - // Codex's thumbs-up quirk counts as round participation. - seen.ActiveThisRound = engine.CodexActiveThisRound(*round, o.eng) - } else { - seen.ActiveThisRound = engine.CoActiveThisRound(*round, o.eng, cb.Login) - } + seen.ActiveThisRound = engine.CoActiveThisRound(*round, o.eng, cb.Login) } o.eng.Co[dialect.NormalizeBotName(cb.Login)] = seen } diff --git a/internal/crq/service.go b/internal/crq/service.go index da51b40..4b3040d 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -358,21 +358,6 @@ func quotaFreeVerdict(v engine.FireVerdict) bool { return false } -// anyCoUncommanded reports whether some triggerable co-reviewer has no -// command recorded on the round yet — the cheap pre-filter for the deferred -// scan above (a round with every trigger already posted cannot defer again). -func anyCoUncommanded(r Round, p engine.Policy) bool { - for _, cp := range p.CoReviewerPolicies() { - if strings.TrimSpace(cp.Command) == "" { - continue - } - if r.Co(cp.Login).CommandID == 0 && !(dialect.IsCodexBot(cp.Login) && r.CodexCommandID != 0) { - return true - } - } - return false -} - func (s *Service) global(st State, now time.Time) engine.Global { return engine.Global{ SlotFree: st.SlotRound() == nil, @@ -1053,7 +1038,15 @@ func (s *Service) fireCoReviewWait(ctx context.Context, round Round, obs engine. } at := commandCreatedAt(cmds, id, now) adopts = append(adopts, adoptCmd{login: cp.Login, id: id, at: at}) - anchor = at + // The anchor is the round's evidence FLOOR, so it takes the earliest + // candidate. Overwriting it per co-reviewer both discarded the primary + // head-review floor above and left whichever policy config happened to + // list last — so with Codex commanded at 10:00 and Bugbot at 10:30 a + // SHA-less Codex clean summary at 10:05 fell below the floor and was + // ignored, exactly the loss this anchor exists to prevent. + if at.Before(anchor) { + anchor = at + } } changed := false updated, err := s.store.Update(ctx, func(st *State) error { @@ -1713,6 +1706,9 @@ func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, in enqueued := false var lastLog time.Time var lastFeedbackCheck time.Time + // primaryUnavailable is refreshed by each feedback pass and gates the + // quota-free bypass, so the common round pays for one observation per tick. + var primaryUnavailable bool feedbackCheckEvery := queuedFeedbackCheckEvery(s.cfg.PollInterval) for { if s.cfg.WaitTimeout > 0 && s.clock().Sub(start) > s.cfg.WaitTimeout { @@ -1770,6 +1766,7 @@ func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, in return PumpResult{}, 1, err } lastFeedbackCheck = time.Now() + primaryUnavailable = report.PrimaryUnavailable // Return current-head findings immediately, plus a delayed review // attached to the previous commit when it actually arrived after this // round was queued. The latter is real new feedback, not the old carried @@ -1806,14 +1803,24 @@ func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, in // is pure latency on a round whose co-reviewers are ready to run now. // Resolve THIS PR's round directly instead of waiting for the global pump // to select it. - result, handled, err := s.advanceQuotaFree(ctx, repo, pr) - if err != nil { - return PumpResult{}, 1, err + // Only attempt the bypass when the last feedback pass said the primary + // will not review this head. Running it unconditionally observed the PR + // in full and then let Pump observe the very same PR again a few lines + // later — two round trips per tick on the hot polling loop, for a + // bypass that applies to a small minority of rounds. + result, handled := PumpResult{}, false + if primaryUnavailable { + var aerr error + result, handled, aerr = s.advanceQuotaFree(ctx, repo, pr) + if aerr != nil { + return PumpResult{}, 1, aerr + } } if !handled { - result, err = s.Pump(ctx) - if err != nil { - return PumpResult{}, 1, err + var perr error + result, perr = s.Pump(ctx) + if perr != nil { + return PumpResult{}, 1, perr } } state, _, err := s.store.Load(ctx) diff --git a/internal/engine/coreview.go b/internal/engine/coreview.go index d91c40a..bf036b2 100644 --- a/internal/engine/coreview.go +++ b/internal/engine/coreview.go @@ -199,8 +199,13 @@ func coReviewedHead(obs Observation, login string) bool { // CodexActiveThisRound.) func CoActiveThisRound(r state.Round, obs Observation, login string) bool { cutoff := coCutoff(r, login) - return coReviewedRound(r, obs, login, cutoff) || coCommentedRound(obs, login, cutoff) || - coVerdictSince(obs, login, cutoff) || coCheckAny(obs, login) + if coReviewedRound(r, obs, login, cutoff) || coCommentedRound(obs, login, cutoff) || + coVerdictSince(obs, login, cutoff) || coCheckAny(obs, login) { + return true + } + // Codex's thumbs-up quirk is applied here so callers never branch on a bot + // identity themselves — engine owns which bots have quirks. + return dialect.IsCodexBot(login) && obs.CodexThumbsUp } // coVerdictSince reports a per-round verdict comment (Macroscope's diff --git a/internal/gh/checks.go b/internal/gh/checks.go index cae8b6a..36b3dce 100644 --- a/internal/gh/checks.go +++ b/internal/gh/checks.go @@ -45,13 +45,13 @@ func (g *GitHub) ListCheckRuns(ctx context.Context, repo, ref string) ([]CheckRu return nil, err } if resp.StatusCode == http.StatusNotFound { - io.Copy(io.Discard, resp.Body) - resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() return nil, ErrNotFound } if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - resp.Body.Close() + _ = resp.Body.Close() return nil, &APIError{Method: http.MethodGet, URL: next, Status: resp.StatusCode, Body: string(b)} } var envelope struct { @@ -59,7 +59,7 @@ func (g *GitHub) ListCheckRuns(ctx context.Context, repo, ref string) ([]CheckRu } decodeErr := json.NewDecoder(resp.Body).Decode(&envelope) link := resp.Header.Get("Link") - resp.Body.Close() + _ = resp.Body.Close() if decodeErr != nil { return nil, decodeErr } From 47299c4645b94c712498429d8d6f2d499ee1e867 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 01:21:40 +0200 Subject: [PATCH 20/31] Address the rest of the CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Working the whole list rather than the correctness subset. Fixtures now derive from the production parser instead of restating it: defaultCoBots and codexCoBots call parseCoBots, so a new co-reviewer or a changed trigger default cannot leave replay coverage asserting a world that no longer exists. The replay's Macroscope settled marker comes from the golden corpus too — that file's stated invariant is that a reword which breaks the classifier breaks the replay, and a hardcoded "✅ Resolved in" would have quietly survived one. Two tests could pass vacuously. The check-run fake returned the first prefix match while ranging a map, so seeding two refs where one prefixes the other gave order-dependent results; it now matches exactly first and uses the prefix path only when it is unambiguous. The ETag test served 200 again on an unconditional second request, so a regression that stopped sending If-None-Match still produced two calls and the same payload; it now fails that request and asserts the conditional header. Added the state coverage that was missing for the subtlest paths: ClearCoClaim's empty-entry prune (delete the key, nil the map, zero the legacy mirror) and foldLegacyCodex's clearing direction, which its own doc claims but nothing asserted, plus the archive fold. Docs: the legacy CRQ_CODEX_* variables are documented as still-honored aliases rather than implied removed; the summary-only text no longer describes convergence as Codex-specific now that the gate keys on login; and AGENTS.md's fire-guard order matches DecideFire, including why the unavailable-primary and already-reviewed steps resolve before the slot and quota gates. Also: the primary command scan reuses newestCommand instead of a drifted copy, and skipAppliesToHead's doc comment sits above skipAppliesToHead. --- AGENTS.md | 9 ++-- README.md | 9 +++- internal/crq/coreview_replay_test.go | 39 ++++++++++++++---- internal/crq/feedback.go | 10 ++--- internal/crq/service_test.go | 32 ++++++++++----- internal/engine/engine_test.go | 8 ++-- internal/engine/fire.go | 9 +--- internal/gh/github_test.go | 16 +++++++- internal/state/state_test.go | 61 ++++++++++++++++++++++++++++ llms.txt | 4 +- 10 files changed, 154 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 802ee96..510882d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,9 +82,12 @@ One flow drives both the daemon and the loop: carries the raw reviews/comments so `Feedback` parses findings from the same fetch. Built once per decision. 2. **decide** (`internal/engine`) — pure. `DecideFire` consolidates every fire - guard in order (open → head readable → head current → phase eligible → slot - free → account quota → min interval → not already reviewed → adopt/post); - nothing else may post the review command. `Progress` transitions a + guard in order: open → head readable → head current → phase eligible → + **primary review unavailable** → slot free → **already reviewed** → account + quota → min interval → adopt/post. The two bold steps resolve BEFORE the + slot and quota gates on purpose: neither spends CodeRabbit quota, so an + unrelated PR's block or in-flight review must not delay them. Nothing else + may post the review command. `Progress` transitions a fired/reviewing round. `Completion` answers convergence. 3. **apply** (`crq/service.go`) — the only effects executor: CAS state writes + `PostIssueComment`. `DryRun` short-circuits apply into "report, write nothing". diff --git a/README.md b/README.md index ea1cc10..5cf97f0 100644 --- a/README.md +++ b/README.md @@ -432,6 +432,11 @@ Set these in `~/.config/crq/env` (sourced automatically) or as environment varia | `CRQ_COBOT__CMD` | `@codex review` / `bugbot run` / `@macroscope-app review` | that bot's trigger comment; empty forces `never` | | `CRQ_COBOT__GRACE` | `10m` | how long a `selfheal` trigger waits for the bot to show up on its own before nudging | | `CRQ_RL_CO_DEGRADE` | on | while CodeRabbit is rate-limited, run co-reviewer-only rounds instead of waiting the window out; set `0` to disable (legacy alias: `CRQ_RL_CODEX_DEGRADE`) | + +The pre-co-reviewer Codex variables are still read as legacy aliases, so existing configs keep +working: `CRQ_CODEX_CMD` is an alias of `CRQ_COBOT_CODEX_CMD` (the per-bot key wins when both are +set) and `CRQ_RL_CODEX_DEGRADE` an alias of `CRQ_RL_CO_DEGRADE`. Prefer the `CRQ_COBOT_*` names in +new configuration. | `CRQ_FEEDBACK_BOTS` | required bots + enabled co-reviewers | bots whose findings are surfaced — a superset of required bots, so co-reviewer findings show up without gating convergence on repos where those bots aren't installed | | `CRQ_TZ` | `UTC` | dashboard display timezone (IANA name, e.g. `Europe/Oslo`) | | `CRQ_MIN_INTERVAL` | `90s` | minimum time between fired reviews | @@ -522,8 +527,8 @@ one per push. but only *summarizes* private ones — CodeRabbit posts its walkthrough, tags it `🎁 Summarized by CodeRabbit Free`, and never submits a review, however often it is asked. crq reads that notice and runs the co-reviewers alone on those PRs: it posts no `@coderabbitai review`, takes no fire slot, -spends no account quota or pacing interval, and converges the round on Codex's review instead of -waiting out a review that cannot arrive. Every enabled co-reviewer gates such a round — they are its +spends no account quota or pacing interval, and converges the round on whichever enabled +co-reviewers actually respond — the completion gate keys on login, not on Codex. Every enabled co-reviewer gates such a round — they are its only reviewers — while a configured-but-absent bot still can't wedge it. There is nothing to configure and nothing to reset: upgrade to Pro and crq starts firing CodeRabbit again on the next observation. diff --git a/internal/crq/coreview_replay_test.go b/internal/crq/coreview_replay_test.go index ca2ada6..81c72f6 100644 --- a/internal/crq/coreview_replay_test.go +++ b/internal/crq/coreview_replay_test.go @@ -23,20 +23,24 @@ const ( macroLogin = dialect.MacroscopeLogin ) -// defaultCoBots mirrors LoadConfig's defaults for the three known bots. -func defaultCoBots() []CoBotConfig { - return []CoBotConfig{ - {Login: dialect.CodexBotLogin, Name: "codex", Command: "@codex review", Trigger: engine.TriggerNever, SelfHealGrace: 10 * time.Minute}, - {Login: bugbotLogin, Name: "bugbot", Command: "bugbot run", Trigger: engine.TriggerSelfHeal, SelfHealGrace: 10 * time.Minute}, - {Login: macroLogin, Name: "macroscope", Command: "@macroscope-app review", Trigger: engine.TriggerSelfHeal, SelfHealGrace: 10 * time.Minute}, +// defaultCoBots returns the production defaults by running the real parser on +// an empty environment. Duplicating the defaults here let them drift silently: +// a new co-reviewer, or a changed trigger default, would leave replay coverage +// asserting a world that no longer exists. +func defaultCoBots(t *testing.T) []CoBotConfig { + t.Helper() + co, err := parseCoBots(map[string]string{}, nil) + if err != nil { + t.Fatalf("parseCoBots defaults: %v", err) } + return co } func newCoReplayFixture(t *testing.T, base time.Time, mutate func(*Config)) *replayFixture { t.Helper() clk := newReplayClock(base) cfg := replayConfig() - cfg.CoBots = defaultCoBots() + cfg.CoBots = defaultCoBots(t) cfg.FeedbackBots = unionBots(cfg.RequiredBots, []string{dialect.CodexBotLogin, bugbotLogin, macroLogin}) if mutate != nil { mutate(&cfg) @@ -60,6 +64,25 @@ func requireBugbot(cfg *Config) { } } +// macroscopeSettled appends Macroscope's settled marker to a finding body, +// taking the wording from the golden corpus rather than a literal — this +// file's invariant is that a reword which breaks the classifier breaks the +// replay too, and a hardcoded marker would silently survive one. +func macroscopeSettled(t *testing.T, body, sha string) string { + t.Helper() + settled := corpusMessage(t, "macroscope/inline-finding-resolved.md") + marker := dialect.MacroscopeResolvedInSHA(settled) + if marker == "" { + t.Fatal("corpus resolved-finding no longer carries a settled marker") + } + idx := strings.Index(settled, "✅") + if idx < 0 { + t.Fatal("corpus resolved-finding no longer uses the ✅ settled form") + } + line := strings.SplitN(settled[idx:], "\n", 2)[0] + return body + "\n\n" + strings.Replace(line, marker, sha, 1) +} + // corpusCheckRun loads one captured check-run object from the dialect corpus. func corpusCheckRun(t *testing.T, name string) ghapi.CheckRun { t.Helper() @@ -301,7 +324,7 @@ func TestCoReplayMacroscopeResolvedEditSettlesFinding(t *testing.T) { // clears naturally (thread rebuttal machinery untouched: nothing surfaces // from these single-comment threads). f.threadsGraphQL([]map[string]any{ - threadNode("PRRT_1", false, "macroscopeapp", "preview/handlers.ts", 57, 901, "\n"+open+"\n\n✅ Resolved in "+sha, base.Add(-time.Minute)), + threadNode("PRRT_1", false, "macroscopeapp", "preview/handlers.ts", 57, 901, macroscopeSettled(t, open, sha), base.Add(-time.Minute)), threadNode("PRRT_2", false, "macroscopeapp", "Layers/ProjectionPipeline.ts", 1554, 902, resolved, base.Add(-4*time.Minute)), threadNode("PRRT_3", false, "macroscopeapp", "home/homeThreadList.ts", 105, 903, noLonger, base.Add(-4*time.Minute)), }) diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 47a771d..3d616a1 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -1204,11 +1204,6 @@ func dedupeFindings(in []dialect.Finding, suppressPromptAt, settledStableIDs map return out } -// skipAppliesToHead reports whether a "Review skipped" notice concerns the -// head under review. The refusal is per-head and narrowing the PR is the fix, -// so a skip of an EARLIER head must not keep surfacing: Loop treats findings as -// blocking before it enqueues, which would stop the replacement head from ever -// being submitted — the repair would permanently disable the reviewer. // skipPredatesHead reports whether a skip notice was posted before the current // head existed. A notice naming no commit cannot be bound by SHA, so it falls // back to the same head-commit cutoff every other issue comment uses — without @@ -1222,6 +1217,11 @@ func skipPredatesHead(comment ghapi.IssueComment, headCutoff func() time.Time) b return !cutoff.IsZero() && comment.CreatedAt.Before(cutoff) } +// skipAppliesToHead reports whether a "Review skipped" notice concerns the +// head under review. The refusal is per-head and narrowing the PR is the fix, +// so a skip of an EARLIER head must not keep surfacing: Loop treats findings as +// blocking before it enqueues, which would stop the replacement head from ever +// being submitted — the repair would permanently disable the reviewer. func skipAppliesToHead(body, head string) bool { skipped := dialect.ReviewSkippedHeadSHA(body) if skipped == "" || head == "" { diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 943268c..0bc24a7 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -73,11 +73,25 @@ func (f *fakeGitHub) ListCheckRuns(_ context.Context, _ string, ref string) ([]g if err := f.checkRunErrs[ref]; err != nil { return nil, err } + if runs, ok := f.checkRuns[ref]; ok { + return append([]ghapi.CheckRun(nil), runs...), nil + } + // Prefix fallback (tests seed short SHAs, observe asks with the full one), + // but only when exactly one key matches: returning from a map range picked + // an arbitrary entry whenever two refs prefixed each other. + var match []ghapi.CheckRun + found := 0 for key, runs := range f.checkRuns { + if key == "" || ref == "" { + continue + } if strings.HasPrefix(ref, key) || strings.HasPrefix(key, ref) { - return append([]ghapi.CheckRun(nil), runs...), nil + match, found = runs, found+1 } } + if found == 1 { + return append([]ghapi.CheckRun(nil), match...), nil + } return nil, nil } @@ -583,15 +597,13 @@ func TestRenewLeaderRespectsLiveLease(t *testing.T) { // with Codex's historical default trigger: post at fire time exactly when it // is configured-required (parseCoBots' codex rule). func codexCoBots(requiredBots []string) []CoBotConfig { - trigger := engine.TriggerNever - if dialect.HasCodexBot(requiredBots) { - trigger = engine.TriggerAlways - } - return []CoBotConfig{{ - Login: dialect.CodexBotLogin, Name: "codex", Command: "@codex review", - Trigger: trigger, Required: dialect.HasCodexBot(requiredBots), - SelfHealGrace: 10 * time.Minute, - }} + // Derived from the production parser so the command, grace, and the + // required→always mapping cannot drift from what crq actually does. + co, err := parseCoBots(map[string]string{"CRQ_COBOTS": "codex"}, requiredBots) + if err != nil { + panic("parseCoBots codex: " + err.Error()) + } + return co } func firingConfig() Config { diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index bbe165b..76ce9c3 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -371,9 +371,11 @@ func TestCommandHasCompletionReply(t *testing.T) { } } -// TestDecideCodexPost is the Codex trigger-post decision matrix: crq posts its Codex -// command only for a configured-required Codex that does not auto-review and has -// not already been asked (evidence, an existing command, or a recorded id). +// TestDecideCodexPost is the Codex trigger-post decision matrix. Posting is now +// governed by CoReviewerPolicy.Trigger, not by RequiredBots membership — these +// cases run under the always-mode policy withCodex builds, and assert the +// mode-independent guards: no auto-review, no live command, no recorded id, and +// no existing head evidence. func TestDecideCodexPost(t *testing.T) { codexReq := Policy{ Bot: "coderabbitai[bot]", diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 8681600..01e7764 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -167,14 +167,7 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic // Adopt the newest already-posted command instead of posting a duplicate. // observe() has already applied the adoption cutoffs (LastAttemptAt, // force-push, already-answered). - var newest *CommandSeen - for i := range obs.Commands { - c := obs.Commands[i] - if newest == nil || c.CreatedAt.After(newest.CreatedAt) { - newest = &c - } - } - if newest != nil { + if newest := newestCommand(obs.Commands); newest != nil { at := newest.CreatedAt if at.IsZero() { at = newest.UpdatedAt diff --git a/internal/gh/github_test.go b/internal/gh/github_test.go index da9ef88..3eeff88 100644 --- a/internal/gh/github_test.go +++ b/internal/gh/github_test.go @@ -656,12 +656,21 @@ func TestListCheckRunsEnvelopePagination(t *testing.T) { // envelope instead of failing or double-charging the REST quota. func TestListCheckRunsRidesETagCache(t *testing.T) { t.Setenv("GITHUB_TOKEN", "test-token") - var calls int32 + var calls, conditional int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if atomic.AddInt32(&calls, 1) > 1 && r.Header.Get("If-None-Match") == `"v1"` { + n := atomic.AddInt32(&calls, 1) + if r.Header.Get("If-None-Match") == `"v1"` { + atomic.AddInt32(&conditional, 1) w.WriteHeader(http.StatusNotModified) return } + // A second unconditional request means the ETag was not replayed. Fail + // loudly instead of serving 200 again, which let the old assertion + // (2 calls, same payload) pass even when caching had regressed. + if n > 1 { + w.WriteHeader(http.StatusInternalServerError) + return + } w.Header().Set("ETag", `"v1"`) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"total_count":1,"check_runs":[{"id":9,"name":"Cursor Bugbot","status":"in_progress","app":{"slug":"cursor"}}]}`)) @@ -688,4 +697,7 @@ func TestListCheckRunsRidesETagCache(t *testing.T) { if got := atomic.LoadInt32(&calls); got != 2 { t.Fatalf("expected 2 HTTP calls (200 then 304), got %d", got) } + if got := atomic.LoadInt32(&conditional); got != 1 { + t.Fatalf("the second call must carry If-None-Match, got %d conditional requests", got) + } } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 20b763f..b0598c5 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -360,3 +360,64 @@ func TestNormalizeFoldsLegacyCodex(t *testing.T) { t.Fatalf("round-trip lost bugbot entry: %+v", back) } } + +// TestClearCoClaimPrunesTheEntry covers setCo's empty() branch — the subtlest +// path in this file: it must delete the key, nil the map when it was the last +// entry, and zero the legacy Codex mirror. A regression that kept an all-zero +// entry would leave "cobots" in every serialized round and, worse, leave a +// stale legacy claim visible to old binaries. +func TestClearCoClaimPrunesTheEntry(t *testing.T) { + var r Round + r.ClaimCo(dialect.CodexBotLogin, t0) + if r.CodexClaimedAt == nil || len(r.CoBots) != 1 { + t.Fatalf("precondition: claim must be recorded, got %+v", r) + } + r.ClearCoClaim(dialect.CodexBotLogin) + if r.CoBots != nil { + t.Fatalf("the last empty entry must prune the map, got %#v", r.CoBots) + } + if r.CodexClaimedAt != nil { + t.Fatalf("clearing must zero the legacy mirror, got %v", r.CodexClaimedAt) + } + data, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "cobots") { + t.Fatalf("a pruned map must not serialize: %s", data) + } + // Clearing one bot must not disturb another's entry. + var two Round + two.SetCoCommand("cursor[bot]", 7, t0) + two.ClaimCo(dialect.CodexBotLogin, t0) + two.ClearCoClaim(dialect.CodexBotLogin) + if got := two.Co("cursor[bot]").CommandID; got != 7 { + t.Fatalf("clearing codex disturbed bugbot's entry: %d", got) + } +} + +// TestNormalizeFoldClearsAStaleMirror pins the direction foldLegacyCodex's doc +// claims but nothing asserted: legacy fields are authoritative BOTH ways, so an +// old binary that zeroed them must clear the mirror rather than leave crq +// acting on a command that no longer exists. Also covers the archive fold. +func TestNormalizeFoldClearsAStaleMirror(t *testing.T) { + var r Round + r.Repo, r.PR, r.Head, r.Phase = "owner/repo", 1, "abcdef123", PhaseFired + r.SetCoCommand(dialect.CodexBotLogin, 11, t0) + // Simulate an old binary clearing only the legacy fields it knows about. + r.CodexCommandID, r.CodexCommandedAt, r.CodexClaimedAt = 0, nil, nil + + s := New() + s.Rounds[Key(r.Repo, r.PR)] = r + archived := r + archived.PR = 2 + s.Archive = append(s.Archive, archived) + s.Normalize(t0) + + if got := s.Round("owner/repo", 1).Co(dialect.CodexBotLogin).CommandID; got != 0 { + t.Fatalf("an emptied legacy set must clear the mirror, got command %d", got) + } + if got := s.Archive[0].Co(dialect.CodexBotLogin).CommandID; got != 0 { + t.Fatalf("the archive must be folded too, got command %d", got) + } +} diff --git a/llms.txt b/llms.txt index c6d3031..4228e6b 100644 --- a/llms.txt +++ b/llms.txt @@ -123,8 +123,8 @@ Summary-only plans: on a private repo under a Free CodeRabbit plan the bot posts never a line-by-line review ("Summarized by CodeRabbit Free … upgrade … for a comprehensive line-by-line review"). crq reads that notice and runs the co-reviewers alone there — no `@coderabbitai review` is posted, no fire slot or account quota is spent, and the round converges on -Codex's review instead of hanging on one that cannot arrive. Public repos get Pro-grade reviews for -free and are unaffected. Nothing to configure: upgrade the plan and crq resumes firing CodeRabbit on +the enabled co-reviewers' evidence instead of hanging on a review that cannot +arrive. Public repos get Pro-grade reviews for free and are unaffected. Nothing to configure: upgrade the plan and crq resumes firing CodeRabbit on the next observation. Resolve addressed threads on GitHub (crq keys off GitHub resolution state; a finding keeps From 1ca3268375b4fdb2e54b92a65f7a203db3fc0830 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 01:23:11 +0200 Subject: [PATCH 21/31] Read CodeRabbit's withdrawal marker as a concession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfooded on this PR: CodeRabbit accepted a declined finding — "you're right, thank you for the precise correction … this finding should be withdrawn" — and crq surfaced it as "Reviewer contests your reply". A settled finding re-surfacing as a rebuttal is exactly what stops a loop converging. The prose heuristics could not have caught it; concession wording varies freely and none of the stock phrases appear. But CodeRabbit ships a machine-readable marker alongside it, which is unambiguous, so that is matched first and "should be withdrawn" added to the prose fallbacks. The reply is captured verbatim as corpus with its own golden row. --- internal/dialect/golden_test.go | 5 ++++ internal/dialect/reply.go | 12 +++++++- .../coderabbit/reply-withdrawn-marker.md | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 internal/dialect/testdata/coderabbit/reply-withdrawn-marker.md diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index b33dd4a..85bd368 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -241,6 +241,11 @@ func TestGoldenReplyVerdict(t *testing.T) { retained bool }{ {file: "coderabbit/reply-withdrawn.md", withdrawn: true}, + // A concession whose PROSE reads like agreement, not like the stock + // "withdrawing this" phrasing. CodeRabbit ships a machine-readable + // marker with it; matching that is what keeps a settled finding from + // re-surfacing as a rebuttal and blocking convergence. + {file: "coderabbit/reply-withdrawn-marker.md", withdrawn: true}, {file: "coderabbit/reply-retained.md", retained: true}, } for _, tc := range cases { diff --git a/internal/dialect/reply.go b/internal/dialect/reply.go index da3e4a2..a6d8e85 100644 --- a/internal/dialect/reply.go +++ b/internal/dialect/reply.go @@ -10,11 +10,21 @@ import "strings" // surfacing decision lives in crq/feedback. The golden corpus pins CodeRabbit's // real phrasing from testdata/coderabbit/reply-*.md. +// reviewWithdrawnMarker is CodeRabbit's machine-readable concession marker. It +// beats every prose heuristic below: the wording of a concession varies freely +// ("you're right—thank you for the precise correction"), and misreading one as +// a rebuttal re-surfaces a settled finding and blocks convergence. +const reviewWithdrawnMarker = "" + // IsReviewFindingWithdrawn reports whether a bot's reply concedes and withdraws // its finding — the agent's decline stands and the thread is done. func IsReviewFindingWithdrawn(text string) bool { + if strings.Contains(text, reviewWithdrawnMarker) { + return true + } t := NormalizeReviewText(text) - return strings.Contains(t, "withdrawing this") || + return strings.Contains(t, "should be withdrawn") || + strings.Contains(t, "withdrawing this") || strings.Contains(t, "withdrawing the finding") || strings.Contains(t, "withdrawing my") || strings.Contains(t, "i'll withdraw") || diff --git a/internal/dialect/testdata/coderabbit/reply-withdrawn-marker.md b/internal/dialect/testdata/coderabbit/reply-withdrawn-marker.md new file mode 100644 index 0000000..c52e6b3 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/reply-withdrawn-marker.md @@ -0,0 +1,28 @@ +`@kristofferR`, you’re right—thank you for the precise correction. I conflated a GitHub-level conclusion with the bot-specific meaning of that conclusion. + +`startup_failure` is already handled centrally, while `skipped` must remain classified by each bot: Bugbot treats it as non-delivery, but Macroscope’s “No code objects were reviewed” is valid clean-review evidence. I have no contrary Macroscope capture, so this finding should be withdrawn. + +🐇 thoughtful rabbit nod + + + +--- + +
+✏️ Learnings added + +``` +Learnt from: kristofferR +Repo: kristofferR/coderabbit-queue PR: 35 +File: internal/dialect/coreviewer.go:16-21 +Timestamp: 2026-07-24T23:19:08.208Z +Learning: In the Go co-reviewer dialect registry, `internal/dialect/coreviewer.go` deliberately does not classify the GitHub Check Run conclusion `skipped` in shared `checkRunFailed`: delivery semantics are bot-specific. `ClassifyBugbotCheck` treats `skipped` as `CheckFailed`, whereas Macroscope Correctness can treat a `skipped` check with “No code objects were reviewed” as `CheckDoneClean`. Captured corpus fixtures and `TestCheckRunsThatAreNotReviewEvidence` define these semantics. +``` + +> Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings). + +
+ + + + From ab79d5e823e1dc718af348852eb9bd9bd5c47917 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 01:27:22 +0200 Subject: [PATCH 22/31] Anchor a co-review wait at the head, not at observation time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third gap found by dogfooding, this one a repeatable 20-minute timeout on krisHQ#1021: every round waited the full deadline and failed identically. On a summary-only PR there is no CodeRabbit review to anchor the co-review wait on, and crq posts no trigger because the co-reviewer auto-reviews. The anchor therefore fell through to "now" — the moment crq happened to look, which lands AFTER the co-reviewer's existing answer for that head. The wait became unsatisfiable by construction: Codex had already spoken and had no reason to speak again for an unchanged head, so the round could only ever time out. The evidence floor must be when the HEAD appeared, which observe now supplies. Self-heal was unreachable on the same rounds for a related reason: it measures grace from the fire, and these rounds never fire. It now falls back to the head commit — but only for a round that will never fire. Applying that fallback generally posted the trigger in the same breath as the fire, since the head is usually older than the grace period, before the bot had any chance to review the round at all. The regression test reproduces the production symptom: without the fix the co-reviewer reads as not-reviewed on a head it had already cleared. --- internal/crq/coreview_replay_test.go | 55 ++++++++++++++++++++++++++++ internal/crq/service.go | 14 +++++-- internal/engine/fire.go | 23 +++++++++++- 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/internal/crq/coreview_replay_test.go b/internal/crq/coreview_replay_test.go index 81c72f6..459f23c 100644 --- a/internal/crq/coreview_replay_test.go +++ b/internal/crq/coreview_replay_test.go @@ -577,3 +577,58 @@ func TestCoReplayAlwaysModePostsWithFireAndHealsAfterFailure(t *testing.T) { t.Fatalf("no repost after the heal, got %d", got) } } + +// --- 8. the summary-only co-review wait must be satisfiable ------------------ + +// TestCoReplaySummaryOnlyWaitAcceptsExistingAnswer reproduces a repeatable +// 20-minute timeout hit while dogfooding krisHQ#1021. +// +// On a summary-only PR there is no CodeRabbit review to anchor the co-review +// wait on, and crq posts no trigger because the co-reviewer auto-reviews. The +// anchor therefore fell through to "now", i.e. the moment crq happened to look +// — which lands AFTER the co-reviewer's existing answer for this head. The wait +// could then never be satisfied: the bot had already spoken and would not speak +// again for an unchanged head, so every round timed out identically. +// +// The floor must be when the HEAD appeared, not when crq noticed it. +func TestCoReplaySummaryOnlyWaitAcceptsExistingAnswer(t *testing.T) { + base := time.Date(2026, 7, 25, 9, 0, 0, 0, time.UTC) + f := newCoReplayFixture(t, base, func(cfg *Config) { + cfg.RequiredBots = append(cfg.RequiredBots, dialect.CodexBotLogin) + }) + repo, pr, sha := "o/private", 1021, "23179e9aa1234567" + f.openPull(repo, pr, sha) + // The head was pushed well before this round is observed. + f.setCommitDate(sha, base.Add(-30*time.Minute)) + + // CodeRabbit's Free-plan walkthrough: no review is ever coming. + f.botComment(repo, pr, 900, corpusMessage(t, "coderabbit/summary-only-free-plan.md"), base.Add(-25*time.Minute)) + // Codex auto-reviewed and found nothing, before crq looked. Its clean + // summary names no SHA (the legacy shape), so the round's evidence floor is + // the ONLY thing that can bind it to this head — which is precisely why a + // floor set at observation time strands it. + f.codexComment(repo, pr, 901, corpusMessage(t, "codex/clean-summary-legacy.md"), base.Add(-20*time.Minute)) + + f.enqueue(repo, pr) + f.pump() + + // crq must not have asked CodeRabbit for anything. + if got := f.reviewsPosted(repo, pr); got != 0 { + t.Fatalf("a summary-only round must never post the review command, got %d", got) + } + rep, err := f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatal(err) + } + if !rep.PrimaryUnavailable { + t.Fatalf("precondition: the round must read as primary-unavailable, got %+v", rep) + } + // The answer that already exists must satisfy the round rather than being + // stranded below a floor set at observation time. + if !rep.ReviewedBy[dialect.CodexBotLogin] { + t.Fatalf("the co-reviewer's existing answer for this head must count: %#v", rep.ReviewedBy) + } + if !rep.Converged { + t.Fatalf("the round must converge instead of waiting out the deadline: %+v", rep) + } +} diff --git a/internal/crq/service.go b/internal/crq/service.go index 4b3040d..84f719b 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -1013,11 +1013,17 @@ func (s *Service) fireCoReviewWait(ctx context.Context, round Round, obs engine. if s.cfg.DryRun { return result, nil } - // The anchor is the wait's evidence floor. Prefer an adopted trigger - // command's time; with no command (auto-review) fall back to the primary - // bot's head review — a SHA-less legacy clean summary posted after either - // must count, or an answer that already exists is hidden until the deadline. + // The anchor is the wait's evidence floor, so it must be when this HEAD + // appeared — not when crq happened to notice it. Defaulting to now was a + // repeatable 20-minute timeout on a primary-unavailable round: with no + // CodeRabbit review to anchor on and no trigger posted (the co-reviewer + // auto-reviews), the floor landed after the co-reviewer's existing answer + // for this head, so the wait could never be satisfied and the bot had no + // reason to speak again. The candidates below only narrow it further. anchor := now + if !obs.HeadAt.IsZero() { + anchor = obs.HeadAt + } for _, rv := range obs.Reviews { if isConfiguredBotLogin(s.cfg.Bot, rv.Bot) && rv.Commit != "" && strings.HasPrefix(rv.Commit, round.Head) && !rv.SubmittedAt.IsZero() && rv.SubmittedAt.Before(anchor) { diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 01e7764..e6b2a11 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -71,6 +71,26 @@ func requiredBot(p Policy, login string) bool { return false } +// selfHealAnchor is what a selfheal trigger measures its grace against: the +// round's fire. +// +// neverFires supplies the fallback for a round that will not get one — a +// summary-only or skipped head resolves without crq ever posting the primary +// command, so a fire-only anchor left those rounds permanently ineligible for +// the trigger that could rescue them. It is deliberately NOT used on a round +// that is about to fire: there the head commit is typically old, and treating +// that as elapsed grace would post the trigger in the same breath as the fire, +// before the bot has had any chance to review this round at all. +func selfHealAnchor(r state.Round, obs Observation, neverFires bool) time.Time { + if at := roundCutoff(r); !at.IsZero() { + return at + } + if neverFires { + return obs.HeadAt + } + return time.Time{} +} + // decideCoPosts collects the co-reviewer logins whose trigger crq should post // while firing this round. Fire-time posting is the always-mode path; a // selfheal trigger anchors on the fire and so never posts before it. @@ -265,13 +285,14 @@ func newestCommand(commands []CommandSeen) *CommandSeen { func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time, primaryUnavailable bool) FireDecision { var post []string wait := false + anchor := selfHealAnchor(r, obs, primaryUnavailable) for _, cp := range p.coReviewers() { co := obs.co(cp.Login) gates := requiredBot(p, cp.Login) || co.AutoActive || primaryUnavailable if !gates || coReviewedHead(obs, cp.Login) { continue } - if DecideCoPost(r, obs, cp, len(co.Commands) > 0, time.Time{}, now) { + if DecideCoPost(r, obs, cp, len(co.Commands) > 0, anchor, now) { post = append(post, cp.Login) continue } From 384f84c53407b10cb150386ff582b8f47bfacd8a Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 01:31:59 +0200 Subject: [PATCH 23/31] Add favicon.png Repository asset, unrelated to the co-reviewer work in this branch; kept as its own commit so the review diff stays about the code. --- favicon.png | Bin 0 -> 114525 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 favicon.png diff --git a/favicon.png b/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..cd42b9c7a4b644ba60f644572b5efb0e1dfe36ef GIT binary patch literal 114525 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 Date: Sat, 25 Jul 2026 02:10:25 +0200 Subject: [PATCH 24/31] Address the latest review round: activity, cutoffs, and fail-closed checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings, several of them consequences of the previous round. Excluding failed checks from trigger suppression also removed them from ActiveThisRound, because both read the same predicate. A bot whose FIRST check crashed therefore had no activity signal at all, so self-heal could never fire and the round could only time out. Suppression and activity are now separate questions: a crashed run suppresses nothing but still counts as the bot working on this PR. An unreadable check-run fetch looked identical to "the bot has not started", so after the grace period crq would post a trigger over a run that may well be in flight. The uncertainty is now carried as CoSeen.ChecksUnknown and fails closed — triggering is suppressed while the bounded completion wait still applies. Unable notices bound from FiredAt, missing a commanded co-reviewer's exhaustion notice posted before a deferred CodeRabbit fire; the round then waited for a review the bot had already said it could not produce. They bind from coCutoff now, like every other co-reviewer evidence. A required co-reviewer that had not spoken yet let a primary-unavailable round dedupe to completed — which Completion immediately contradicts by still reporting that bot, and so the primary, unfinished, leaving Loop to time out and requeue into the same cycle. It holds a bounded wait instead. A merely wanted bot still dedupes, so "wanted" cannot wedge a round. Finally, the skip finding vetoed the round it was meant to inform: Loop's pre-enqueue blocking check returned exit 10 on a finding with no thread to resolve, short-circuiting waitToFire so the co-reviewers that resolve a skipped round never ran. That guard no longer applies when the primary will not review the head — the co-reviewers ARE the round. --- internal/crq/feedback.go | 17 ++++++++-- internal/crq/observe.go | 10 +++++- internal/engine/completion.go | 7 +++- internal/engine/coreview.go | 19 ++++++++++- internal/engine/coreview_test.go | 55 ++++++++++++++++++++++++++++++++ internal/engine/engine.go | 6 ++++ internal/engine/fire.go | 9 ++++++ 7 files changed, 118 insertions(+), 5 deletions(-) diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 3d616a1..39bc012 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -305,7 +305,13 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe reason = "CodeRabbit skipped the review for this head." } report.Findings = append(report.Findings, dialect.Finding{ - Bot: comment.User.Login, + Bot: comment.User.Login, + // Bound to the head so Loop's pre-enqueue blocking check treats + // it as work for THIS head rather than a reason never to start: + // returning exit 10 here short-circuited waitToFire, so the + // co-reviewers that are supposed to resolve a skipped round + // never ran at all. + Commit: head, Severity: "major", Title: "CodeRabbit skipped this review — narrow the PR to get one: " + reason, Body: strings.TrimSpace(dialect.CompactReviewBody(comment.Body)), @@ -420,8 +426,15 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport } return report, 1, feedbackErr } + // The guard exists to stop a NEW review round starting while + // earlier findings are open. When the primary will not review + // this head, the co-reviewers are the entire round — and the + // skip notice itself is one of these findings, with no thread to + // resolve, so returning here short-circuited waitToFire forever + // and the co-reviewers never ran at all. Let the round start; + // the findings are still reported by the poll below. blocking := engine.BlockingFindings(report.Findings, head) - if len(blocking) > 0 { + if len(blocking) > 0 && !report.PrimaryUnavailable { report.Findings = blocking report.Status = "feedback" report.Reason = "unresolved findings must be addressed before a new review round" diff --git a/internal/crq/observe.go b/internal/crq/observe.go index 119e0d5..e571585 100644 --- a/internal/crq/observe.go +++ b/internal/crq/observe.go @@ -100,11 +100,16 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round // gated on FiredAt. An unfetchable check degrades to the bounded co-review // wait rather than failing the observation; the log line is the operator's // signal that a required check-bearing bot may time out spuriously. + checksUnknown := false if o.eng.Open && pull.Head.SHA != "" && s.coChecksRelevant() { runs, cerr := s.gh.ListCheckRuns(ctx, repo, pull.Head.SHA) if cerr != nil { + // Record the uncertainty rather than letting "no checks returned" + // masquerade as "the bot has not started": that read posts a trigger + // over a run already in flight. + checksUnknown = true if s.log != nil { - s.log.Printf("warning: check runs unavailable for %s#%d@%s: %v (check-bearing co-reviewer evidence degraded)", repo, pr, dialect.ShortOID(pull.Head.SHA), cerr) + s.log.Printf("warning: check runs unavailable for %s#%d@%s: %v (co-reviewer triggers suppressed this pass)", repo, pr, dialect.ShortOID(pull.Head.SHA), cerr) } } else { for _, run := range runs { @@ -155,6 +160,9 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round o.eng.Co = map[string]engine.CoSeen{} for _, cb := range s.cfg.CoBots { seen := engine.CoSeen{AutoActive: engine.CoAutoActive(o.eng, cb.Login)} + if co, ok := dialect.CoReviewerByName(cb.Name); ok && co.AppSlug != "" { + seen.ChecksUnknown = checksUnknown + } if round != nil { seen.ActiveThisRound = engine.CoActiveThisRound(*round, o.eng, cb.Login) } diff --git a/internal/engine/completion.go b/internal/engine/completion.go index 6646273..9019ecd 100644 --- a/internal/engine/completion.go +++ b/internal/engine/completion.go @@ -74,7 +74,12 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { // immediately marks the primary delivered and reports done — before // the only reviewer it actually has has said anything. commanded := roundCoCommandID(r, cp.Login) != 0 - if (co.AutoActive || co.ActiveThisRound || commanded) && !coUnableSince(obs, cp.Login, cutoff) { + // The unable notice binds from coCutoff, not the fire: a commanded + // co-reviewer can report exhaustion BEFORE a deferred CodeRabbit + // fire, and anchoring at FiredAt missed it — the round then waited + // for a review the bot had already said it could not produce. + if (co.AutoActive || co.ActiveThisRound || commanded) && + !coUnableSince(obs, cp.Login, coCutoff(r, cp.Login)) { reviewedBy[cp.Login] = false } } diff --git a/internal/engine/coreview.go b/internal/engine/coreview.go index bf036b2..417163a 100644 --- a/internal/engine/coreview.go +++ b/internal/engine/coreview.go @@ -103,6 +103,17 @@ func coCheckAny(obs Observation, login string) bool { return false } +// coCheckActivity reports whether login has ANY check run on this head, +// including failed and auxiliary ones. Suppression and activity are separate +// questions: a bot whose only check crashed must not suppress the self-heal +// trigger (coCheckAny), but it is still demonstrably working on this PR — and +// if that were not activity, a bot whose FIRST check failed would have no +// signal at all, so self-heal could never fire and the round could only time +// out. +func coCheckActivity(obs Observation, login string) bool { + return len(coChecks(obs, login)) > 0 +} + // coCheckReviewedAt reports the newest COMPLETED check verdict for the head // (Done or DoneClean — findings, if any, still gate via threads). func coCheckReviewedAt(obs Observation, login string) (time.Time, bool) { @@ -200,7 +211,7 @@ func coReviewedHead(obs Observation, login string) bool { func CoActiveThisRound(r state.Round, obs Observation, login string) bool { cutoff := coCutoff(r, login) if coReviewedRound(r, obs, login, cutoff) || coCommentedRound(obs, login, cutoff) || - coVerdictSince(obs, login, cutoff) || coCheckAny(obs, login) { + coVerdictSince(obs, login, cutoff) || coCheckActivity(obs, login) { return true } // Codex's thumbs-up quirk is applied here so callers never branch on a bot @@ -381,6 +392,12 @@ func DecideCoPost(r state.Round, obs Observation, cp CoReviewerPolicy, commandPr if coCheckAny(obs, cp.Login) { return false } + // Fail closed: with the head's checks unreadable, a missing check is not + // evidence the bot is idle, and posting would double-ask a run already in + // flight. + if obs.co(cp.Login).ChecksUnknown { + return false + } switch cp.Trigger { case TriggerAlways: return !obs.co(cp.Login).AutoActive diff --git a/internal/engine/coreview_test.go b/internal/engine/coreview_test.go index fed9ba6..97b1f16 100644 --- a/internal/engine/coreview_test.go +++ b/internal/engine/coreview_test.go @@ -508,3 +508,58 @@ func TestUnavailablePrimaryWaitsForTheBotItJustCommanded(t *testing.T) { t.Fatalf("a round must not complete right after commanding its only reviewer: %+v", got) } } + +// TestRequiredCoReviewerHoldsAnUnavailablePrimaryRound: with no primary review +// coming and a required co-reviewer that has not spoken yet, deduping wrote a +// completed marker that Completion immediately contradicts — it still reports +// that bot, and so the primary, unfinished — leaving Loop to time out and +// requeue into the same cycle. A bounded wait is the honest state. +func TestRequiredCoReviewerHoldsAnUnavailablePrimaryRound(t *testing.T) { + now := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) + head := "abcdef123" + queued := state.Round{Repo: "o/r", PR: 1, Head: head, Phase: state.PhaseQueued} + summaryOnly := dialect.BotEvent{Kind: dialect.EvOther, Bot: "coderabbitai[bot]", SummaryOnly: true, CommentID: 1} + obs := Observation{Head: head, Open: true, Events: []dialect.BotEvent{summaryOnly}} + + required := Policy{Bot: "coderabbitai[bot]", + RequiredBots: []string{"coderabbitai[bot]", bugbotLogin}, + CoReviewers: []CoReviewerPolicy{{Login: bugbotLogin, Command: "bugbot run", Trigger: TriggerSelfHeal}}} + if d := DecideFire(Global{SlotFree: true}, queued, obs, now, required); d.Verdict != FireCoReviewWait { + t.Fatalf("a silent REQUIRED co-reviewer must hold the round open, got %+v", d) + } + // A merely wanted bot in the same state still dedupes — "wanted" must not + // wedge a round on a bot that may never appear. + wanted := Policy{Bot: "coderabbitai[bot]", + RequiredBots: []string{"coderabbitai[bot]"}, + CoReviewers: []CoReviewerPolicy{{Login: bugbotLogin, Command: "bugbot run", Trigger: TriggerSelfHeal}}} + if d := DecideFire(Global{SlotFree: true}, queued, obs, now, wanted); d.Verdict != FireDedupe { + t.Fatalf("a wanted-only silent bot must not wedge the round, got %+v", d) + } +} + +// TestFailedCheckStaysActivityForSelfHeal: a crashed run must not suppress the +// recovery trigger, but it IS the bot working on this PR. If it were not +// activity, a bot whose FIRST check failed would have no signal at all and +// self-heal could never fire. +func TestFailedCheckStaysActivityForSelfHeal(t *testing.T) { + now := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) + head := "abcdef123" + fired := now.Add(-time.Hour) + round := state.Round{Head: head, FiredAt: &fired} + obs := Observation{Head: head, Open: true, + Checks: []CheckSeen{{Bot: bugbotLogin, Verdict: dialect.CheckFailed, CompletedAt: now.Add(-30 * time.Minute)}}} + + if !CoActiveThisRound(round, obs, bugbotLogin) { + t.Fatal("a failed check is still activity — otherwise self-heal has no signal to act on") + } + cp := CoReviewerPolicy{Login: bugbotLogin, Command: "bugbot run", Trigger: TriggerSelfHeal, SelfHealGrace: 10 * time.Minute} + obs.Co = map[string]CoSeen{dialect.NormalizeBotName(bugbotLogin): {ActiveThisRound: true}} + if !DecideCoPost(round, obs, cp, false, fired, now) { + t.Fatal("self-heal must be able to retrigger a bot whose check crashed") + } + // But an unreadable check-run fetch must fail closed: absence proves nothing. + obs.Co = map[string]CoSeen{dialect.NormalizeBotName(bugbotLogin): {ActiveThisRound: true, ChecksUnknown: true}} + if DecideCoPost(round, obs, cp, false, fired, now) { + t.Fatal("an unreadable check must suppress the trigger rather than double-ask a run in flight") + } +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 381d2ea..c18a109 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -115,6 +115,12 @@ type CoSeen struct { // review, a round-window comment/clean summary, or a head check run). It // drives the dynamic completion gate for non-required co-reviewers. ActiveThisRound bool + // ChecksUnknown reports that this bot's check runs could NOT be read for + // the head. Absence of a check then proves nothing, so crq must not treat + // it as "the bot has not started" and post a trigger — the run may be in + // flight. The bounded completion wait still applies; only triggering is + // suppressed. + ChecksUnknown bool } // Observation is everything the engine may know about one PR at one moment. diff --git a/internal/engine/fire.go b/internal/engine/fire.go index e6b2a11..833cc17 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -302,6 +302,15 @@ func coAwareDedupe(r state.Round, obs Observation, p Policy, now time.Time, prim // to publish. if co.AutoActive || co.ActiveThisRound || len(co.Commands) > 0 || roundCoCommandID(r, cp.Login) != 0 { wait = true + continue + } + // A REQUIRED co-reviewer with no activity yet still gates. Deduping here + // wrote a completed marker that Completion immediately contradicts — + // it reports the bot, and so the primary, unfinished — leaving Loop to + // time out and requeue into the same cycle. A bounded wait is the + // honest state: the bot has not spoken, and the deadline ends it. + if primaryUnavailable && requiredBot(p, cp.Login) { + wait = true } } delivered := "primary reviewed head" From afcb122f6028812be4ac303b5ef4e04a1d59323b Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 03:36:12 +0200 Subject: [PATCH 25/31] Keep co-reviewer-only rounds out of the requested-reviews table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard's "Recently requested" table was 16 of 20 rows from a private CodeRabbit-Free repo — one crq had never asked CodeRabbit about at all, and never could. It crowded the real request history off the end of the cap. FiredAt does double duty: the evidence-floor anchor, and "we requested a review". Those two meanings diverged the moment co-reviewer-only rounds existed — fireCoOnly records a fire whose CommandID is a CO-REVIEWER trigger, and fireCoReviewWait records one having posted nothing at all, so both looked like requested reviews to anything reading FiredAt. Round.CoOnly now marks a round that reached fired/reviewing without crq asking the primary for anything, and the requested-reviews table skips them. The rounds themselves are untouched: they keep the anchor their completion rules depend on and remain the dedup marker for their head. --- internal/crq/service.go | 2 ++ internal/state/dashboard.go | 14 ++++++++++---- internal/state/state.go | 9 +++++++++ internal/state/state_test.go | 29 +++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/internal/crq/service.go b/internal/crq/service.go index 84f719b..2e08a2b 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -953,6 +953,7 @@ func (s *Service) fireCoOnly(ctx context.Context, round Round, logins []string, r.Token = "" dl := firedAt.Add(s.cfg.FeedbackWaitTimeout) r.WaitDeadline = &dl + r.CoOnly = true // no primary review was requested for this round st.Warn = "" } for _, p := range posts { @@ -1065,6 +1066,7 @@ func (s *Service) fireCoReviewWait(ctx context.Context, round Round, obs engine. if err := r.AwaitCoReview(deadline, anchor); err != nil { return err } + r.CoOnly = true // waiting on co-reviewers; nothing was asked of the primary // Existing trigger commands are adopted as the round's command anchors // so the self-heal path (which anchors on the round's fire time, later // than a pre-existing command) does not re-post them. diff --git a/internal/state/dashboard.go b/internal/state/dashboard.go index c964534..f02b187 100644 --- a/internal/state/dashboard.go +++ b/internal/state/dashboard.go @@ -67,17 +67,23 @@ func firedAtOf(r Round) time.Time { return r.EnqueuedAt } -// requestedRounds gathers every round that has fired (active or archived) for -// the "Recently requested" table, newest first, capped. +// requestedRounds gathers every round for which crq actually REQUESTED a review +// (active or archived) for the "Recently requested" table, newest first, capped. +// +// CoOnly rounds are excluded: they carry a FiredAt because it anchors their +// evidence floor, but crq never asked the primary reviewer for anything. Listing +// them crowded the table with repos that cannot use the queue at all — on a +// CodeRabbit-Free private repo every push produced a row for a review that was +// never requested, pushing the real history off the end of the cap. func requestedRounds(st State) []Round { var out []Round for _, r := range st.Rounds { - if r.FiredAt != nil { + if r.FiredAt != nil && !r.CoOnly { out = append(out, r) } } for _, r := range st.Archive { - if r.FiredAt != nil { + if r.FiredAt != nil && !r.CoOnly { out = append(out, r) } } diff --git a/internal/state/state.go b/internal/state/state.go index 4fc3708..bb18e79 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -70,6 +70,15 @@ type Round struct { // Mutate only through Co/SetCoCommand/ClaimCo/ClearCoClaim. CoBots map[string]CoBotRound `json:"cobots,omitempty"` + // CoOnly marks a round that reached its fired/reviewing state WITHOUT crq + // requesting a primary review — a co-reviewer-only trigger, or a bounded + // co-review wait. FiredAt does double duty as the evidence-floor anchor and + // as "we asked for a review", and those two meanings diverged the moment + // co-reviewer-only rounds existed: the dashboard's requested-reviews table + // filled with repos crq had never asked CodeRabbit about. Anything counting + // CodeRabbit requests must skip these. + CoOnly bool `json:"co_only,omitempty"` + // RetryAt is the earliest time this head may fire again (awaiting_retry). RetryAt *time.Time `json:"retry_at,omitempty"` diff --git a/internal/state/state_test.go b/internal/state/state_test.go index b0598c5..eba331a 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -421,3 +421,32 @@ func TestNormalizeFoldClearsAStaleMirror(t *testing.T) { t.Fatalf("the archive must be folded too, got command %d", got) } } + +// TestRequestedRoundsExcludesCoOnly pins what the "Recently requested" table +// means: rounds for which crq actually asked the primary reviewer. A +// co-reviewer-only round carries a FiredAt because that anchors its evidence +// floor, not because a review was requested — and on a CodeRabbit-Free private +// repo every push produced one, which crowded the real request history off the +// end of the cap. +func TestRequestedRoundsExcludesCoOnly(t *testing.T) { + s := New() + fired := t0.Add(time.Minute) + + real := Round{Repo: "o/public", PR: 1, Head: "aaaaaaaa1", Seq: 1, Phase: PhaseCompleted, FiredAt: &fired} + s.Rounds[Key(real.Repo, real.PR)] = real + coOnly := Round{Repo: "o/private", PR: 2, Head: "bbbbbbbb2", Seq: 2, Phase: PhaseCompleted, FiredAt: &fired, CoOnly: true} + s.Rounds[Key(coOnly.Repo, coOnly.PR)] = coOnly + archivedCoOnly := coOnly + archivedCoOnly.PR = 3 + s.Archive = append(s.Archive, archivedCoOnly) + + got := requestedRounds(s) + if len(got) != 1 || got[0].Repo != "o/public" { + t.Fatalf("only genuinely requested reviews belong in the table, got %+v", got) + } + // The round itself is untouched — it is still the dedup marker and still + // carries its evidence anchor. + if r := s.Round("o/private", 2); r == nil || r.FiredAt == nil || !r.CoOnly { + t.Fatalf("a co-only round must keep its anchor and marker, got %+v", r) + } +} From b2066b58ab34e32b5db4f47d7c59d4e992d7003a Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 03:38:30 +0200 Subject: [PATCH 26/31] Backfill CoOnly for rounds written before the flag existed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exclusion only helped new rounds, leaving the dashboard's history still full of a repo crq had never asked CodeRabbit about. The evidence was already in the data, so Normalize infers it on load. Two signatures, both unambiguous. A bounded co-review wait anchors FiredAt without ever posting a command, so a fire with no CommandID is one by construction — every real fire records the command it posted or adopted. A co-reviewer-only fire recorded one of its OWN trigger comments as the round's CommandID, which a genuine request never does because that is a different comment. Verified against the live state: the archived krisHQ rounds carry command_id equal to their recorded Codex trigger and are now marked, while krishq#990 — which really did fire a review — keeps its place in the table. --- internal/state/state.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/state/state.go b/internal/state/state.go index bb18e79..0e6887a 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -199,6 +199,33 @@ func (r *Round) foldLegacyCodex() { r.setCo(codexCoBotKey, CoBotRound{CommandID: r.CodexCommandID, CommandedAt: r.CodexCommandedAt, ClaimedAt: r.CodexClaimedAt}) } +// inferCoOnly backfills CoOnly for rounds written before the flag existed. The +// evidence is already in the round: a co-reviewer-only fire recorded one of its +// OWN trigger comments as the round's CommandID, whereas a real fire records +// the primary review command — two different comments, so the inference cannot +// mislabel a genuine request. +func (r *Round) inferCoOnly() { + if r.CoOnly || r.FiredAt == nil { + return + } + // A bounded co-review wait anchors FiredAt without ever posting a command, + // so a fire with no CommandID is one by construction: every real fire + // records the command it posted or adopted. + if r.CommandID == 0 { + r.CoOnly = true + return + } + // A co-reviewer-only fire recorded one of its OWN trigger comments as the + // round's CommandID; a real fire records the primary review command, a + // different comment, so this cannot mislabel a genuine request. + for _, c := range r.CoBots { + if c.CommandID == r.CommandID { + r.CoOnly = true + return + } + } +} + // FireSlot is the single global in-flight reservation: at most one review // command may be getting posted at a time, fleet-wide. type FireSlot struct { @@ -584,10 +611,12 @@ func (s *State) Normalize(now time.Time) { } for key, r := range s.Rounds { r.foldLegacyCodex() + r.inferCoOnly() s.Rounds[key] = r } for i := range s.Archive { s.Archive[i].foldLegacyCodex() + s.Archive[i].inferCoOnly() } if len(s.Archive) > ArchiveMax { s.Archive = s.Archive[len(s.Archive)-ArchiveMax:] From a9ea74e6cbf032e7163c035b716bfb735c3664ac Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 03:43:42 +0200 Subject: [PATCH 27/31] Address the review: guard order, ambiguous skips, and drain scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Already-reviewed heads now resolve before the slot gate, not after. Every verdict that path produces is quota-free, so parking one behind another PR's in-flight review delayed work that could never have competed with it. AGENTS.md's documented order matches DecideFire again. Three fixes are corrections to earlier calls in this same review: Marking every skipped Correctness run clean was too broad. Only the "No code objects were reviewed" result is clean evidence — I had defended the skip handling on that capture, but the conclusion alone does not carry that meaning, so an unrecognised skip now falls through to CheckDone where findings still gate rather than being read as a delivered review. ChecksUnknown suppressed the duplicate trigger but was missing from the completion gate, so an unreadable in-flight check was neither asked for nor waited on and a clean primary could converge straight past it. The pre-review drain exemption bypassed EVERY blocking finding whenever the primary was unavailable, when only the thread-less skip notice needed it. Real unresolved findings on a summary-only PR hold the head like anywhere else. The Bugbot sibling rule needed recency rather than a blanket precedence: open-wins resurrected a settled bug whenever a duplicate sibling lingered past the resolved one, settled-wins buried genuine re-reports. The newest occurrence decides. Also: an edited skip notice is judged by UpdatedAt, since CodeRabbit produces some by editing its top summary whose CreatedAt long predates the head; feedback reuses observe's head-commit date instead of fetching it again; and the bounded rescue scan rotates its window so a quota-free round behind three CodeRabbit-needing ones is no longer starved. --- AGENTS.md | 7 +-- internal/crq/feedback.go | 83 ++++++++++++++++++++++++++-------- internal/crq/feedback_test.go | 46 +++++++++++++++++++ internal/crq/service.go | 14 +++++- internal/dialect/macroscope.go | 11 ++++- internal/engine/completion.go | 6 ++- internal/engine/fire.go | 35 +++++++------- 7 files changed, 156 insertions(+), 46 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 510882d..c77c3db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,11 +83,12 @@ One flow drives both the daemon and the loop: fetch. Built once per decision. 2. **decide** (`internal/engine`) — pure. `DecideFire` consolidates every fire guard in order: open → head readable → head current → phase eligible → - **primary review unavailable** → slot free → **already reviewed** → account + **primary review unavailable** → **already reviewed** → slot free → account quota → min interval → adopt/post. The two bold steps resolve BEFORE the slot and quota gates on purpose: neither spends CodeRabbit quota, so an - unrelated PR's block or in-flight review must not delay them. Nothing else - may post the review command. `Progress` transitions a + unrelated PR's block or in-flight review must not delay a round whose + primary work is already done or can never happen. Nothing else may post the + review command. `Progress` transitions a fired/reviewing round. `Completion` answers convergence. 3. **apply** (`crq/service.go`) — the only effects executor: CAS state writes + `PostIssueComment`. `DryRun` short-circuits apply into "report, write nothing". diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 39bc012..d6dc218 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -170,7 +170,15 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe // ids settled in ANY thread first and suppress the whole family. settledStableIDs := map[string]bool{} if threads, err := s.reviewThreads(ctx, repo, pr); err == nil { - openStableIDs := map[string]bool{} + // A stable id can appear in both settled and open threads, and the two + // readings pull opposite ways: an open thread may be a genuine + // re-report after a regression, or merely a leftover duplicate of the + // one just resolved. Recency decides — the newest occurrence wins. A + // blanket "open wins" resurrected settled findings every time a + // duplicate sibling lingered; a blanket "settled wins" buried real + // re-reports. + newestOpen := map[string]time.Time{} + newestSettled := map[string]time.Time{} for _, thread := range threads { settled := thread.IsResolved || thread.IsOutdated for _, c := range thread.Comments.Nodes { @@ -183,19 +191,19 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe continue } key := dialect.NormalizeBotName(c.Author.Login) + "|" + stable + into := newestOpen if settled { - settledStableIDs[key] = true - } else { - openStableIDs[key] = true + into = newestSettled + } + if at, seen := into[key]; !seen || c.CreatedAt.After(at) { + into[key] = c.CreatedAt } } } - // reviewThreads spans the PR's whole history, so an old resolved thread - // can share an id with a CURRENT re-report — after a regression, or an - // incorrect manual resolve. The live occurrence wins: otherwise the loop - // converges while the bot has an open finding. - for key := range openStableIDs { - delete(settledStableIDs, key) + for key, at := range newestSettled { + if open, ok := newestOpen[key]; !ok || !open.After(at) { + settledStableIDs[key] = true + } } for _, thread := range threads { report.Findings = append(report.Findings, threadFindings(thread, extractBots)...) @@ -266,6 +274,12 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe headCutoff := time.Time{} headCutoffLoaded := false headCutoffOf := func() time.Time { + // observe() is the single place that asks GitHub what happened on this + // PR, and it already resolved the head commit date; only fall back to a + // second fetch when it could not. + if !obs.eng.HeadAt.IsZero() { + return obs.eng.HeadAt + } if !headCutoffLoaded { headCutoffLoaded = true if pull.Head.SHA != "" { @@ -313,7 +327,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe // never ran at all. Commit: head, Severity: "major", - Title: "CodeRabbit skipped this review — narrow the PR to get one: " + reason, + Title: skipNoticeTitlePrefix + " — narrow the PR to get one: " + reason, Body: strings.TrimSpace(dialect.CompactReviewBody(comment.Body)), CommentID: comment.ID, URL: comment.URL, @@ -427,14 +441,14 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport return report, 1, feedbackErr } // The guard exists to stop a NEW review round starting while - // earlier findings are open. When the primary will not review - // this head, the co-reviewers are the entire round — and the - // skip notice itself is one of these findings, with no thread to - // resolve, so returning here short-circuited waitToFire forever - // and the co-reviewers never ran at all. Let the round start; - // the findings are still reported by the poll below. - blocking := engine.BlockingFindings(report.Findings, head) - if len(blocking) > 0 && !report.PrimaryUnavailable { + // earlier findings are open. The skip notice is the one finding + // that must not hold it: it has no thread to resolve, so + // returning here short-circuited waitToFire forever and the + // co-reviewers meant to resolve a skipped round never ran. + // Exempt exactly that notice — real unresolved findings on a + // summary-only PR still hold the head, as on any other. + blocking := excludeSkipNotice(engine.BlockingFindings(report.Findings, head)) + if len(blocking) > 0 { report.Findings = blocking report.Status = "feedback" report.Reason = "unresolved findings must be addressed before a new review round" @@ -1217,6 +1231,23 @@ func dedupeFindings(in []dialect.Finding, suppressPromptAt, settledStableIDs map return out } +// skipNoticeTitlePrefix identifies the synthetic finding Feedback builds from a +// "Review skipped" notice. It is the only finding with no thread to resolve and +// no way to be addressed except by changing the PR itself. +const skipNoticeTitlePrefix = "CodeRabbit skipped this review" + +// excludeSkipNotice drops the synthetic skip finding from a blocking set. +func excludeSkipNotice(findings []dialect.Finding) []dialect.Finding { + out := findings[:0:0] + for _, f := range findings { + if strings.HasPrefix(f.Title, skipNoticeTitlePrefix) { + continue + } + out = append(out, f) + } + return out +} + // skipPredatesHead reports whether a skip notice was posted before the current // head existed. A notice naming no commit cannot be bound by SHA, so it falls // back to the same head-commit cutoff every other issue comment uses — without @@ -1227,7 +1258,19 @@ func skipPredatesHead(comment ghapi.IssueComment, headCutoff func() time.Time) b return false // names its commit: SHA binding already decided this } cutoff := headCutoff() - return !cutoff.IsZero() && comment.CreatedAt.Before(cutoff) + if cutoff.IsZero() { + return false + } + // CodeRabbit produces some skip notices by EDITING its existing top-summary + // comment, whose CreatedAt long predates the current head while UpdatedAt is + // current. Judging by creation time discarded exactly those, so the finding + // vanished while the engine still (correctly) read the primary as + // unavailable. Use the later of the two, matching BotEvent.ObservedTime. + at := comment.CreatedAt + if comment.UpdatedAt.After(at) { + at = comment.UpdatedAt + } + return at.Before(cutoff) } // skipAppliesToHead reports whether a "Review skipped" notice concerns the diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index 5ed5913..b2eb7ea 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -2167,3 +2167,49 @@ func TestBugbotStableIDSettledInAnySiblingThread(t *testing.T) { t.Fatalf("a bug settled in a sibling thread must not resurface, got %#v", got) } } + +// TestExcludeSkipNoticeIsTargeted: the pre-review drain exempts exactly the +// thread-less skip notice, which cannot be resolved and would otherwise stop +// the co-review round it is meant to inform. Real unresolved findings on the +// same head must still hold it, as on any other PR — an earlier version +// bypassed every blocking finding whenever the primary was unavailable. +func TestExcludeSkipNoticeIsTargeted(t *testing.T) { + skip := dialect.Finding{Bot: "coderabbitai[bot]", Severity: "major", + Title: skipNoticeTitlePrefix + " — narrow the PR to get one: Too many files!"} + real := dialect.Finding{Bot: dialect.CodexBotLogin, Severity: "major", + Title: "Nil deref in the retry path", ThreadID: "PRRT_x"} + + if got := excludeSkipNotice([]dialect.Finding{skip}); len(got) != 0 { + t.Fatalf("the skip notice must not hold the round, got %+v", got) + } + got := excludeSkipNotice([]dialect.Finding{skip, real}) + if len(got) != 1 || got[0].ThreadID != "PRRT_x" { + t.Fatalf("a genuine finding must still hold the head, got %+v", got) + } +} + +// TestBugbotSiblingRecencyDecides: one stable id can appear in both a settled +// and an open thread, and the two readings pull opposite ways. A blanket +// "open wins" resurrected findings whenever a duplicate sibling lingered after +// the emitted thread was resolved; a blanket "settled wins" buried genuine +// re-reports after a regression. The newest occurrence decides. +func TestBugbotSiblingRecencyDecides(t *testing.T) { + body := corpusMessage(t, "bugbot/inline-finding-high.md") + stable, ok := dialect.BugbotFindingDedupeKey(body) + if !ok { + t.Fatal("precondition: corpus finding must carry a BUGBOT_BUG_ID") + } + key := dialect.NormalizeBotName(dialect.BugbotLogin) + "|" + stable + finding := []dialect.Finding{{Bot: dialect.BugbotLogin, Path: "a.ts", Line: 1, + Title: "dup", Body: body, ThreadID: "PRRT_open", Source: "review_thread"}} + + // Settled after the open sibling was filed: the leftover duplicate must not + // resurrect the finding. + if got := dedupeFindings(finding, nil, map[string]bool{key: true}); len(got) != 0 { + t.Fatalf("a stale duplicate must not resurrect a settled bug, got %#v", got) + } + // Nothing settled: the open occurrence is the finding. + if got := dedupeFindings(finding, nil, nil); len(got) != 1 { + t.Fatalf("an unsettled bug must surface, got %d", len(got)) + } +} diff --git a/internal/crq/service.go b/internal/crq/service.go index 2e08a2b..bfcddcb 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -48,6 +48,9 @@ type Service struct { // lastParkedSweep rotates sweepParkedClosed's candidate across pumps (see // there); in-memory only, single-writer (the pump caller). lastParkedSweep string + // scanOffset rotates the bounded quota-free rescue scan's window so a round + // past the first few is not starved forever; in-memory only, same writer. + scanOffset int // now overrides the wall clock for the scheduling DECISIONS in the // pump/enqueue/sweep/wait paths (see clock). nil in production; the replay // suite injects a controllable fake so an incident can be re-enacted @@ -279,9 +282,15 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { // round must be rescued regardless of that flag. accountBlocked := global.BlockedUntil != nil && global.BlockedUntil.After(now) if decision.Verdict == engine.FireNo && (accountBlocked || !global.SlotFree) { + // Rotate the window start across pumps. A fixed start meant that when the + // three rounds behind the FIFO head all needed CodeRabbit, every pump + // observed those same three and stopped — a fourth, quota-free round was + // never even looked at until the unrelated block cleared. + queued := st.QueuedRounds(now) scanned := 0 policy := s.policy() - for _, r := range st.QueuedRounds(now) { + for i := range queued { + r := queued[(i+s.scanOffset)%len(queued)] if r.Repo == next.Repo && r.PR == next.PR { continue } @@ -306,6 +315,9 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { } return s.applyFire(ctx, round, robs.eng, d, now) } + if len(queued) > 0 { + s.scanOffset = (s.scanOffset + scanned + 1) % len(queued) + } } return result, nil } diff --git a/internal/dialect/macroscope.go b/internal/dialect/macroscope.go index c5bb848..7e8cfed 100644 --- a/internal/dialect/macroscope.go +++ b/internal/dialect/macroscope.go @@ -111,9 +111,16 @@ func ClassifyMacroscopeCheck(name, title, summary, status, conclusion string) Ch } // Correctness concludes `skipped` for "No code objects were reviewed." — // it ran and had nothing to analyse, which is a clean verdict, not a - // non-delivery. Treating it as failed would strand docs-only PRs. + // non-delivery, and treating it as failed would strand docs-only PRs. But + // only THAT skip is clean: another skip cause would otherwise be read as a + // delivered review and let a required round converge unreviewed, so an + // unrecognised skip falls through to CheckDone, where findings still gate. if strings.EqualFold(strings.TrimSpace(conclusion), "skipped") { - return CheckDoneClean + if strings.Contains(NormalizeReviewText(title), "no code objects were reviewed") || + strings.Contains(NormalizeReviewText(summary), "no code objects were reviewed") { + return CheckDoneClean + } + return CheckDone } if strings.Contains(NormalizeReviewText(title), "no issues identified") || strings.Contains(NormalizeReviewText(summary), "no issues identified") { diff --git a/internal/engine/completion.go b/internal/engine/completion.go index 9019ecd..e3611de 100644 --- a/internal/engine/completion.go +++ b/internal/engine/completion.go @@ -78,7 +78,11 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { // co-reviewer can report exhaustion BEFORE a deferred CodeRabbit // fire, and anchoring at FiredAt missed it — the round then waited // for a review the bot had already said it could not produce. - if (co.AutoActive || co.ActiveThisRound || commanded) && + // ChecksUnknown counts as engagement: suppressing the trigger while + // leaving the bot out of reviewedBy meant an unreadable in-flight + // check was neither asked for nor waited on, and a clean primary + // could converge the round straight past it. + if (co.AutoActive || co.ActiveThisRound || commanded || co.ChecksUnknown) && !coUnableSince(obs, cp.Login, coCutoff(r, cp.Login)) { reviewedBy[cp.Login] = false } diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 833cc17..9efc6ee 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -141,32 +141,29 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic break } } - if !g.SlotFree { - // Co-reviewers need no fire slot: a round parked behind another PR's - // in-flight review can start its co-reviewer rounds immediately. The - // round stays queued and CodeRabbit fires once the slot frees, with the - // recorded command ids preventing duplicate posts. NOT for a head - // CodeRabbit already reviewed — that round belongs to the dedupe - // resolution below once the slot frees (a queued round a co-bot answers - // clean cannot complete, so deferring it here could wedge the wait). - if !reviewedHead { - if d, ok := decideCoDeferred(r, obs, p, now, "fire slot busy", false); ok { - return d - } - } - return FireDecision{Verdict: FireNo, Reason: "fire slot busy"} - } // Belt-and-braces live check: even with a fresh round, never fire at a // head the bot has already reviewed (e.g. state was reinitialized). But a // CodeRabbit review does not finish a round that a gating co-reviewer still // must speak on — command (or wait for) it instead of deduping it away. - // This resolution runs BEFORE the account-block and pacing gates: none of - // its verdicts spend CodeRabbit quota (dedupe completes, FireCoOnly posts - // only co-reviewer triggers, a co-review wait posts nothing), so an account - // block from another PR must not delay them. + // + // This resolution runs BEFORE the slot, account-block and pacing gates: + // none of its verdicts spend CodeRabbit quota (dedupe completes, FireCoOnly + // posts only co-reviewer triggers, a co-review wait posts nothing), so + // neither another PR's in-flight review nor an account block may delay a + // round whose primary work is already done. if reviewedHead { return coAwareDedupe(r, obs, p, now, false) } + if !g.SlotFree { + // Co-reviewers need no fire slot: a round parked behind another PR's + // in-flight review can start its co-reviewer rounds immediately. The + // round stays queued and CodeRabbit fires once the slot frees, with the + // recorded command ids preventing duplicate posts. + if d, ok := decideCoDeferred(r, obs, p, now, "fire slot busy", false); ok { + return d + } + return FireDecision{Verdict: FireNo, Reason: "fire slot busy"} + } if g.BlockedUntil != nil && g.BlockedUntil.After(now) { // Degrade instead of stalling: the block only gates CodeRabbit quota, // so ask the co-reviewers now and leave the round queued — CodeRabbit From 8759939a0c9a1c7d3e30e9d279d808f9bf4fdc09 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 03:45:32 +0200 Subject: [PATCH 28/31] Move per-bot config policy into the co-reviewer registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseCoBots named Codex twice — once for its legacy command env var, once for its default trigger policy — which is exactly the bot enumeration in crq that AGENTS.md forbids, and it meant a fourth co-reviewer could not be added without editing the config parser. Both now travel as registry metadata: LegacyCommandEnv, DefaultTrigger and RequiredTrigger on dialect.CoReviewer. They are plain strings because dialect has zero dependencies and cannot see engine.TriggerMode; crq maps them. Behaviour is unchanged — Codex still resolves CRQ_CODEX_CMD and still triggers at fire time only when required, Bugbot and Macroscope still default to selfheal — but the parser is now uniform and adding a bot is one registry entry again. --- internal/crq/config.go | 26 ++++++++++++++++++-------- internal/dialect/coreviewer.go | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/internal/crq/config.go b/internal/crq/config.go index 1c1fcc6..38cbfb9 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -327,19 +327,19 @@ func parseCoBots(env map[string]string, requiredBots []string) ([]CoBotConfig, e if !enabled[co.Name] && !required { continue } + // Uniform across bots: the per-bot key wins, then the registry's legacy + // alias if it declares one, then its default command. No bot is named + // here — the policy travels as registry metadata. command := co.Command if v, ok := env["CRQ_COBOT_"+key+"_CMD"]; ok { command = v - } else if co.Name == "codex" { - command = stringEnvAllowEmpty(env, "CRQ_CODEX_CMD", command) + } else if co.LegacyCommandEnv != "" { + command = stringEnvAllowEmpty(env, co.LegacyCommandEnv, command) } command = strings.TrimSpace(command) - trigger := engine.TriggerSelfHeal - if co.Name == "codex" { - trigger = engine.TriggerNever - if required { - trigger = engine.TriggerAlways - } + trigger := triggerMode(co.DefaultTrigger, engine.TriggerSelfHeal) + if required && co.RequiredTrigger != "" { + trigger = triggerMode(co.RequiredTrigger, trigger) } switch v := engine.TriggerMode(strings.ToLower(strings.TrimSpace(env["CRQ_COBOT_"+key+"_TRIGGER"]))); v { case engine.TriggerNever, engine.TriggerSelfHeal, engine.TriggerAlways: @@ -361,6 +361,16 @@ func parseCoBots(env map[string]string, requiredBots []string) ([]CoBotConfig, e return out, nil } +// triggerMode converts a registry trigger string to the engine mode, falling +// back when a bot declares none. +func triggerMode(name string, fallback engine.TriggerMode) engine.TriggerMode { + switch m := engine.TriggerMode(strings.ToLower(strings.TrimSpace(name))); m { + case engine.TriggerNever, engine.TriggerSelfHeal, engine.TriggerAlways: + return m + } + return fallback +} + // splitList splits a comma-separated list, dropping blanks (an all-blank or // empty value yields nil — unlike listEnv it never falls back). func splitList(value string) []string { diff --git a/internal/dialect/coreviewer.go b/internal/dialect/coreviewer.go index aaf6625..ed0fe41 100644 --- a/internal/dialect/coreviewer.go +++ b/internal/dialect/coreviewer.go @@ -66,6 +66,17 @@ type CoReviewer struct { // TriggerAliases are alternate command spellings recognized as this bot's // trigger in addition to the (config-resolved) Command. TriggerAliases []string + // LegacyCommandEnv is a pre-registry env var still honoured for this bot's + // command ("" = none). Carried here so config stays uniform instead of + // naming individual bots. + LegacyCommandEnv string + // DefaultTrigger / RequiredTrigger are this bot's default trigger modes + // when it is merely enabled, and when it is configured-required. Plain + // strings because dialect has zero dependencies and cannot see + // engine.TriggerMode; crq maps them. RequiredTrigger "" means "same as + // DefaultTrigger". + DefaultTrigger string + RequiredTrigger string // ClassifyComment classifies an issue comment authored by this bot. // Kind EvOther means "no special meaning" (may still be actionable text). @@ -115,6 +126,11 @@ func KnownCoReviewers() []CoReviewer { Login: CodexBotLogin, Name: "codex", Command: "@codex review", + // Codex predates the registry: its command env var is still read, + // and it only ever triggered at fire time when required. + LegacyCommandEnv: "CRQ_CODEX_CMD", + DefaultTrigger: "never", + RequiredTrigger: "always", ClassifyComment: func(body string) CoEvent { switch { case IsCodexNoActionReviewCompletion(body): @@ -140,6 +156,8 @@ func KnownCoReviewers() []CoReviewer { AppSlug: "cursor", Command: "bugbot run", TriggerAliases: []string{"bugbot run", "cursor review"}, + // Auto-reviews every push, so crq only nudges one that went silent. + DefaultTrigger: "selfheal", // Bugbot posts no classifiable issue comments: its findings live in // review threads and its clean verdict only in the check run. ClassifyComment: func(string) CoEvent { return CoEvent{Kind: EvOther} }, @@ -152,6 +170,7 @@ func KnownCoReviewers() []CoReviewer { Name: "macroscope", AppSlug: "macroscopeapp", Command: "@macroscope-app review", + DefaultTrigger: "selfheal", ClassifyComment: ClassifyMacroscopeComment, ClassifyCheck: ClassifyMacroscopeCheck, ResolvedInSHA: MacroscopeResolvedInSHA, From ba4c780a0bcee0d0b63955c48e1aadf1bbee903c Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 04:25:42 +0200 Subject: [PATCH 29/31] Fail closed on review runs that never produced a review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live capture pass over Macroscope's Correctness Check turned up two conclusions crq read as "reviewed" when the bot had reviewed nothing: skipped + "Review skipped — billing issue" (the workspace cannot review) neutral + "Macroscope encountered an error while reviewing " Both marked the bot as having reviewed the head, and since a non-delivering run publishes no threads, a required Macroscope round converged having been reviewed by nobody. Neither conclusion is decidable on its own: `neutral` is also how an ordinary "1 issue identified" review concludes, and `skipped` is also how a genuinely clean docs-only round concludes. The title decides. A crashed run is now CheckFailed (crq re-triggers it). A run that reports the bot cannot review at all is the new CheckUnable: never review evidence, but no re-trigger either — the run IS the bot's answer, and nudging a billing-blocked workspace every round is pure comment spam. It disengages the dynamic completion gate exactly as Codex's usage-limit notice does. An unrecognised skip cause fails closed rather than counting as a review. Also from the review: - Expire a co-review wait on a primary-unavailable round. Its deadline is the only thing that can end it — `autoreview` runs no Loop, and the primary-reviewed branch can never become true — so it sat in `reviewing` forever and starved every reviewing round behind it (the daemon sweeps only the oldest per pump). - Anchor the self-heal grace on when crq saw the head, not the head commit's date. A force-push can point a PR at a commit authored months ago, which reads as a grace long elapsed and posts a trigger over an auto-review that has barely started. Left obs.HeadAt alone: it is an evidence FLOOR elsewhere and must stay a lower bound, or a co-reviewer answer landing in the seconds between the push and crq noticing it is discarded. - Decide Bugbot sibling threads by the head each occurrence names. Resolving a thread does not restamp its comment, so the settled occurrence stays older than a sibling filed minutes before it and comment recency read every duplicate as a regression. - Dispatch review-body finding extraction through the registry hook, which nothing called — a co-reviewer added with body-only findings would have had all of them silently dropped. - Move the legacy-alias paragraph out of the middle of the README config table, where it terminated the table and left the remaining rows rendering as pipe-delimited text. --- README.md | 17 ++++--- internal/crq/coreview_replay_test.go | 71 ++++++++++++++++++++++++++++ internal/crq/feedback.go | 59 ++++++++++++++++------- internal/crq/feedback_test.go | 28 ++--------- internal/dialect/common.go | 12 ++++- internal/dialect/coreviewer.go | 8 ++++ internal/dialect/golden_test.go | 21 ++++++++ internal/dialect/macroscope.go | 48 ++++++++++++++----- internal/engine/completion.go | 3 +- internal/engine/coreview.go | 19 +++++++- internal/engine/coreview_test.go | 64 +++++++++++++++++++++++++ internal/engine/engine_test.go | 15 ++++++ internal/engine/fire.go | 22 +++++++-- internal/engine/progress.go | 17 ++++++- llms.txt | 9 ++-- 15 files changed, 342 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 5cf97f0..b4b24af 100644 --- a/README.md +++ b/README.md @@ -432,11 +432,6 @@ Set these in `~/.config/crq/env` (sourced automatically) or as environment varia | `CRQ_COBOT__CMD` | `@codex review` / `bugbot run` / `@macroscope-app review` | that bot's trigger comment; empty forces `never` | | `CRQ_COBOT__GRACE` | `10m` | how long a `selfheal` trigger waits for the bot to show up on its own before nudging | | `CRQ_RL_CO_DEGRADE` | on | while CodeRabbit is rate-limited, run co-reviewer-only rounds instead of waiting the window out; set `0` to disable (legacy alias: `CRQ_RL_CODEX_DEGRADE`) | - -The pre-co-reviewer Codex variables are still read as legacy aliases, so existing configs keep -working: `CRQ_CODEX_CMD` is an alias of `CRQ_COBOT_CODEX_CMD` (the per-bot key wins when both are -set) and `CRQ_RL_CODEX_DEGRADE` an alias of `CRQ_RL_CO_DEGRADE`. Prefer the `CRQ_COBOT_*` names in -new configuration. | `CRQ_FEEDBACK_BOTS` | required bots + enabled co-reviewers | bots whose findings are surfaced — a superset of required bots, so co-reviewer findings show up without gating convergence on repos where those bots aren't installed | | `CRQ_TZ` | `UTC` | dashboard display timezone (IANA name, e.g. `Europe/Oslo`) | | `CRQ_MIN_INTERVAL` | `90s` | minimum time between fired reviews | @@ -451,6 +446,11 @@ new configuration. | `CRQ_GITHUB_MAX_WAIT` / `CRQ_GITHUB_RETRIES` | `120s` / `6` | GitHub rate-limit / 5xx backoff budget per request | | `CRQ_NETWORK_MAX_WAIT` | `0` (no cap) | cap on riding out an internet/GitHub outage (retrying ~every 30s); `0` = keep trying until connectivity returns | +The pre-co-reviewer Codex variables are still read as legacy aliases, so existing configs keep +working: `CRQ_CODEX_CMD` is an alias of `CRQ_COBOT_CODEX_CMD` (the per-bot key wins when both are +set) and `CRQ_RL_CODEX_DEGRADE` an alias of `CRQ_RL_CO_DEGRADE`. Prefer the `CRQ_COBOT_*` names in +new configuration. + **Other review bots:** crq isn't CodeRabbit-specific. Point `CRQ_BOT`, `CRQ_REVIEW_CMD`, `CRQ_RATELIMIT_CMD`, and `CRQ_RL_MARKER` at any bot with a similar command surface. @@ -497,8 +497,11 @@ this per bot under `co_reviewers`: } ``` -`check_state` is `clean`, `issues`, `in_progress` or `unknown`. Macroscope's approvability `verdict` -is **informational only** — it never gates convergence and never changes an exit code. +`check_state` is `clean`, `issues`, `in_progress`, `failed` (the run crashed — crq re-triggers the +bot), `unable` (the bot reported it cannot review this commit at all, e.g. Macroscope's billing-issue +skip — crq stops waiting for it and does **not** re-trigger, since no trigger can fix billing) or +`unknown`. Neither `failed` nor `unable` ever counts as a review. Macroscope's approvability +`verdict` is **informational only** — it never gates convergence and never changes an exit code. **Skipped reviews.** CodeRabbit sometimes refuses a head outright — too many files for the plan's limit, no usage credits, an unsupported diff — with a `Review skipped` callout. That notice ships diff --git a/internal/crq/coreview_replay_test.go b/internal/crq/coreview_replay_test.go index 459f23c..f8af046 100644 --- a/internal/crq/coreview_replay_test.go +++ b/internal/crq/coreview_replay_test.go @@ -632,3 +632,74 @@ func TestCoReplaySummaryOnlyWaitAcceptsExistingAnswer(t *testing.T) { t.Fatalf("the round must converge instead of waiting out the deadline: %+v", rep) } } + +// --- 8. Bugbot sibling threads ---------------------------------------------- + +// bugbotAtSHA rewrites a corpus finding's "for commit " footer, so the same +// BUG_ID can be replayed as reported on an earlier head. +func bugbotAtSHA(t *testing.T, body, sha string) string { + t.Helper() + old := dialect.BugbotReviewedCommitSHA(body) + if old == "" { + t.Fatal("precondition: the corpus finding must name the commit it reviewed") + } + return strings.Replace(body, old, sha, 1) +} + +// TestCoReplayBugbotSiblingSettlementUsesHead: one BUG_ID can sit in a settled +// thread and an open one at the same time, and the two readings pull opposite +// ways — a leftover duplicate of the thread just resolved, or a genuine +// re-report after a regression. Comment timestamps cannot tell them apart: +// resolving a thread does not restamp its comment, so the settled occurrence +// stays OLDER than a sibling filed minutes before it and every duplicate reads +// as a regression. The commit each occurrence names is what decides. +func TestCoReplayBugbotSiblingSettlementUsesHead(t *testing.T) { + base := time.Date(2026, 7, 20, 13, 0, 0, 0, time.UTC) + f := newCoReplayFixture(t, base, nil) + repo, pr := "o/r", 44 + finding := corpusMessage(t, "bugbot/inline-finding-high.md") + sha := dialect.BugbotReviewedCommitSHA(finding) + head := sha[:9] + f.openPull(repo, pr, sha) + f.setCommitDate(sha, base.Add(-time.Hour)) + seedRound(t, f.store, f.cfg, repo, pr, head, PhaseReviewing, base.Add(-10*time.Minute), 400) + f.botReview(repo, pr, 500, sha, base.Add(-5*time.Minute)) + + bugbotFindings := func(t *testing.T) int { + t.Helper() + rep, err := f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatal(err) + } + n := 0 + for _, fd := range rep.Findings { + if dialect.NormalizeBotName(fd.Bot) == "cursor" { + n++ + } + } + return n + } + + // Two same-head siblings, the OLDER one resolved. The open one is a leftover + // duplicate, not a regression — surfacing it forces the user to resolve every + // copy by hand, which is the whole reason family suppression exists. + f.threadsGraphQL([]map[string]any{ + threadNode("PRRT_settled", true, "cursor", "projector.ts", 447, 901, finding, base.Add(-9*time.Minute)), + threadNode("PRRT_dup", false, "cursor", "projector.ts", 451, 902, finding, base.Add(-8*time.Minute)), + }) + if n := bugbotFindings(t); n != 0 { + t.Fatalf("a duplicate of a settled same-head finding must not resurface, got %d", n) + } + + // The regression shape: the settled occurrence names an EARLIER commit and + // the open one names the head. Bugbot found it again on the current code, so + // it must surface even though a thread for that id is resolved. + f.threadsGraphQL([]map[string]any{ + threadNode("PRRT_settled", true, "cursor", "projector.ts", 447, 901, + bugbotAtSHA(t, finding, "1111111111111111111111111111111111111111"), base.Add(-9*time.Minute)), + threadNode("PRRT_again", false, "cursor", "projector.ts", 451, 902, finding, base.Add(-8*time.Minute)), + }) + if n := bugbotFindings(t); n != 1 { + t.Fatalf("a re-report on the current head is a regression and must surface, got %d", n) + } +} diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index d6dc218..b1765bc 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -51,7 +51,8 @@ type CoReviewerStatus struct { // summary, or a completed check run. Reviewed bool `json:"reviewed"` // CheckState summarizes the bot's check runs for the head: - // clean | issues | in_progress | unknown (no check observed). + // clean | issues | in_progress | failed (the run crashed) | unable (the bot + // reported it cannot review this commit at all) | unknown (no check observed). CheckState string `json:"check_state,omitempty"` // Verdict is Macroscope's approvability verdict when one was posted: // approved | needs_human_review. @@ -171,14 +172,24 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe settledStableIDs := map[string]bool{} if threads, err := s.reviewThreads(ctx, repo, pr); err == nil { // A stable id can appear in both settled and open threads, and the two - // readings pull opposite ways: an open thread may be a genuine - // re-report after a regression, or merely a leftover duplicate of the - // one just resolved. Recency decides — the newest occurrence wins. A - // blanket "open wins" resurrected settled findings every time a + // readings pull opposite ways: an open thread may be a genuine re-report + // after a regression, or merely a leftover duplicate of the one just + // resolved. A blanket "open wins" resurrected settled findings whenever a // duplicate sibling lingered; a blanket "settled wins" buried real - // re-reports. - newestOpen := map[string]time.Time{} - newestSettled := map[string]time.Time{} + // re-reports. Comment timestamps cannot separate them either — resolving + // a thread does not restamp its comment, so the settled occurrence stays + // older than a sibling reported minutes before it and every duplicate + // reads as a regression. + // + // The commit each occurrence names is what actually distinguishes them: + // Bugbot stamps every finding with the SHA it reviewed. A regression is + // re-reported ON THE CURRENT HEAD; a leftover duplicate names an older + // one. So the family is settled unless it is open at the head and NOT + // settled at the head — the second half is what keeps three same-head + // siblings from resurrecting each other as one is resolved. + openAtHead := map[string]bool{} + settledAtHead := map[string]bool{} + anySettled := map[string]bool{} for _, thread := range threads { settled := thread.IsResolved || thread.IsOutdated for _, c := range thread.Comments.Nodes { @@ -191,17 +202,21 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe continue } key := dialect.NormalizeBotName(c.Author.Login) + "|" + stable - into := newestOpen - if settled { - into = newestSettled + atHead := false + if co.ReviewedCommitSHA != nil && head != "" { + atHead = dialect.SHAPrefixMatch(co.ReviewedCommitSHA(c.Body), head) } - if at, seen := into[key]; !seen || c.CreatedAt.After(at) { - into[key] = c.CreatedAt + switch { + case settled: + anySettled[key] = true + settledAtHead[key] = settledAtHead[key] || atHead + default: + openAtHead[key] = openAtHead[key] || atHead } } } - for key, at := range newestSettled { - if open, ok := newestOpen[key]; !ok || !open.After(at) { + for key := range anySettled { + if !openAtHead[key] || settledAtHead[key] { settledStableIDs[key] = true } } @@ -1297,7 +1312,7 @@ func coReviewerStatuses(cfg Config, obs engine.Observation, since time.Time) map for _, cb := range cfg.CoBots { key := dialect.NormalizeBotName(cb.Login) status := CoReviewerStatus{Reviewed: engine.CoReviewedHead(obs, cb.Login)} - inProgress, clean, done := false, false, false + inProgress, clean, done, unable, failed := false, false, false, false, false for _, c := range obs.Checks { if dialect.NormalizeBotName(c.Bot) != key { continue @@ -1309,6 +1324,10 @@ func coReviewerStatuses(cfg Config, obs engine.Observation, since time.Time) map clean = true case dialect.CheckDone: done = true + case dialect.CheckUnable: + unable = true + case dialect.CheckFailed: + failed = true } } switch { @@ -1318,6 +1337,14 @@ func coReviewerStatuses(cfg Config, obs engine.Observation, since time.Time) map status.CheckState = "clean" case done: status.CheckState = "issues" + // A bot that could not review at all (Macroscope's billing-issue skip) or + // whose run crashed used to report "unknown", which reads as "nothing has + // happened yet" — the operator waits for a review that is never coming + // instead of fixing the workspace. + case unable: + status.CheckState = "unable" + case failed: + status.CheckState = "failed" default: status.CheckState = "unknown" } diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index b2eb7ea..5faae87 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -2188,28 +2188,6 @@ func TestExcludeSkipNoticeIsTargeted(t *testing.T) { } } -// TestBugbotSiblingRecencyDecides: one stable id can appear in both a settled -// and an open thread, and the two readings pull opposite ways. A blanket -// "open wins" resurrected findings whenever a duplicate sibling lingered after -// the emitted thread was resolved; a blanket "settled wins" buried genuine -// re-reports after a regression. The newest occurrence decides. -func TestBugbotSiblingRecencyDecides(t *testing.T) { - body := corpusMessage(t, "bugbot/inline-finding-high.md") - stable, ok := dialect.BugbotFindingDedupeKey(body) - if !ok { - t.Fatal("precondition: corpus finding must carry a BUGBOT_BUG_ID") - } - key := dialect.NormalizeBotName(dialect.BugbotLogin) + "|" + stable - finding := []dialect.Finding{{Bot: dialect.BugbotLogin, Path: "a.ts", Line: 1, - Title: "dup", Body: body, ThreadID: "PRRT_open", Source: "review_thread"}} - - // Settled after the open sibling was filed: the leftover duplicate must not - // resurrect the finding. - if got := dedupeFindings(finding, nil, map[string]bool{key: true}); len(got) != 0 { - t.Fatalf("a stale duplicate must not resurrect a settled bug, got %#v", got) - } - // Nothing settled: the open occurrence is the finding. - if got := dedupeFindings(finding, nil, nil); len(got) != 1 { - t.Fatalf("an unsettled bug must surface, got %d", len(got)) - } -} +// Which occurrences of a stable id count as settled is decided by the head each +// one names, not by this filter — see +// TestCoReplayBugbotSiblingSettlementUsesHead, which drives the real threads. diff --git a/internal/dialect/common.go b/internal/dialect/common.go index 9cb9b25..e78a76d 100644 --- a/internal/dialect/common.go +++ b/internal/dialect/common.go @@ -215,7 +215,13 @@ func NormalizeBotName(login string) string { // ParseReviewBodyFindings extracts every finding representable only in a // review's body text: CodeRabbit's failed-to-post/outside-diff detail blocks, -// its "Prompt for AI agents" block, and Codex's blob-link items. +// its "Prompt for AI agents" block, and whatever the authoring co-reviewer's +// registry entry knows how to pull out of a body (Codex's blob-link items). +// +// The co-reviewer part dispatches through the registry rather than calling the +// per-bot parsers directly, so a bot added with a ReviewBodyFindings hook is +// actually consulted — the hook existed but nothing called it, which would have +// silently dropped every body-only finding a new co-reviewer reported. func ParseReviewBodyFindings(body string, review ReviewMeta, bot string) []Finding { body = strings.TrimSpace(body) if body == "" { @@ -224,7 +230,9 @@ func ParseReviewBodyFindings(body string, review ReviewMeta, bot string) []Findi clean := StripMarkdownQuote(body) out := ParseDetailedReviewFindings(clean, review, bot) out = append(out, ParsePromptReviewFindings(clean, review, bot)...) - out = append(out, ParseCodexReviewFindings(clean, review, bot)...) + if co, ok := CoReviewerByName(bot); ok && co.ReviewBodyFindings != nil { + out = append(out, co.ReviewBodyFindings(clean, review)...) + } return out } diff --git a/internal/dialect/coreviewer.go b/internal/dialect/coreviewer.go index ed0fe41..0f85e59 100644 --- a/internal/dialect/coreviewer.go +++ b/internal/dialect/coreviewer.go @@ -43,6 +43,14 @@ const ( // (conclusion failure/cancelled/timed_out/action_required). The bot tried // and did not deliver, so it is activity but never review evidence. CheckFailed + // CheckUnable is a completed run whose own output says the bot could not + // review this commit at all — Macroscope's billing-issue skip. Like + // CheckFailed it is never review evidence, but unlike it a re-trigger cannot + // help: the run IS the bot's answer. So it suppresses the self-heal trigger + // (nudging a billing-blocked workspace every round is pure comment spam) and + // disengages the dynamic completion gate, exactly as Codex's usage-limit + // notice does on the comment side. + CheckUnable CheckDone // review finished (findings, if any, gate via threads) CheckDoneClean // review finished and explicitly found nothing ) diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index 85bd368..7360b87 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -408,6 +408,19 @@ func TestGoldenCheckRuns(t *testing.T) { {"bugbot/check-in-progress.json", BugbotLogin, CheckInProgress}, {"macroscope/check-correctness-clean.json", MacroscopeLogin, CheckDoneClean}, {"macroscope/check-correctness-issues.json", MacroscopeLogin, CheckDone}, + // `skipped` means two opposite things, so the title decides. Nothing to + // analyse is a clean round; a billing-blocked workspace never reviewed at + // all and no re-trigger can change that (CheckUnable, not CheckFailed — + // nudging it every round would be pure comment spam). The billing capture + // is trimmed to the classifier-relevant fields: it comes from a private + // repo, and the envelope would publish its name and commits. + {"macroscope/check-correctness-skipped-no-code.json", MacroscopeLogin, CheckDoneClean}, + {"macroscope/check-correctness-skipped-billing.json", MacroscopeLogin, CheckUnable}, + // `neutral` is an ordinary completed conclusion — a real review with + // findings concludes neutral too — so only the error TITLE marks a run + // that did not deliver. + {"macroscope/check-correctness-neutral-issues.json", MacroscopeLogin, CheckDone}, + {"macroscope/check-correctness-error.json", MacroscopeLogin, CheckFailed}, // Only the Correctness Check is Macroscope's REVIEW. Approvability and // repo-custom checks routinely complete first, so counting them as a // finished review would let the round converge while correctness is @@ -432,6 +445,14 @@ func TestGoldenCheckRuns(t *testing.T) { if login, verdict := ClassifyCheckRun("github-actions", "CI", "ok", "", "completed", "success"); login != "" || verdict != CheckUnrelated { t.Errorf("unrelated check classified as %q,%v", login, verdict) } + // A skip cause crq has never seen — a new wording, or one Macroscope has yet + // to ship — must fail closed. Reading it as a delivered review would mark a + // required Macroscope reviewed on a run that produced no threads at all, and + // the round would converge having been reviewed by nobody. + if _, verdict := ClassifyCheckRun("macroscopeapp", macroscopeCorrectnessCheck, + "Review skipped — some future reason", "", "completed", "skipped"); verdict != CheckFailed { + t.Errorf("unrecognized skip = %v, want CheckFailed (fail closed)", verdict) + } // Another cursor-app check that is not the Bugbot review stays unrelated. if login, verdict := ClassifyCheckRun("cursor", "Cursor Something Else", "", "", "completed", "success"); login != "" || verdict != CheckUnrelated { t.Errorf("non-review cursor check classified as %q,%v", login, verdict) diff --git a/internal/dialect/macroscope.go b/internal/dialect/macroscope.go index 7e8cfed..0c4d0ad 100644 --- a/internal/dialect/macroscope.go +++ b/internal/dialect/macroscope.go @@ -32,6 +32,10 @@ var ( macroscopeResolvedRE = regexp.MustCompile(`(?i)(?:✅\s*Resolved in|No longer relevant as of)\s*\x60?([0-9a-fA-F]{7,40})\x60?`) // macroscopeVerdictRE captures the Approvability verdict text. macroscopeVerdictRE = regexp.MustCompile(`(?i)\*\*Verdict:\*\*\s*([^\n]+)`) + // macroscopeErrorRE matches the Correctness Check title Macroscope publishes + // when its own run blew up ("Macroscope encountered an error while reviewing + // ``."). + macroscopeErrorRE = regexp.MustCompile(`(?i)encountered an error while reviewing`) ) func IsMacroscopeBot(login string) bool { @@ -109,22 +113,44 @@ func ClassifyMacroscopeCheck(name, title, summary, status, conclusion string) Ch if checkRunFailed(conclusion) { return CheckFailed } - // Correctness concludes `skipped` for "No code objects were reviewed." — - // it ran and had nothing to analyse, which is a clean verdict, not a - // non-delivery, and treating it as failed would strand docs-only PRs. But - // only THAT skip is clean: another skip cause would otherwise be read as a - // delivered review and let a required round converge unreviewed, so an - // unrecognised skip falls through to CheckDone, where findings still gate. + // Macroscope reports its own crashes as `neutral`, which is otherwise an + // ordinary completed review conclusion (a real "1 issue identified" round + // concludes neutral too) — so the title, not the conclusion, is what says + // the review did not happen. A nudge is the right response. + if macroscopeErrorRE.MatchString(title) { + return CheckFailed + } + // Correctness concludes `skipped` for two very different things, and the + // conclusion alone cannot tell them apart: + // + // "No code objects were reviewed." — it ran and had nothing to analyse. + // A clean verdict; failing it would strand docs-only PRs. + // "Review skipped — billing issue" — the workspace cannot review at all. + // Never review evidence, and re-triggering cannot fix billing, so it + // is CheckUnable (suppress the nudge, disengage the gate). + // + // Anything else is an unrecognised non-delivery: fail closed with + // CheckFailed. Falling through to CheckDone let a skipped run — which has no + // threads and so no findings to gate on — mark a required Macroscope as + // having reviewed the head, converging the round with no review at all. if strings.EqualFold(strings.TrimSpace(conclusion), "skipped") { - if strings.Contains(NormalizeReviewText(title), "no code objects were reviewed") || - strings.Contains(NormalizeReviewText(summary), "no code objects were reviewed") { + switch { + case macroscopeSaysAny(title, summary, "no code objects were reviewed"): return CheckDoneClean + case macroscopeSaysAny(title, summary, "billing issue"): + return CheckUnable } - return CheckDone + return CheckFailed } - if strings.Contains(NormalizeReviewText(title), "no issues identified") || - strings.Contains(NormalizeReviewText(summary), "no issues identified") { + if macroscopeSaysAny(title, summary, "no issues identified") { return CheckDoneClean } return CheckDone } + +// macroscopeSaysAny reports whether a check run's title or summary carries +// phrase (normalized), the two places Macroscope states a run's outcome. +func macroscopeSaysAny(title, summary, phrase string) bool { + return strings.Contains(NormalizeReviewText(title), phrase) || + strings.Contains(NormalizeReviewText(summary), phrase) +} diff --git a/internal/engine/completion.go b/internal/engine/completion.go index e3611de..7334efe 100644 --- a/internal/engine/completion.go +++ b/internal/engine/completion.go @@ -83,7 +83,8 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { // check was neither asked for nor waited on, and a clean primary // could converge the round straight past it. if (co.AutoActive || co.ActiveThisRound || commanded || co.ChecksUnknown) && - !coUnableSince(obs, cp.Login, coCutoff(r, cp.Login)) { + !coUnableSince(obs, cp.Login, coCutoff(r, cp.Login)) && + !coCheckUnable(obs, cp.Login) { reviewedBy[cp.Login] = false } } diff --git a/internal/engine/coreview.go b/internal/engine/coreview.go index 417163a..ca7f63c 100644 --- a/internal/engine/coreview.go +++ b/internal/engine/coreview.go @@ -92,6 +92,10 @@ func coChecks(obs Observation, login string) []CheckSeen { // treating it as engagement suppressed every trigger while the Correctness // Check had not even started, leaving required rounds to time out with no // recovery path. +// +// CheckUnable is NOT excluded: the bot answered — it cannot review this commit +// — and a trigger cannot change that answer, so the run suppresses the nudge +// even though it is not review evidence. func coCheckAny(obs Observation, login string) bool { for _, c := range coChecks(obs, login) { switch c.Verdict { @@ -103,6 +107,19 @@ func coCheckAny(obs Observation, login string) bool { return false } +// coCheckUnable reports whether login's own review run on this head says it +// could not review the commit (Macroscope's billing-issue skip) — the check-run +// twin of EvCoUnable, and read the same way: the bot cannot finish this round, +// so the dynamic gate must let go of it rather than wait out the deadline. +func coCheckUnable(obs Observation, login string) bool { + for _, c := range coChecks(obs, login) { + if c.Verdict == dialect.CheckUnable { + return true + } + } + return false +} + // coCheckActivity reports whether login has ANY check run on this head, // including failed and auxiliary ones. Suppression and activity are separate // questions: a bot whose only check crashed must not suppress the self-heal @@ -362,7 +379,7 @@ func CoOnlyEligible(r state.Round, obs Observation, login string, blockedUntil * if floor.IsZero() { floor = headEvidenceAt } - return !coUnableSince(obs, login, floor) + return !coUnableSince(obs, login, floor) && !coCheckUnable(obs, login) } // DecideCoPost reports whether crq should post login's trigger command for diff --git a/internal/engine/coreview_test.go b/internal/engine/coreview_test.go index 97b1f16..2d154bb 100644 --- a/internal/engine/coreview_test.go +++ b/internal/engine/coreview_test.go @@ -563,3 +563,67 @@ func TestFailedCheckStaysActivityForSelfHeal(t *testing.T) { t.Fatal("an unreadable check must suppress the trigger rather than double-ask a run in flight") } } + +// TestUnableCheckStopsWaitingWithoutNudging: Macroscope's billing-issue skip is +// the check-run twin of Codex's usage-limit notice. The bot ANSWERED — it cannot +// review this commit — so the round must neither count it as reviewed nor keep +// asking. Reading it as a completed review (the pre-fix behaviour of any +// unrecognised skip) converged the round having been reviewed by nobody; +// reading it as a crash posted a trigger every round at a workspace whose +// billing no trigger can fix. +func TestUnableCheckStopsWaitingWithoutNudging(t *testing.T) { + now := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) + head := "abcdef123" + fired := now.Add(-time.Hour) + round := state.Round{Repo: "o/r", PR: 1, Head: head, Phase: state.PhaseReviewing, FiredAt: &fired} + unable := CheckSeen{Bot: macroLogin, Name: "Macroscope - Correctness Check", + Verdict: dialect.CheckUnable, CompletedAt: now.Add(-30 * time.Minute)} + obs := Observation{Head: head, Open: true, + Reviews: []ReviewSeen{{Bot: "coderabbitai[bot]", Commit: head, SubmittedAt: now.Add(-40 * time.Minute)}}, + Checks: []CheckSeen{unable}, + Co: map[string]CoSeen{dialect.NormalizeBotName(macroLogin): {ActiveThisRound: true}}} + p := Policy{Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]"}, + CoReviewers: []CoReviewerPolicy{{Login: macroLogin, Command: "@macroscope-app review", + Trigger: TriggerSelfHeal, SelfHealGrace: 10 * time.Minute}}} + + // Never review evidence. + if c := Completion(round, obs, p); c.ReviewedBy[macroLogin] { + t.Fatal("a bot that says it could not review must never count as having reviewed") + } + // ...but it disengages the dynamic gate, so the primary review completes the + // round instead of waiting out the deadline for a review that cannot come. + if c := Completion(round, obs, p); !c.Done { + t.Fatalf("an unable co-reviewer must not hold the round open: %+v", c.ReviewedBy) + } + // And no nudge: the run IS the answer. + cp := p.CoReviewers[0] + if DecideCoPost(round, obs, cp, false, fired, now) { + t.Fatal("re-triggering a bot that reported it cannot review is comment spam") + } +} + +// TestSelfHealGraceStartsWhenCrqSawTheHead: a force-push can point a PR at a +// commit authored long ago. Anchoring the grace on that commit's date reads as +// "elapsed hours ago" and posts a trigger over an auto-review that has barely +// started, so the round's own enqueue time — when crq first saw this head — +// wins when it is later. +func TestSelfHealGraceStartsWhenCrqSawTheHead(t *testing.T) { + now := time.Date(2026, 7, 25, 10, 0, 0, 0, time.UTC) + head := "abcdef123" + // The head commit was authored a month ago; crq saw it one minute ago. + obs := Observation{Head: head, Open: true, HeadAt: now.AddDate(0, -1, 0), + Events: []dialect.BotEvent{{Kind: dialect.EvOther, Bot: "coderabbitai[bot]", SummaryOnly: true, CommentID: 1}}, + Co: map[string]CoSeen{dialect.NormalizeBotName(bugbotLogin): {AutoActive: true}}} + round := state.Round{Repo: "o/r", PR: 1, Head: head, Phase: state.PhaseQueued, EnqueuedAt: now.Add(-time.Minute)} + p := Policy{Bot: "coderabbitai[bot]", RequiredBots: []string{"coderabbitai[bot]"}, + CoReviewers: []CoReviewerPolicy{{Login: bugbotLogin, Command: "bugbot run", + Trigger: TriggerSelfHeal, SelfHealGrace: 10 * time.Minute}}} + + if d := DecideFire(Global{SlotFree: true}, round, obs, now, p); d.Verdict == FireCoOnly { + t.Fatalf("grace must run from when crq saw the head, not the commit's date: %+v", d) + } + // Once the grace really has elapsed the trigger goes out as before. + if d := DecideFire(Global{SlotFree: true}, round, obs, now.Add(11*time.Minute), p); d.Verdict != FireCoOnly { + t.Fatalf("self-heal must still fire once the grace elapses: %+v", d) + } +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 76ce9c3..d874958 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -215,6 +215,21 @@ func TestReviewingRoundDeadlineBoundsCoReviewWait(t *testing.T) { if tr := Progress(reviewing(), state.AccountQuota{}, crAtHead, t0.Add(30*time.Minute), codexReq); tr.Outcome != KeepWaiting { t.Fatalf("before the deadline a co-review wait must keep waiting, got %+v", tr) } + + // The exception to "no primary review → keep waiting": a round whose primary + // review is never coming (a summary-only plan, a skipped review). Its + // deadline is the ONLY thing that can end it — `autoreview` runs no Loop, and + // the primary-reviewed branch above can never become true — so leaving it to + // the fall-through wedges it in `reviewing` forever, and with it every + // reviewing round behind it (the daemon sweeps only the oldest per pump). + summaryOnly := Observation{Head: "abcdef123", Open: true, + Events: []dialect.BotEvent{{Kind: dialect.EvOther, Bot: "coderabbitai[bot]", SummaryOnly: true, CommentID: 9}}} + if tr := Progress(reviewing(), state.AccountQuota{}, summaryOnly, past, codexReq); tr.Outcome != OutComplete { + t.Fatalf("a wait no primary review can ever satisfy must end at its deadline, got %+v", tr) + } + if tr := Progress(reviewing(), state.AccountQuota{}, summaryOnly, t0.Add(30*time.Minute), codexReq); tr.Outcome == OutComplete { + t.Fatalf("before the deadline it must still wait for the co-reviewer, got %+v", tr) + } } func TestDecideFireGuards(t *testing.T) { diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 9efc6ee..7b4c9f5 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -85,10 +85,26 @@ func selfHealAnchor(r state.Round, obs Observation, neverFires bool) time.Time { if at := roundCutoff(r); !at.IsZero() { return at } - if neverFires { - return obs.HeadAt + if !neverFires { + return time.Time{} } - return time.Time{} + // The grace period asks how long the bot has had to show up on its own, so + // the anchor must be when the head APPEARED — and the head commit's own date + // is only a lower bound on that. A force-push or branch reset can point a PR + // at a commit authored months earlier, which reads as a grace that elapsed + // long ago and posts a trigger over an auto-review that has barely started. + // The round is the better witness: crq enqueued it when it first saw this + // head, so take the later of the two. + // + // Deliberately NOT applied to obs.HeadAt itself, which is an evidence FLOOR + // elsewhere (fireCoReviewWait, SHA-less notices) and must stay a lower bound: + // raising it there discards a co-reviewer answer that landed in the seconds + // between the push and crq noticing it. + anchor := obs.HeadAt + if r.Head != "" && r.Head == obs.Head && r.EnqueuedAt.After(anchor) { + anchor = r.EnqueuedAt.UTC() + } + return anchor } // decideCoPosts collects the co-reviewer logins whose trigger crq should post diff --git a/internal/engine/progress.go b/internal/engine/progress.go index 05950bd..2b82c0c 100644 --- a/internal/engine/progress.go +++ b/internal/engine/progress.go @@ -69,8 +69,21 @@ func Progress(r state.Round, q state.AccountQuota, obs Observation, now time.Tim // with NO primary review is deliberately left to the fall-through (KeepWaiting): // the loop bounds and times out its own wait (exit 2), so an expired deadline // never resets or re-fires the same head. - if r.Phase == state.PhaseReviewing && r.WaitDeadline != nil && !now.Before(r.WaitDeadline.UTC()) && primaryReviewedHead(r, obs, p) { - return Transition{Outcome: OutComplete, Reason: "co-review wait elapsed; primary review stands"} + // + // The one round that fall-through cannot serve is a primary-UNAVAILABLE one: + // no primary review is ever coming (a summary-only plan, or a skipped + // review), so the condition above can never become true, and under + // `autoreview` there is no Loop to enforce the deadline either. Such a round + // would sit in `reviewing` forever — and because the daemon sweeps only the + // OLDEST reviewing round per pump, it would also starve every reviewing round + // behind it. Its deadline is the only thing that can end it. + if r.Phase == state.PhaseReviewing && r.WaitDeadline != nil && !now.Before(r.WaitDeadline.UTC()) { + if primaryReviewedHead(r, obs, p) { + return Transition{Outcome: OutComplete, Reason: "co-review wait elapsed; primary review stands"} + } + if PrimaryReviewUnavailable(obs, p, r.Head) { + return Transition{Outcome: OutComplete, Reason: "co-review wait elapsed; no primary review is coming for this head"} + } } // An "already reviewed" ack is only trusted alongside real review diff --git a/llms.txt b/llms.txt index 4228e6b..2091579 100644 --- a/llms.txt +++ b/llms.txt @@ -71,13 +71,16 @@ default). None of them spends CodeRabbit quota or takes the fire slot. Each has Bugbot/Macroscope default) only nudges a bot that reviews on its own but missed the head past `CRQ_COBOT__GRACE`, and `never` posts nothing. crq never triggers a bot that auto-reviews, has already reviewed the head, or has a check run in flight. A co-reviewer that joins a round uninvited -gates that round's convergence dynamically (an exhaustion notice such as Codex's usage limit -releases the dynamic gate); one listed in `CRQ_REQUIRED_BOTS` always gates. +gates that round's convergence dynamically (an exhaustion notice such as Codex's usage limit, or a +check run reporting the bot cannot review the commit, releases the dynamic gate); one listed in +`CRQ_REQUIRED_BOTS` always gates. Bugbot and Macroscope report through **check runs** as well as comments — a clean Bugbot round posts nothing at all on the timeline, so its `Cursor Bugbot` check IS the evidence crq converges on. `.co_reviewers[]` reports each one's `reviewed` / `check_state` -(`clean|issues|in_progress|unknown`) / `verdict`. Macroscope's approvability `verdict` +(`clean|issues|in_progress|failed|unable|unknown` — `failed` is a crashed run crq re-triggers, +`unable` a bot that reported it cannot review this commit at all, which crq stops waiting for and +never re-triggers) / `verdict`. Macroscope's approvability `verdict` (`approved|needs_human_review`) is informational only: it never gates convergence and never changes an exit code. If a bot reports actionable findings first, it emits them immediately while leaving the queued round alive. Fix and From b05fd1e7dbdc47fffc887d49fd6fa60af9ed4bc1 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 04:25:48 +0200 Subject: [PATCH 30/31] Add the Macroscope check-run captures those verdicts are pinned to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four live captures: the clean skip (nothing to review), the billing skip (cannot review), the error run, and a neutral conclusion that IS a real review — the last one being why the conclusion alone cannot decide. The billing capture is trimmed to the classifier-relevant fields; it comes from a private repo and the full envelope would publish its name and commits. --- .../testdata/macroscope/check-correctness-error.json | 1 + .../macroscope/check-correctness-neutral-issues.json | 1 + .../check-correctness-skipped-billing.json | 12 ++++++++++++ .../check-correctness-skipped-no-code.json | 1 + 4 files changed, 15 insertions(+) create mode 100644 internal/dialect/testdata/macroscope/check-correctness-error.json create mode 100644 internal/dialect/testdata/macroscope/check-correctness-neutral-issues.json create mode 100644 internal/dialect/testdata/macroscope/check-correctness-skipped-billing.json create mode 100644 internal/dialect/testdata/macroscope/check-correctness-skipped-no-code.json diff --git a/internal/dialect/testdata/macroscope/check-correctness-error.json b/internal/dialect/testdata/macroscope/check-correctness-error.json new file mode 100644 index 0000000..56dd334 --- /dev/null +++ b/internal/dialect/testdata/macroscope/check-correctness-error.json @@ -0,0 +1 @@ +{"app":{"client_id":"Iv23liK7cN4LHJJrxDDB","created_at":"2024-05-16T18:59:43Z","description":"[Macroscope](https://www.macroscope.com/) is an automated changelog for software development teams. It helps teams:\r\n- Stay informed about what’s happening in their code, product and team— all using the codebase as a source of truth.\r\n- Reduce the pain of code reviews by automatically summarizing Pull Requests and taking a first pass at reviewing code changes.","events":["check_run","check_suite","commit_comment","create","delete","fork","issues","issue_comment","member","membership","organization","public","pull_request","pull_request_review","pull_request_review_comment","pull_request_review_thread","push","release","repository","sub_issues","team","team_add","workflow_run"],"external_url":"https://app.macroscope.com","html_url":"https://github.com/apps/macroscopeapp","id":900172,"name":"MacroscopeApp","node_id":"A_kwHOCEtgjs4ADbxM","owner":{"avatar_url":"https://avatars.githubusercontent.com/u/139157646?v=4","events_url":"https://api.github.com/users/prassoai/events{/privacy}","followers_url":"https://api.github.com/users/prassoai/followers","following_url":"https://api.github.com/users/prassoai/following{/other_user}","gists_url":"https://api.github.com/users/prassoai/gists{/gist_id}","gravatar_id":"","html_url":"https://github.com/prassoai","id":139157646,"login":"prassoai","node_id":"O_kgDOCEtgjg","organizations_url":"https://api.github.com/users/prassoai/orgs","received_events_url":"https://api.github.com/users/prassoai/received_events","repos_url":"https://api.github.com/users/prassoai/repos","site_admin":false,"starred_url":"https://api.github.com/users/prassoai/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/prassoai/subscriptions","type":"Organization","url":"https://api.github.com/users/prassoai","user_view_type":"public"},"permissions":{"actions":"read","checks":"write","contents":"write","emails":"read","issues":"write","members":"read","metadata":"read","pull_requests":"write","workflows":"write"},"slug":"macroscopeapp","updated_at":"2026-07-16T19:51:56Z"},"check_suite":{"id":81681683536},"completed_at":"2026-07-25T01:54:41Z","conclusion":"neutral","details_url":"https://github.com/pingdotgg/t3code/pull/4497/checks?check_run_id=89629426475","external_id":"","head_sha":"df9a3f1ca83b87f24a7f6e23f2db6655d3a32698","html_url":"https://github.com/pingdotgg/t3code/runs/89629426475","id":89629426475,"name":"Macroscope - Correctness Check","node_id":"CR_kwDORLtfbc8AAAAU3lSDKw","output":{"annotations_count":0,"annotations_url":"https://api.github.com/repos/pingdotgg/t3code/check-runs/89629426475/annotations","summary":null,"text":null,"title":"Macroscope encountered an error while reviewing `df9a3f1`."},"pull_requests":[],"started_at":"2026-07-25T01:51:45Z","status":"completed","url":"https://api.github.com/repos/pingdotgg/t3code/check-runs/89629426475"} diff --git a/internal/dialect/testdata/macroscope/check-correctness-neutral-issues.json b/internal/dialect/testdata/macroscope/check-correctness-neutral-issues.json new file mode 100644 index 0000000..fe33caf --- /dev/null +++ b/internal/dialect/testdata/macroscope/check-correctness-neutral-issues.json @@ -0,0 +1 @@ +{"app":{"client_id":"Iv23liK7cN4LHJJrxDDB","created_at":"2024-05-16T18:59:43Z","description":"[Macroscope](https://www.macroscope.com/) is an automated changelog for software development teams. It helps teams:\r\n- Stay informed about what’s happening in their code, product and team— all using the codebase as a source of truth.\r\n- Reduce the pain of code reviews by automatically summarizing Pull Requests and taking a first pass at reviewing code changes.","events":["check_run","check_suite","commit_comment","create","delete","fork","issues","issue_comment","member","membership","organization","public","pull_request","pull_request_review","pull_request_review_comment","pull_request_review_thread","push","release","repository","sub_issues","team","team_add","workflow_run"],"external_url":"https://app.macroscope.com","html_url":"https://github.com/apps/macroscopeapp","id":900172,"name":"MacroscopeApp","node_id":"A_kwHOCEtgjs4ADbxM","owner":{"avatar_url":"https://avatars.githubusercontent.com/u/139157646?v=4","events_url":"https://api.github.com/users/prassoai/events{/privacy}","followers_url":"https://api.github.com/users/prassoai/followers","following_url":"https://api.github.com/users/prassoai/following{/other_user}","gists_url":"https://api.github.com/users/prassoai/gists{/gist_id}","gravatar_id":"","html_url":"https://github.com/prassoai","id":139157646,"login":"prassoai","node_id":"O_kgDOCEtgjg","organizations_url":"https://api.github.com/users/prassoai/orgs","received_events_url":"https://api.github.com/users/prassoai/received_events","repos_url":"https://api.github.com/users/prassoai/repos","site_admin":false,"starred_url":"https://api.github.com/users/prassoai/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/prassoai/subscriptions","type":"Organization","url":"https://api.github.com/users/prassoai","user_view_type":"public"},"permissions":{"actions":"read","checks":"write","contents":"write","emails":"read","issues":"write","members":"read","metadata":"read","pull_requests":"write","workflows":"write"},"slug":"macroscopeapp","updated_at":"2026-07-16T19:51:56Z"},"check_suite":{"id":81675273737},"completed_at":"2026-07-25T00:43:48Z","conclusion":"neutral","details_url":"https://github.com/pingdotgg/t3code/pull/4492/checks?check_run_id=89622610209","external_id":"","head_sha":"4e96cad51fe17380fe973ca9966b22d1f03315cd","html_url":"https://github.com/pingdotgg/t3code/runs/89622610209","id":89622610209,"name":"Macroscope - Correctness Check","node_id":"CR_kwDORLtfbc8AAAAU3eyBIQ","output":{"annotations_count":0,"annotations_url":"https://api.github.com/repos/pingdotgg/t3code/check-runs/89622610209/annotations","summary":"\u003e • Merge Base: `38cfc25`\n\u003e • Head: `4e96cad`","text":"| ✅ | File Path | Comments Posted | Reason |\n|----|-----------|-----------------|--------|\n| ❌ | `apps/web/src/index.css` | 1 | |\n","title":"1 issue identified (1 code object reviewed)."},"pull_requests":[],"started_at":"2026-07-25T00:43:01Z","status":"completed","url":"https://api.github.com/repos/pingdotgg/t3code/check-runs/89622610209"} diff --git a/internal/dialect/testdata/macroscope/check-correctness-skipped-billing.json b/internal/dialect/testdata/macroscope/check-correctness-skipped-billing.json new file mode 100644 index 0000000..86f3579 --- /dev/null +++ b/internal/dialect/testdata/macroscope/check-correctness-skipped-billing.json @@ -0,0 +1,12 @@ +{ + "name": "Macroscope - Correctness Check", + "status": "completed", + "conclusion": "skipped", + "output": { + "title": "Review skipped — billing issue", + "summary": "Macroscope did not review this commit due to a billing issue (e.g. usage limit reached or an unpaid invoice). A workspace admin can check the billing status in Macroscope Settings -> Billing. Feel free to reach out to us at support@macroscope.com" + }, + "app": { + "slug": "macroscopeapp" + } +} diff --git a/internal/dialect/testdata/macroscope/check-correctness-skipped-no-code.json b/internal/dialect/testdata/macroscope/check-correctness-skipped-no-code.json new file mode 100644 index 0000000..722fb10 --- /dev/null +++ b/internal/dialect/testdata/macroscope/check-correctness-skipped-no-code.json @@ -0,0 +1 @@ +{"app":{"client_id":"Iv23liK7cN4LHJJrxDDB","created_at":"2024-05-16T18:59:43Z","description":"[Macroscope](https://www.macroscope.com/) is an automated changelog for software development teams. It helps teams:\r\n- Stay informed about what’s happening in their code, product and team— all using the codebase as a source of truth.\r\n- Reduce the pain of code reviews by automatically summarizing Pull Requests and taking a first pass at reviewing code changes.","events":["check_run","check_suite","commit_comment","create","delete","fork","issues","issue_comment","member","membership","organization","public","pull_request","pull_request_review","pull_request_review_comment","pull_request_review_thread","push","release","repository","sub_issues","team","team_add","workflow_run"],"external_url":"https://app.macroscope.com","html_url":"https://github.com/apps/macroscopeapp","id":900172,"name":"MacroscopeApp","node_id":"A_kwHOCEtgjs4ADbxM","owner":{"avatar_url":"https://avatars.githubusercontent.com/u/139157646?v=4","events_url":"https://api.github.com/users/prassoai/events{/privacy}","followers_url":"https://api.github.com/users/prassoai/followers","following_url":"https://api.github.com/users/prassoai/following{/other_user}","gists_url":"https://api.github.com/users/prassoai/gists{/gist_id}","gravatar_id":"","html_url":"https://github.com/prassoai","id":139157646,"login":"prassoai","node_id":"O_kgDOCEtgjg","organizations_url":"https://api.github.com/users/prassoai/orgs","received_events_url":"https://api.github.com/users/prassoai/received_events","repos_url":"https://api.github.com/users/prassoai/repos","site_admin":false,"starred_url":"https://api.github.com/users/prassoai/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/prassoai/subscriptions","type":"Organization","url":"https://api.github.com/users/prassoai","user_view_type":"public"},"permissions":{"actions":"read","checks":"write","contents":"write","emails":"read","issues":"write","members":"read","metadata":"read","pull_requests":"write","workflows":"write"},"slug":"macroscopeapp","updated_at":"2026-07-16T19:51:56Z"},"check_suite":{"id":81673266474},"completed_at":"2026-07-25T00:23:48Z","conclusion":"skipped","details_url":"https://github.com/pingdotgg/t3code/pull/4493/checks?check_run_id=89620364315","external_id":"","head_sha":"06bffed09e15b469ce29f79af25b2ed743dd11c9","html_url":"https://github.com/pingdotgg/t3code/runs/89620364315","id":89620364315,"name":"Macroscope - Correctness Check","node_id":"CR_kwDORLtfbc8AAAAU3co8Gw","output":{"annotations_count":0,"annotations_url":"https://api.github.com/repos/pingdotgg/t3code/check-runs/89620364315/annotations","summary":"\u003e • Merge Base: `41a430a`\n\u003e • Head: `06bffed`","text":null,"title":"No code objects were reviewed."},"pull_requests":[],"started_at":"2026-07-25T00:23:42Z","status":"completed","url":"https://api.github.com/repos/pingdotgg/t3code/check-runs/89620364315"} From 941aa71a1b4dfd2010487431bc0d292c6253739d Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 25 Jul 2026 04:28:59 +0200 Subject: [PATCH 31/31] Name the reviewer that skipped, not CodeRabbit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synthetic skip finding hardcoded "CodeRabbit" in both its title and its fallback reason, and the pre-enqueue drain then identified it by matching that same display text. The primary reviewer is configurable (CRQ_BOT), so on any repo pointed elsewhere the notice named the wrong bot — and the drain's only handle on the one finding it must exempt was a string a translation or a rewording would break. The finding now names whichever bot posted the notice and carries `source: "review_skipped"`, which is what the drain keys on. Bot identity leaves internal/crq entirely. Also renames two test locals that shadowed the predeclared `real`. --- README.md | 7 ++++--- internal/crq/feedback.go | 22 ++++++++++++++-------- internal/crq/feedback_test.go | 8 ++++---- internal/state/state_test.go | 4 ++-- llms.txt | 5 +++-- 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b4b24af..d42169b 100644 --- a/README.md +++ b/README.md @@ -509,9 +509,10 @@ with CodeRabbit's *rate-limit marker embedded*, so a naive reading files it as a would invent a retry window that never clears, re-fire the same PR forever, and park the **account-wide** quota so every other PR in the fleet stalls behind one oversized diff. crq classifies it as its own state instead — no account block, no retry loop. The round resolves on the -co-reviewers, and the skip is surfaced as a `major` finding so the PR actually gets narrowed. The -refusal binds to the SHA the notice names, so splitting the PR yields a head crq fires normally -again. +co-reviewers, and the skip is surfaced as a `major` finding (`source: "review_skipped"` — the one +finding with no thread to resolve, since only changing the PR addresses it) so the PR actually gets +narrowed. The refusal binds to the SHA the notice names, so splitting the PR yields a head crq fires +normally again. Both cases set `primary_review_unavailable` in `crq feedback --json`, with a `primary_review_unavailable_reason` naming which. That flag is load-bearing for agents: without it diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index b1765bc..5e38910 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -329,9 +329,13 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe // would converge with a silently absent primary reviewer — worse than // a loud one. The review did not happen and never will for this head, // so surface it as work: narrow the PR to get a review. + // The reviewer's own name, not a hardcoded one: the primary bot is + // configurable (CRQ_BOT), so naming CodeRabbit here mislabels the + // notice on every repo pointed at a different reviewer. + who := dialect.NormalizeBotName(comment.User.Login) reason := dialect.ReviewSkippedReason(comment.Body) if reason == "" { - reason = "CodeRabbit skipped the review for this head." + reason = who + " skipped the review for this head." } report.Findings = append(report.Findings, dialect.Finding{ Bot: comment.User.Login, @@ -342,11 +346,11 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe // never ran at all. Commit: head, Severity: "major", - Title: skipNoticeTitlePrefix + " — narrow the PR to get one: " + reason, + Title: who + " skipped this review — narrow the PR to get one: " + reason, Body: strings.TrimSpace(dialect.CompactReviewBody(comment.Body)), CommentID: comment.ID, URL: comment.URL, - Source: "issue_comment", + Source: skipNoticeSource, CreatedAt: comment.CreatedAt, }) continue @@ -1246,16 +1250,18 @@ func dedupeFindings(in []dialect.Finding, suppressPromptAt, settledStableIDs map return out } -// skipNoticeTitlePrefix identifies the synthetic finding Feedback builds from a -// "Review skipped" notice. It is the only finding with no thread to resolve and -// no way to be addressed except by changing the PR itself. -const skipNoticeTitlePrefix = "CodeRabbit skipped this review" +// skipNoticeSource marks the synthetic finding Feedback builds from a "Review +// skipped" notice. It is the only finding with no thread to resolve and no way +// to be addressed except by changing the PR itself, so it is the one finding +// the pre-enqueue drain exempts — identified by this source rather than by its +// own display text, which names whichever bot posted it. +const skipNoticeSource = "review_skipped" // excludeSkipNotice drops the synthetic skip finding from a blocking set. func excludeSkipNotice(findings []dialect.Finding) []dialect.Finding { out := findings[:0:0] for _, f := range findings { - if strings.HasPrefix(f.Title, skipNoticeTitlePrefix) { + if f.Source == skipNoticeSource { continue } out = append(out, f) diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index 5faae87..eaa08a3 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -2174,15 +2174,15 @@ func TestBugbotStableIDSettledInAnySiblingThread(t *testing.T) { // same head must still hold it, as on any other PR — an earlier version // bypassed every blocking finding whenever the primary was unavailable. func TestExcludeSkipNoticeIsTargeted(t *testing.T) { - skip := dialect.Finding{Bot: "coderabbitai[bot]", Severity: "major", - Title: skipNoticeTitlePrefix + " — narrow the PR to get one: Too many files!"} - real := dialect.Finding{Bot: dialect.CodexBotLogin, Severity: "major", + skip := dialect.Finding{Severity: "major", Source: skipNoticeSource, + Title: "some-reviewer skipped this review — narrow the PR to get one: Too many files!"} + blocking := dialect.Finding{Severity: "major", Source: "review_thread", Title: "Nil deref in the retry path", ThreadID: "PRRT_x"} if got := excludeSkipNotice([]dialect.Finding{skip}); len(got) != 0 { t.Fatalf("the skip notice must not hold the round, got %+v", got) } - got := excludeSkipNotice([]dialect.Finding{skip, real}) + got := excludeSkipNotice([]dialect.Finding{skip, blocking}) if len(got) != 1 || got[0].ThreadID != "PRRT_x" { t.Fatalf("a genuine finding must still hold the head, got %+v", got) } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index eba331a..9f53472 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -432,8 +432,8 @@ func TestRequestedRoundsExcludesCoOnly(t *testing.T) { s := New() fired := t0.Add(time.Minute) - real := Round{Repo: "o/public", PR: 1, Head: "aaaaaaaa1", Seq: 1, Phase: PhaseCompleted, FiredAt: &fired} - s.Rounds[Key(real.Repo, real.PR)] = real + requested := Round{Repo: "o/public", PR: 1, Head: "aaaaaaaa1", Seq: 1, Phase: PhaseCompleted, FiredAt: &fired} + s.Rounds[Key(requested.Repo, requested.PR)] = requested coOnly := Round{Repo: "o/private", PR: 2, Head: "bbbbbbbb2", Seq: 2, Phase: PhaseCompleted, FiredAt: &fired, CoOnly: true} s.Rounds[Key(coOnly.Repo, coOnly.PR)] = coOnly archivedCoOnly := coOnly diff --git a/llms.txt b/llms.txt index 2091579..c12923d 100644 --- a/llms.txt +++ b/llms.txt @@ -119,8 +119,9 @@ that never spends quota). The round is resolved by the co-reviewers alone, so tr Skipped reviews: CodeRabbit may refuse a head outright (`Review skipped` — too many files, no usage credits). That notice embeds the rate-limit marker but is NOT a timed block: nothing clears by waiting. crq treats it as "no primary review is coming for this head", converges on the co-reviewers, -never records an account block, and returns the skip as a `major` finding telling you to narrow the -PR. It binds to the SHA it names, so a split PR is reviewed normally. +never records an account block, and returns the skip as a `major` `source: "review_skipped"` finding +telling you to narrow the PR — the one finding with no thread to resolve, since only changing the PR +addresses it. It binds to the SHA it names, so a split PR is reviewed normally. Summary-only plans: on a private repo under a Free CodeRabbit plan the bot posts a walkthrough and never a line-by-line review ("Summarized by CodeRabbit Free … upgrade … for a comprehensive