From aaaf009482eb1026f394d13e02c3267e914bfece Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 15:04:12 +0200 Subject: [PATCH 01/20] Extract bot-text knowledge into internal/dialect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All CodeRabbit/Codex message heuristics — completion phrases, rate-limit notices, in-progress/failure summaries, findings markup parsers, SHA conventions — move to a dependency-free internal/dialect package. Config markers are injected as CodeRabbit struct fields instead of read from cfg inside classifiers. A golden corpus (internal/dialect/testdata) now pins one file per known message format; existing crq call sites and tests are kept stable through thin aliases in dialect_aliases.go that die with the engine extraction. --- internal/crq/dialect_aliases.go | 48 ++ internal/crq/feedback.go | 531 +----------------- internal/crq/service.go | 137 +---- internal/dialect/coderabbit.go | 319 +++++++++++ internal/dialect/codex.go | 142 +++++ internal/dialect/common.go | 209 +++++++ internal/dialect/finding.go | 51 ++ internal/dialect/golden_test.go | 181 ++++++ .../testdata/coderabbit/already-reviewed.md | 2 + .../testdata/coderabbit/completion-reply.md | 2 + .../coderabbit/findings-failed-to-post.md | 15 + .../coderabbit/findings-nested-quotes.md | 27 + .../coderabbit/findings-outside-diff.md | 19 + .../coderabbit/findings-prompt-block.md | 16 + .../coderabbit/no-actionable-comments.md | 3 + .../coderabbit/rate-limit-bold-window.md | 8 + .../coderabbit/rate-limit-fair-usage.md | 2 + .../testdata/coderabbit/rate-limit-legacy.md | 1 + .../testdata/coderabbit/review-failed.md | 5 + .../testdata/coderabbit/review-in-progress.md | 8 + .../testdata/coderabbit/reviews-paused.md | 4 + .../testdata/codex/clean-summary-legacy.md | 1 + .../testdata/codex/clean-summary-tada.md | 3 + .../testdata/codex/findings-outside-diff.md | 10 + .../dialect/testdata/codex/usage-limit.md | 1 + 25 files changed, 1095 insertions(+), 650 deletions(-) create mode 100644 internal/crq/dialect_aliases.go create mode 100644 internal/dialect/coderabbit.go create mode 100644 internal/dialect/codex.go create mode 100644 internal/dialect/common.go create mode 100644 internal/dialect/finding.go create mode 100644 internal/dialect/golden_test.go create mode 100644 internal/dialect/testdata/coderabbit/already-reviewed.md create mode 100644 internal/dialect/testdata/coderabbit/completion-reply.md create mode 100644 internal/dialect/testdata/coderabbit/findings-failed-to-post.md create mode 100644 internal/dialect/testdata/coderabbit/findings-nested-quotes.md create mode 100644 internal/dialect/testdata/coderabbit/findings-outside-diff.md create mode 100644 internal/dialect/testdata/coderabbit/findings-prompt-block.md create mode 100644 internal/dialect/testdata/coderabbit/no-actionable-comments.md create mode 100644 internal/dialect/testdata/coderabbit/rate-limit-bold-window.md create mode 100644 internal/dialect/testdata/coderabbit/rate-limit-fair-usage.md create mode 100644 internal/dialect/testdata/coderabbit/rate-limit-legacy.md create mode 100644 internal/dialect/testdata/coderabbit/review-failed.md create mode 100644 internal/dialect/testdata/coderabbit/review-in-progress.md create mode 100644 internal/dialect/testdata/coderabbit/reviews-paused.md create mode 100644 internal/dialect/testdata/codex/clean-summary-legacy.md create mode 100644 internal/dialect/testdata/codex/clean-summary-tada.md create mode 100644 internal/dialect/testdata/codex/findings-outside-diff.md create mode 100644 internal/dialect/testdata/codex/usage-limit.md diff --git a/internal/crq/dialect_aliases.go b/internal/crq/dialect_aliases.go new file mode 100644 index 0000000..b744d56 --- /dev/null +++ b/internal/crq/dialect_aliases.go @@ -0,0 +1,48 @@ +package crq + +import ( + "github.com/kristofferR/coderabbit-queue/internal/dialect" +) + +// Aliases for the bot-text helpers that moved to internal/dialect. They keep +// existing call sites and tests stable while the refactor lands package by +// package; new code should call dialect directly. Deleted once the engine +// extraction rewrites the callers. + +type Finding = dialect.Finding + +var ( + severityOf = dialect.SeverityOf + rankSeverity = dialect.RankSeverity + titleOf = dialect.TitleOf + stripMarkdownQuote = dialect.StripMarkdownQuote + compactReviewBody = dialect.CompactReviewBody + normalizeReviewText = dialect.NormalizeReviewText + looksLikePath = dialect.LooksLikePath + normalizeBotName = dialect.NormalizeBotName + inBots = dialect.InBots + botSet = dialect.BotSet + isCodexBot = dialect.IsCodexBot + hasCodexBot = dialect.HasCodexBot + isNonActionableText = dialect.IsNonActionableText + isActionableFinding = dialect.IsActionableFinding + isNoActionReviewCompletion = dialect.IsNoActionReviewCompletion + isCodexNoActionReviewCompletion = dialect.IsCodexNoActionReviewCompletion + codexReviewedCommitSHA = dialect.CodexReviewedCommitSHA + shaPrefixMatch = dialect.SHAPrefixMatch + shortOID = dialect.ShortOID + parseAvailableIn = dialect.ParseAvailableIn + parseQuota = dialect.ParseQuota + parseRemainingReviews = dialect.ParseRemainingReviews +) + +// parseReviewBodyFindings adapts a GitHub review to the dialect parsers, which +// take only the metadata they attach to findings. +func parseReviewBodyFindings(review Review, bot string) []Finding { + return dialect.ParseReviewBodyFindings(review.Body, dialect.ReviewMeta{ + ID: review.ID, + CommitID: review.CommitID, + HTMLURL: review.HTMLURL, + SubmittedAt: review.SubmittedAt, + }, bot) +} diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 41f1341..cbf165a 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -6,31 +6,12 @@ import ( "encoding/hex" "encoding/json" "errors" - "net/url" - "regexp" "sort" "strconv" "strings" "time" ) -type Finding struct { - ID string `json:"id"` - Bot string `json:"bot"` - Severity string `json:"severity"` - Path string `json:"path,omitempty"` - Line int `json:"line,omitempty"` - Title string `json:"title"` - Body string `json:"body"` - ThreadID string `json:"thread_id,omitempty"` - CommentID int64 `json:"comment_id,omitempty"` - ReviewID int64 `json:"review_id,omitempty"` - Commit string `json:"commit,omitempty"` - URL string `json:"url,omitempty"` - Source string `json:"source"` - CreatedAt time.Time `json:"created_at,omitempty"` -} - type FeedbackReport struct { Status string `json:"status"` Repo string `json:"repo"` @@ -1023,22 +1004,6 @@ func (s *Service) reviewThreads(ctx context.Context, repo string, pr int) ([]rev return all, nil } -var ( - detailSummaryRE = regexp.MustCompile(`(?i)\s*([^<]+?)\s+\([0-9]+\)\s*`) - // Line headers come backticked in "Outside diff range comments" (`12-15`:) and - // un-backticked in "Comments failed to post" (12-15:) — accept both. - detailHeaderRE = regexp.MustCompile("^`?([0-9]+)(?:\\s*-\\s*([0-9]+))?`?: *(.*)$") - promptBlockRE = regexp.MustCompile("(?is)[^<]*Prompt for all review comments with AI agents[^<]*.*?```\\s*(.*?)\\s*```") - promptFileRE = regexp.MustCompile("^In (?:`@([^`]+)`|@([^:]+)):$") - promptBulletRE = regexp.MustCompile("^- (?:Around line|Line)\\s+([0-9]+)(?:\\s*-\\s*([0-9]+))?:\\s*(.*)$") - boldTitleRE = regexp.MustCompile(`(?m)^\*\*([^*\n]+)\*\*`) - codexBlobLinkRE = regexp.MustCompile(`(?m)^https://github\.com/[^/\s]+/[^/\s]+/blob/([0-9a-fA-F]{7,64})/(.+?)#L([0-9]+)(?:-L([0-9]+))?\s*$`) - markdownImageRE = regexp.MustCompile(`!\[[^]]*\]\([^)]+\)`) - subTagRE = regexp.MustCompile(`(?i)`) - crCommentRE = regexp.MustCompile(``) - htmlCommentRE = regexp.MustCompile(`(?s)`) -) - // reviewNewer reports whether review a supersedes b: later submission wins, and // a higher ID breaks ties (equal/zero timestamps) so selection is deterministic. func reviewNewer(a, b Review) bool { @@ -1048,201 +1013,6 @@ func reviewNewer(a, b Review) bool { return a.ID > b.ID } -func parseReviewBodyFindings(review Review, bot string) []Finding { - body := strings.TrimSpace(review.Body) - if body == "" { - return nil - } - clean := stripMarkdownQuote(body) - out := parseDetailedReviewFindings(clean, review, bot) - out = append(out, parsePromptReviewFindings(clean, review, bot)...) - out = append(out, parseCodexReviewFindings(clean, review, bot)...) - return out -} - -// parseCodexReviewFindings extracts findings that Codex can only place in the -// review body when the referenced line is outside GitHub's current diff. Codex -// anchors each item with a blob URL followed by a bold priority/title line. -func parseCodexReviewFindings(body string, review Review, bot string) []Finding { - if !isCodexBot(bot) { - return nil - } - matches := codexBlobLinkRE.FindAllStringSubmatchIndex(body, -1) - out := make([]Finding, 0, len(matches)) - for index, match := range matches { - blockEnd := len(body) - if index+1 < len(matches) { - blockEnd = matches[index+1][0] - } - block := strings.TrimSpace(body[match[1]:blockEnd]) - if details := strings.Index(strings.ToLower(block), "= 0 { - block = strings.TrimSpace(block[:details]) - } - if block == "" { - continue - } - - path, err := url.PathUnescape(body[match[4]:match[5]]) - if err != nil { - path = body[match[4]:match[5]] - } - line, _ := strconv.Atoi(body[match[6]:match[7]]) - title := "" - if titleMatch := boldTitleRE.FindStringSubmatch(block); titleMatch != nil { - title = cleanCodexReviewText(titleMatch[1]) - } - if title == "" { - title = titleOf(block) - } - finding := Finding{ - Bot: bot, - Severity: codexPrioritySeverity(block), - Path: path, - Line: line, - Title: title, - Body: cleanCodexReviewText(compactReviewBody(block)), - ReviewID: review.ID, - Commit: shortOID(body[match[2]:match[3]]), - URL: review.HTMLURL, - Source: "review_body", - CreatedAt: review.SubmittedAt, - } - if isActionableFinding(finding) { - out = append(out, finding) - } - } - return out -} - -func cleanCodexReviewText(text string) string { - text = markdownImageRE.ReplaceAllString(text, "") - text = subTagRE.ReplaceAllString(text, "") - return strings.TrimSpace(text) -} - -func codexPrioritySeverity(text string) string { - lower := strings.ToLower(text) - switch { - case strings.Contains(lower, "![p0 badge]"): - return "critical" - case strings.Contains(lower, "![p1 badge]"): - return "major" - case strings.Contains(lower, "![p2 badge]"), strings.Contains(lower, "![p3 badge]"): - return "minor" - default: - return severityOf(text) - } -} - -func parseDetailedReviewFindings(body string, review Review, bot string) []Finding { - lines := strings.Split(body, "\n") - var out []Finding - currentPath := "" - for i := 0; i < len(lines); i++ { - line := strings.TrimSpace(lines[i]) - if match := detailSummaryRE.FindStringSubmatch(line); match != nil { - summary := strings.TrimSpace(match[1]) - if looksLikePath(summary) { - currentPath = summary - } - continue - } - match := detailHeaderRE.FindStringSubmatch(line) - if match == nil || currentPath == "" { - continue - } - startLine, _ := strconv.Atoi(match[1]) - meta := strings.TrimSpace(match[3]) - if isNonActionableText(meta) { - continue - } - start := i + 1 - end := len(lines) - for j := start; j < len(lines); j++ { - next := strings.TrimSpace(lines[j]) - if detailHeaderRE.MatchString(next) || detailSummaryRE.MatchString(next) { - end = j - break - } - } - block := strings.TrimSpace(strings.Join(lines[start:end], "\n")) - title := titleFromDetailedBlock(block) - if title == "" { - title = titleOf(block) - } - bodyText := compactReviewBody(block) - finding := Finding{ - Bot: bot, - Severity: severityOf(meta + "\n" + block), - Path: strings.TrimPrefix(currentPath, "@"), - Line: startLine, - Title: title, - Body: bodyText, - ReviewID: review.ID, - Commit: shortOID(review.CommitID), - URL: review.HTMLURL, - Source: "review_body", - CreatedAt: review.SubmittedAt, - } - if isActionableFinding(finding) { - out = append(out, finding) - } - } - return out -} - -func parsePromptReviewFindings(body string, review Review, bot string) []Finding { - var out []Finding - for _, blockMatch := range promptBlockRE.FindAllStringSubmatch(body, -1) { - block := blockMatch[1] - lines := strings.Split(block, "\n") - currentPath := "" - for i := 0; i < len(lines); i++ { - line := strings.TrimSpace(lines[i]) - if match := promptFileRE.FindStringSubmatch(line); match != nil { - currentPath = firstNonEmpty(match[1], match[2]) - currentPath = strings.TrimPrefix(currentPath, "@") - continue - } - match := promptBulletRE.FindStringSubmatch(line) - if match == nil || currentPath == "" { - continue - } - startLine, _ := strconv.Atoi(match[1]) - parts := []string{strings.TrimSpace(match[3])} - for j := i + 1; j < len(lines); j++ { - next := strings.TrimSpace(lines[j]) - if next == "" { - continue - } - if strings.HasPrefix(next, "---") || promptFileRE.MatchString(next) || promptBulletRE.MatchString(next) { - break - } - parts = append(parts, next) - i = j - } - bodyText := strings.TrimSpace(strings.Join(parts, " ")) - finding := Finding{ - Bot: bot, - Severity: severityOf(bodyText), - Path: currentPath, - Line: startLine, - Title: titleOf(bodyText), - Body: bodyText, - ReviewID: review.ID, - Commit: shortOID(review.CommitID), - URL: review.HTMLURL, - Source: "review_prompt", - CreatedAt: review.SubmittedAt, - } - if isActionableFinding(finding) { - out = append(out, finding) - } - } - } - return out -} - // threadFindings turns one GitHub review thread into findings. An unresolved, // non-outdated thread is still actionable no matter which commit its comments // were filed on: GitHub's own resolution/outdated state is the source of truth, @@ -1332,68 +1102,7 @@ func dedupeFindings(in []Finding, suppressPromptAt map[string]bool) []Finding { return out } -func botSet(bots []string) map[string]struct{} { - out := map[string]struct{}{} - for _, bot := range bots { - bot = strings.TrimSpace(bot) - if bot != "" { - out[bot] = struct{}{} - } - } - return out -} - -// inBots matches a comment author against the configured bots, tolerating the -// "[bot]" suffix: GitHub's REST API reports "coderabbitai[bot]" but GraphQL -// (review threads) reports "coderabbitai", and the config may use either form. -// Without this, crq missed every review-thread finding and so never surfaced a -// thread_id to resolve. -func inBots(bots map[string]struct{}, login string) bool { - if _, ok := bots[login]; ok { - return true - } - stripped := strings.TrimSuffix(login, "[bot]") - if _, ok := bots[stripped]; ok { - return true - } - _, ok := bots[stripped+"[bot]"] - return ok -} - -func normalizeBotName(login string) string { - return strings.TrimSuffix(login, "[bot]") -} - -// isCompletionReply reports whether body is the bot's reply to a processed -// review command (CodeRabbit: "Review finished."). An empty marker disables -// the completion-reply convergence fallback entirely. -func (s *Service) isCompletionReply(body string) bool { - marker := strings.TrimSpace(s.cfg.CompletionMarker) - if marker == "" { - return false - } - return strings.Contains(strings.ToLower(body), strings.ToLower(marker)) -} - -// 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 -// state, so the reply alone is not a terminal signal. -func isReviewInProgress(body string) bool { - lower := strings.ToLower(body) - return strings.Contains(lower, "currently processing new changes in this pr") || - strings.Contains(lower, "review in progress by coderabbit.ai") -} - -// isReviewFailure reports whether body is CodeRabbit's editable top-summary -// failure state. CodeRabbit can still change the command reply to "Review -// finished" after this summary reports that the review itself failed, so the -// reply is not evidence that the current head was reviewed successfully. -func isReviewFailure(body string) bool { - lower := strings.ToLower(body) - return strings.Contains(lower, "auto-generated comment: failure by coderabbit.ai") || - strings.Contains(lower, "## review failed") -} +func (s *Service) isCompletionReply(body string) bool { return s.cr.IsCompletionReply(body) } // hasNonterminalReviewState reports whether CodeRabbit currently exposes a // post-command state that contradicts a terminal completion reply. The top @@ -1404,7 +1113,7 @@ func (s *Service) hasNonterminalReviewState(comments []IssueComment, since time. if !s.isConfiguredBot(comment.User.Login) || !notBefore(issueCommentTime(comment), since) { continue } - if isReviewInProgress(comment.Body) || s.isRateLimited(comment.Body) || s.isReviewsPaused(comment.Body) { + if s.cr.IsReviewInProgress(comment.Body) || s.isRateLimited(comment.Body) || s.isReviewsPaused(comment.Body) { return true } } @@ -1416,24 +1125,14 @@ func (s *Service) hasFailedReviewState(comments []IssueComment, since time.Time) if !s.isConfiguredBot(comment.User.Login) || !notBefore(issueCommentTime(comment), since) { continue } - if isReviewFailure(comment.Body) { + if s.cr.IsReviewFailure(comment.Body) { return true } } return false } -// isAutoReply reports whether body is one of the bot's auto-generated replies -// to a command — completion, rate-limit, skip, or progress. The bot posts -// exactly one per command, which is what lets completions be paired to the -// command they answer. -func (s *Service) isAutoReply(body string) bool { - marker := strings.TrimSpace(s.cfg.CalibrationMarker) - if marker == "" { - return false - } - return strings.Contains(strings.ToLower(body), strings.ToLower(marker)) -} +func (s *Service) isAutoReply(body string) bool { return s.cr.IsAutoReply(body) } // applyCompletionReplyFallback marks the configured bot reviewed when a // no-findings re-review completed by issue-comment reply rather than a review @@ -1625,19 +1324,6 @@ func needsConfiguredBotReview(reviewedBy map[string]bool, login string) bool { return false } -func isCodexBot(login string) bool { - return normalizeBotName(login) == "chatgpt-codex-connector" -} - -func hasCodexBot(bots []string) bool { - for _, bot := range bots { - if isCodexBot(bot) { - return true - } - } - return false -} - func isCurrentCodexThumbsUp(reaction Reaction, since time.Time) bool { if !isCodexBot(reaction.User.Login) || reaction.Content != "+1" { return false @@ -1670,208 +1356,6 @@ func reviewedByBot(reviewedBy map[string]bool, login string) bool { return false } -func severityOf(text string) string { - lower := strings.ToLower(text) - switch { - case strings.Contains(lower, "critical"), strings.Contains(lower, "🔴"): - return "critical" - case strings.Contains(lower, "major"), strings.Contains(lower, "high"), strings.Contains(lower, "🟠"): - return "major" - case strings.Contains(lower, "potential issue"), strings.Contains(lower, "medium"), strings.Contains(lower, "🟡"): - return "potential" - case strings.Contains(lower, "nitpick"), strings.Contains(lower, "minor"), strings.Contains(lower, "low"), strings.Contains(lower, "🔵"): - return "minor" - default: - return "unknown" - } -} - -func rankSeverity(sev string) int { - switch sev { - case "critical": - return 5 - case "major": - return 4 - case "potential": - return 3 - case "minor": - return 2 - default: - return 1 - } -} - -func titleOf(body string) string { - for _, line := range strings.Split(body, "\n") { - line = strings.TrimSpace(line) - line = strings.Trim(line, "#*_` ") - if line != "" && !strings.HasPrefix(line, " 180 { - line = line[:180] - } - return line - } - } - return "Review finding" -} - -func titleFromDetailedBlock(body string) string { - if match := boldTitleRE.FindStringSubmatch(body); match != nil { - return strings.TrimSpace(match[1]) - } - return "" -} - -func stripMarkdownQuote(body string) string { - lines := strings.Split(body, "\n") - for i, line := range lines { - line = strings.TrimRight(line, " \t") - // CodeRabbit nests review-body sections (outside-diff-range, - // duplicates, nitpicks) several blockquote levels deep — strip - // every leading quote marker, not just the first. - for strings.HasPrefix(line, ">") { - line = strings.TrimPrefix(line, ">") - line = strings.TrimPrefix(line, " ") - } - lines[i] = line - } - return strings.Join(lines, "\n") -} - -func compactReviewBody(body string) string { - body = crCommentRE.ReplaceAllString(body, "") - body = htmlCommentRE.ReplaceAllString(body, "") - body = strings.ReplaceAll(body, "\r\n", "\n") - lines := strings.Split(body, "\n") - var out []string - skipFence := false - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "```") { - skipFence = !skipFence - continue - } - if skipFence || strings.HasPrefix(trimmed, "= 3 && strings.Trim(trimmed, "-_* ") == "" - if trimmed != "" && !isRule { - out = append(out, trimmed) - } - } - return strings.Join(out, "\n") -} - -var rootFileRE = regexp.MustCompile(`^[A-Za-z0-9._+-]+$`) - -func looksLikePath(summary string) bool { - summary = strings.TrimSpace(summary) - if summary == "" || strings.Contains(summary, " ") { - return false - } - if strings.Contains(summary, "/") || strings.Contains(summary, ".") { - return true - } - // Root-level files often have neither a slash nor a dot (Dockerfile, Makefile, - // LICENSE). In a " (N)" detail summary a single filename-safe token is a - // file, so accept it rather than dropping its findings. - return rootFileRE.MatchString(summary) -} - -func isActionableFinding(finding Finding) bool { - title := compactReviewBody(finding.Title) - body := compactReviewBody(finding.Body) - if title == "" && body == "" { - return false - } - text := strings.ToLower(title + "\n" + body) - return !isNonActionableText(text) -} - -func isNonActionableText(text string) bool { - text = normalizeReviewText(text) - if isCodexNoActionReviewCompletion(text) { - return true - } - nonActionable := []string{ - "lgtm", - "also applies to:", - "no issue here", - "incorrect or invalid review comment", - "likely an incorrect or invalid review comment", - "version claim", - "both referenced files exist", - "good regression test", - "already fixed", - "now fixed", - "no further action is needed", - "confirm intended ux", - "worth confirming", - "skipped: comment is from another github bot", - "you have reached your codex usage limits for code reviews", - } - for _, phrase := range nonActionable { - if strings.Contains(text, phrase) { - return true - } - } - return false -} - -func isNoActionReviewCompletion(text string) bool { - text = normalizeReviewText(text) - return strings.Contains(text, "no actionable comments were generated in the recent review") || - isCodexNoActionReviewCompletion(text) -} - -func isCodexNoActionReviewCompletion(text string) bool { - text = normalizeReviewText(text) - if !strings.Contains(text, "didn't find any major issues") { - return false - } - // Codex has shipped several clean-summary tails: the original - // "Keep them coming!", and the newer ":tada:" flourish with a - // "**Reviewed commit:** `sha`" line. - return strings.Contains(text, "keep them coming") || - strings.Contains(text, ":tada:") || - strings.Contains(text, "🎉") || - codexReviewedCommitSHA(text) != "" -} - -// codexReviewedCommitRE matches Codex's "**Reviewed commit:** `4d9e8bca82`" -// line in the newer clean-summary format. -var codexReviewedCommitRE = regexp.MustCompile("(?i)reviewed commit[:*\\s]*`([0-9a-fA-F]{7,40})`") - -// codexReviewedCommitSHA extracts the commit hash Codex says it reviewed, -// or "" when the comment carries no such line. -func codexReviewedCommitSHA(text string) string { - match := codexReviewedCommitRE.FindStringSubmatch(text) - if len(match) == 2 { - return strings.ToLower(match[1]) - } - return "" -} - -// shaPrefixMatch reports whether two commit-hash abbreviations refer to the -// same commit: both are prefixes of the full SHA, so the shorter must prefix -// the longer (crq truncates heads to 9 chars; Codex abbreviates to 10). -func shaPrefixMatch(a, b string) bool { - a, b = strings.ToLower(a), strings.ToLower(b) - if a == "" || b == "" { - return false - } - if len(a) <= len(b) { - return strings.HasPrefix(b, a) - } - return strings.HasPrefix(a, b) -} - -func normalizeReviewText(text string) string { - return strings.NewReplacer("’", "'", "‘", "'").Replace(strings.ToLower(text)) -} - func issueCommentTime(comment IssueComment) time.Time { if comment.UpdatedAt.After(comment.CreatedAt) { return comment.UpdatedAt.UTC() @@ -1879,13 +1363,6 @@ func issueCommentTime(comment IssueComment) time.Time { return comment.CreatedAt.UTC() } -func shortOID(oid string) string { - if len(oid) >= 9 { - return oid[:9] - } - return oid -} - func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { diff --git a/internal/crq/service.go b/internal/crq/service.go index 8a9b90f..2a8f97b 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -11,6 +11,8 @@ import ( "strconv" "strings" "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" ) type Logger interface { @@ -36,13 +38,19 @@ type GitHubAPI interface { type Service struct { cfg Config + cr dialect.CodeRabbit gh GitHubAPI store StateStore log Logger } func NewService(cfg Config, gh GitHubAPI, store StateStore, log Logger) *Service { - return &Service{cfg: cfg, gh: gh, store: store, log: log} + cr := dialect.CodeRabbit{ + CompletionMarker: cfg.CompletionMarker, + RateLimitMarker: cfg.RateLimitMarker, + CalibrationMarker: cfg.CalibrationMarker, + } + return &Service{cfg: cfg, cr: cr, gh: gh, store: store, log: log} } type EnqueueResult struct { @@ -1540,126 +1548,9 @@ func randomToken() string { return hex.EncodeToString(buf[:]) } -// isRateLimited reports whether a CodeRabbit comment is a rate-limit notice. It -// matches the configured CRQ_RL_MARKER plus CodeRabbit's current phrasings (the -// "Fair Usage Limits Policy" / "currently rate limited" message), which the old -// "rate limited by coderabbit.ai" marker alone misses — so a fired review that -// comes back rate-limited is detected and crq backs off instead of firing on. -func (s *Service) isRateLimited(body string) bool { - l := strings.ToLower(body) - if m := strings.ToLower(strings.TrimSpace(s.cfg.RateLimitMarker)); m != "" && strings.Contains(l, m) { - return true - } - return strings.Contains(l, "currently rate limited") || - strings.Contains(l, "rate limited under") || - strings.Contains(l, "fair usage limits policy") -} +// The CodeRabbit comment classifiers live in internal/dialect; these +// forwarders keep call sites and tests stable during the refactor. +func (s *Service) isRateLimited(body string) bool { return s.cr.IsRateLimited(body) } -// isReviewsPaused reports whether a CodeRabbit comment is the "Reviews paused" -// auto-pause notice. CodeRabbit posts this when a branch is under active -// development (an influx of new commits) and auto_pause_after_reviewed_commits -// kicks in. It acknowledges the branch but is not a review of the fired head, so -// — like a rate-limit notice — it must not be mistaken for a completed review -// round: doing so would falsely converge a loop with zero findings. crq keeps -// triggering reviews explicitly, and "@coderabbitai review" still produces a -// single review while auto-review is paused, so the round completes on the real -// review, not this note. -func (s *Service) isReviewsPaused(body string) bool { - l := strings.ToLower(body) - return strings.Contains(l, "reviews paused") || - strings.Contains(l, "automatically paused this review") || - strings.Contains(l, "auto_pause_after_reviewed_commits") -} - -// isReviewAlreadyDone identifies CodeRabbit's "does not re-review already -// reviewed commits" acknowledgement. The text is only a claim, not completion -// evidence: inflightStatus requires a matching GitHub review before trusting it. -// The same boilerplate can appear inside a rate-limit notice's help section, so -// a comment that is itself a rate limit is excluded. -func (s *Service) isReviewAlreadyDone(body string) bool { - l := strings.ToLower(body) - if !strings.Contains(l, "does not re-review already reviewed") && - !strings.Contains(l, "already reviewed commit") { - return false - } - return !s.isRateLimited(l) -} - -// parseAvailableIn extracts CodeRabbit's "next review available in " -// window from a rate-limit comment and returns base+duration. It tolerates the -// markdown and punctuation CodeRabbit now wraps the value in — the current -// phrasing is "**Next review available in:** **40 minutes**", where a colon and -// bold markers sit between "in" and the number. An unparseable body returns nil; -// the caller then falls back to a conservative fixed window rather than a short -// retry (getting this wrong is exactly what let the daemon re-fire every couple -// of minutes instead of honouring a 40-minute limit). -func parseAvailableIn(text string, base time.Time) *time.Time { - lower := strings.ToLower(text) - idx := strings.Index(lower, "available in") - if idx < 0 { - return nil - } - frag := lower[idx+len("available in"):] - // Normalise markdown/punctuation to spaces so "in:** **40 minutes**" scans as - // "40 minutes". Do this before splitting into fields so bold/colon can't fuse - // onto the number ("**40") and defeat the numeric parse. - frag = strings.Map(func(r rune) rune { - switch r { - case '*', ':', '`', ',', '_', '(', ')': - return ' ' - } - return r - }, frag) - // Stop at a sentence boundary so a later number in the body isn't read as part - // of the window. - if dot := strings.IndexByte(frag, '.'); dot >= 0 { - frag = frag[:dot] - } - fields := strings.Fields(frag) - var d time.Duration - for i := 0; i+1 < len(fields); i++ { - n, err := strconv.Atoi(fields[i]) - if err != nil { - continue - } - switch unit := fields[i+1]; { - case strings.HasPrefix(unit, "hour"): - d += time.Duration(n) * time.Hour - case strings.HasPrefix(unit, "minute"): - d += time.Duration(n) * time.Minute - case strings.HasPrefix(unit, "second"): - d += time.Duration(n) * time.Second - } - } - if d == 0 { - return nil - } - t := base.Add(d) - return &t -} - -func parseQuota(text string, base time.Time) (*int, *time.Time) { - remaining := parseRemainingReviews(text) - reset := parseAvailableIn(text, base) - return remaining, reset -} - -func parseRemainingReviews(text string) *int { - lower := strings.ToLower(text) - words := strings.FieldsFunc(lower, func(r rune) bool { - return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') - }) - for i := 0; i < len(words); i++ { - n, err := strconv.Atoi(words[i]) - if err != nil { - continue - } - if i+2 < len(words) && strings.HasPrefix(words[i+1], "review") && (words[i+2] == "remaining" || words[i+2] == "left") { - return &n - } - if i > 0 && (words[i-1] == "remaining" || words[i-1] == "left") { - return &n - } - } - return nil -} +func (s *Service) isReviewsPaused(body string) bool { return s.cr.IsReviewsPaused(body) } +func (s *Service) isReviewAlreadyDone(body string) bool { return s.cr.IsReviewAlreadyDone(body) } diff --git a/internal/dialect/coderabbit.go b/internal/dialect/coderabbit.go new file mode 100644 index 0000000..dca3904 --- /dev/null +++ b/internal/dialect/coderabbit.go @@ -0,0 +1,319 @@ +package dialect + +import ( + "regexp" + "strconv" + "strings" + "time" +) + +// CodeRabbit classifies CodeRabbit's comment bodies. The markers come from +// config (CRQ_COMPLETION_MARKER, CRQ_RL_MARKER, CRQ_CAL_REPLY_MARKER); the +// remaining phrasings are CodeRabbit's own current wording, pinned by the +// golden corpus in testdata/coderabbit. +type CodeRabbit struct { + CompletionMarker string + RateLimitMarker string + CalibrationMarker string +} + +// IsCompletionReply reports whether body is the bot's reply to a processed +// review command (CodeRabbit: "Review finished."). An empty marker disables +// the completion-reply convergence fallback entirely. +func (d CodeRabbit) IsCompletionReply(body string) bool { + marker := strings.TrimSpace(d.CompletionMarker) + if marker == "" { + return false + } + return strings.Contains(strings.ToLower(body), strings.ToLower(marker)) +} + +// IsAutoReply reports whether body is one of the bot's auto-generated replies +// to a command — completion, rate-limit, skip, or progress. The bot posts +// exactly one per command, which is what lets completions be paired to the +// command they answer. +func (d CodeRabbit) IsAutoReply(body string) bool { + marker := strings.TrimSpace(d.CalibrationMarker) + if marker == "" { + return false + } + return strings.Contains(strings.ToLower(body), strings.ToLower(marker)) +} + +// IsRateLimited reports whether a CodeRabbit comment is a rate-limit notice. It +// matches the configured CRQ_RL_MARKER plus CodeRabbit's current phrasings (the +// "Fair Usage Limits Policy" / "currently rate limited" message), which the old +// "rate limited by coderabbit.ai" marker alone misses — so a fired review that +// comes back rate-limited is detected and crq backs off instead of firing on. +func (d CodeRabbit) IsRateLimited(body string) bool { + l := strings.ToLower(body) + if m := strings.ToLower(strings.TrimSpace(d.RateLimitMarker)); m != "" && strings.Contains(l, m) { + return true + } + return strings.Contains(l, "currently rate limited") || + strings.Contains(l, "rate limited under") || + strings.Contains(l, "fair usage limits policy") +} + +// IsReviewsPaused reports whether a CodeRabbit comment is the "Reviews paused" +// auto-pause notice. CodeRabbit posts this when a branch is under active +// development (an influx of new commits) and auto_pause_after_reviewed_commits +// kicks in. It acknowledges the branch but is not a review of the fired head, so +// — like a rate-limit notice — it must not be mistaken for a completed review +// round: doing so would falsely converge a loop with zero findings. crq keeps +// triggering reviews explicitly, and "@coderabbitai review" still produces a +// single review while auto-review is paused, so the round completes on the real +// review, not this note. +func (d CodeRabbit) IsReviewsPaused(body string) bool { + l := strings.ToLower(body) + return strings.Contains(l, "reviews paused") || + strings.Contains(l, "automatically paused this review") || + strings.Contains(l, "auto_pause_after_reviewed_commits") +} + +// IsReviewAlreadyDone identifies CodeRabbit's "does not re-review already +// reviewed commits" acknowledgement. The text is only a claim, not completion +// evidence: callers require a matching GitHub review before trusting it. +// The same boilerplate can appear inside a rate-limit notice's help section, so +// a comment that is itself a rate limit is excluded. +func (d CodeRabbit) IsReviewAlreadyDone(body string) bool { + l := strings.ToLower(body) + if !strings.Contains(l, "does not re-review already reviewed") && + !strings.Contains(l, "already reviewed commit") { + return false + } + return !d.IsRateLimited(l) +} + +// 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 +// state, so the reply alone is not a terminal signal. +func (d CodeRabbit) IsReviewInProgress(body string) bool { + lower := strings.ToLower(body) + return strings.Contains(lower, "currently processing new changes in this pr") || + strings.Contains(lower, "review in progress by coderabbit.ai") +} + +// IsReviewFailure reports whether body is CodeRabbit's editable top-summary +// failure state. CodeRabbit can still change the command reply to "Review +// finished" after this summary reports that the review itself failed, so the +// reply is not evidence that the current head was reviewed successfully. +func (d CodeRabbit) IsReviewFailure(body string) bool { + lower := strings.ToLower(body) + return strings.Contains(lower, "auto-generated comment: failure by coderabbit.ai") || + strings.Contains(lower, "## review failed") +} + +// ParseAvailableIn extracts CodeRabbit's "next review available in " +// window from a rate-limit comment and returns base+duration. It tolerates the +// markdown and punctuation CodeRabbit now wraps the value in — the current +// phrasing is "**Next review available in:** **40 minutes**", where a colon and +// bold markers sit between "in" and the number. An unparseable body returns nil; +// the caller then falls back to a conservative fixed window rather than a short +// retry (getting this wrong is exactly what let the daemon re-fire every couple +// of minutes instead of honouring a 40-minute limit). +func ParseAvailableIn(text string, base time.Time) *time.Time { + lower := strings.ToLower(text) + idx := strings.Index(lower, "available in") + if idx < 0 { + return nil + } + frag := lower[idx+len("available in"):] + // Normalise markdown/punctuation to spaces so "in:** **40 minutes**" scans as + // "40 minutes". Do this before splitting into fields so bold/colon can't fuse + // onto the number ("**40") and defeat the numeric parse. + frag = strings.Map(func(r rune) rune { + switch r { + case '*', ':', '`', ',', '_', '(', ')': + return ' ' + } + return r + }, frag) + // Stop at a sentence boundary so a later number in the body isn't read as part + // of the window. + if dot := strings.IndexByte(frag, '.'); dot >= 0 { + frag = frag[:dot] + } + fields := strings.Fields(frag) + var d time.Duration + for i := 0; i+1 < len(fields); i++ { + n, err := strconv.Atoi(fields[i]) + if err != nil { + continue + } + switch unit := fields[i+1]; { + case strings.HasPrefix(unit, "hour"): + d += time.Duration(n) * time.Hour + case strings.HasPrefix(unit, "minute"): + d += time.Duration(n) * time.Minute + case strings.HasPrefix(unit, "second"): + d += time.Duration(n) * time.Second + } + } + if d == 0 { + return nil + } + t := base.Add(d) + return &t +} + +func ParseQuota(text string, base time.Time) (*int, *time.Time) { + remaining := ParseRemainingReviews(text) + reset := ParseAvailableIn(text, base) + return remaining, reset +} + +func ParseRemainingReviews(text string) *int { + lower := strings.ToLower(text) + words := strings.FieldsFunc(lower, func(r rune) bool { + return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') + }) + for i := 0; i < len(words); i++ { + n, err := strconv.Atoi(words[i]) + if err != nil { + continue + } + if i+2 < len(words) && strings.HasPrefix(words[i+1], "review") && (words[i+2] == "remaining" || words[i+2] == "left") { + return &n + } + if i > 0 && (words[i-1] == "remaining" || words[i-1] == "left") { + return &n + } + } + return nil +} + +var ( + detailSummaryRE = regexp.MustCompile(`(?i)\s*([^<]+?)\s+\([0-9]+\)\s*`) + // Line headers come backticked in "Outside diff range comments" (`12-15`:) and + // un-backticked in "Comments failed to post" (12-15:) — accept both. + detailHeaderRE = regexp.MustCompile("^`?([0-9]+)(?:\\s*-\\s*([0-9]+))?`?: *(.*)$") + promptBlockRE = regexp.MustCompile("(?is)[^<]*Prompt for all review comments with AI agents[^<]*.*?```\\s*(.*?)\\s*```") + promptFileRE = regexp.MustCompile("^In (?:`@([^`]+)`|@([^:]+)):$") + promptBulletRE = regexp.MustCompile("^- (?:Around line|Line)\\s+([0-9]+)(?:\\s*-\\s*([0-9]+))?:\\s*(.*)$") +) + +// 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. +func ParseReviewBodyFindings(body string, review ReviewMeta, bot string) []Finding { + body = strings.TrimSpace(body) + if body == "" { + return nil + } + clean := StripMarkdownQuote(body) + out := ParseDetailedReviewFindings(clean, review, bot) + out = append(out, ParsePromptReviewFindings(clean, review, bot)...) + out = append(out, ParseCodexReviewFindings(clean, review, bot)...) + return out +} + +func ParseDetailedReviewFindings(body string, review ReviewMeta, bot string) []Finding { + lines := strings.Split(body, "\n") + var out []Finding + currentPath := "" + for i := 0; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + if match := detailSummaryRE.FindStringSubmatch(line); match != nil { + summary := strings.TrimSpace(match[1]) + if LooksLikePath(summary) { + currentPath = summary + } + continue + } + match := detailHeaderRE.FindStringSubmatch(line) + if match == nil || currentPath == "" { + continue + } + startLine, _ := strconv.Atoi(match[1]) + meta := strings.TrimSpace(match[3]) + if IsNonActionableText(meta) { + continue + } + start := i + 1 + end := len(lines) + for j := start; j < len(lines); j++ { + next := strings.TrimSpace(lines[j]) + if detailHeaderRE.MatchString(next) || detailSummaryRE.MatchString(next) { + end = j + break + } + } + block := strings.TrimSpace(strings.Join(lines[start:end], "\n")) + title := TitleFromDetailedBlock(block) + if title == "" { + title = TitleOf(block) + } + bodyText := CompactReviewBody(block) + finding := Finding{ + Bot: bot, + Severity: SeverityOf(meta + "\n" + block), + Path: strings.TrimPrefix(currentPath, "@"), + Line: startLine, + Title: title, + Body: bodyText, + ReviewID: review.ID, + Commit: ShortOID(review.CommitID), + URL: review.HTMLURL, + Source: "review_body", + CreatedAt: review.SubmittedAt, + } + if IsActionableFinding(finding) { + out = append(out, finding) + } + } + return out +} + +func ParsePromptReviewFindings(body string, review ReviewMeta, bot string) []Finding { + var out []Finding + for _, blockMatch := range promptBlockRE.FindAllStringSubmatch(body, -1) { + block := blockMatch[1] + lines := strings.Split(block, "\n") + currentPath := "" + for i := 0; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + if match := promptFileRE.FindStringSubmatch(line); match != nil { + currentPath = firstNonEmpty(match[1], match[2]) + currentPath = strings.TrimPrefix(currentPath, "@") + continue + } + match := promptBulletRE.FindStringSubmatch(line) + if match == nil || currentPath == "" { + continue + } + startLine, _ := strconv.Atoi(match[1]) + parts := []string{strings.TrimSpace(match[3])} + for j := i + 1; j < len(lines); j++ { + next := strings.TrimSpace(lines[j]) + if next == "" { + continue + } + if strings.HasPrefix(next, "---") || promptFileRE.MatchString(next) || promptBulletRE.MatchString(next) { + break + } + parts = append(parts, next) + i = j + } + bodyText := strings.TrimSpace(strings.Join(parts, " ")) + finding := Finding{ + Bot: bot, + Severity: SeverityOf(bodyText), + Path: currentPath, + Line: startLine, + Title: TitleOf(bodyText), + Body: bodyText, + ReviewID: review.ID, + Commit: ShortOID(review.CommitID), + URL: review.HTMLURL, + Source: "review_prompt", + CreatedAt: review.SubmittedAt, + } + if IsActionableFinding(finding) { + out = append(out, finding) + } + } + } + return out +} diff --git a/internal/dialect/codex.go b/internal/dialect/codex.go new file mode 100644 index 0000000..7aee9f5 --- /dev/null +++ b/internal/dialect/codex.go @@ -0,0 +1,142 @@ +package dialect + +import ( + "net/url" + "regexp" + "strconv" + "strings" +) + +var ( + codexBlobLinkRE = regexp.MustCompile(`(?m)^https://github\.com/[^/\s]+/[^/\s]+/blob/([0-9a-fA-F]{7,64})/(.+?)#L([0-9]+)(?:-L([0-9]+))?\s*$`) + markdownImageRE = regexp.MustCompile(`!\[[^]]*\]\([^)]+\)`) + subTagRE = regexp.MustCompile(`(?i)`) + // codexReviewedCommitRE matches Codex's "**Reviewed commit:** `4d9e8bca82`" + // line in the newer clean-summary format. + codexReviewedCommitRE = regexp.MustCompile("(?i)reviewed commit[:*\\s]*`([0-9a-fA-F]{7,40})`") +) + +func IsCodexBot(login string) bool { + return NormalizeBotName(login) == "chatgpt-codex-connector" +} + +func HasCodexBot(bots []string) bool { + for _, bot := range bots { + if IsCodexBot(bot) { + return true + } + } + return false +} + +func IsCodexNoActionReviewCompletion(text string) bool { + text = NormalizeReviewText(text) + if !strings.Contains(text, "didn't find any major issues") { + return false + } + // Codex has shipped several clean-summary tails: the original + // "Keep them coming!", and the newer ":tada:" flourish with a + // "**Reviewed commit:** `sha`" line. + return strings.Contains(text, "keep them coming") || + strings.Contains(text, ":tada:") || + strings.Contains(text, "🎉") || + CodexReviewedCommitSHA(text) != "" +} + +// CodexReviewedCommitSHA extracts the commit hash Codex says it reviewed, +// or "" when the comment carries no such line. +func CodexReviewedCommitSHA(text string) string { + match := codexReviewedCommitRE.FindStringSubmatch(text) + if len(match) == 2 { + return strings.ToLower(match[1]) + } + return "" +} + +// SHAPrefixMatch reports whether two commit-hash abbreviations refer to the +// same commit: both are prefixes of the full SHA, so the shorter must prefix +// the longer (crq truncates heads to 9 chars; Codex abbreviates to 10). +func SHAPrefixMatch(a, b string) bool { + a, b = strings.ToLower(a), strings.ToLower(b) + if a == "" || b == "" { + return false + } + if len(a) <= len(b) { + return strings.HasPrefix(b, a) + } + return strings.HasPrefix(a, b) +} + +func CleanCodexReviewText(text string) string { + text = markdownImageRE.ReplaceAllString(text, "") + text = subTagRE.ReplaceAllString(text, "") + return strings.TrimSpace(text) +} + +func CodexPrioritySeverity(text string) string { + lower := strings.ToLower(text) + switch { + case strings.Contains(lower, "![p0 badge]"): + return "critical" + case strings.Contains(lower, "![p1 badge]"): + return "major" + case strings.Contains(lower, "![p2 badge]"), strings.Contains(lower, "![p3 badge]"): + return "minor" + default: + return SeverityOf(text) + } +} + +// ParseCodexReviewFindings extracts findings that Codex can only place in the +// review body when the referenced line is outside GitHub's current diff. Codex +// anchors each item with a blob URL followed by a bold priority/title line. +func ParseCodexReviewFindings(body string, review ReviewMeta, bot string) []Finding { + if !IsCodexBot(bot) { + return nil + } + matches := codexBlobLinkRE.FindAllStringSubmatchIndex(body, -1) + out := make([]Finding, 0, len(matches)) + for index, match := range matches { + blockEnd := len(body) + if index+1 < len(matches) { + blockEnd = matches[index+1][0] + } + block := strings.TrimSpace(body[match[1]:blockEnd]) + if details := strings.Index(strings.ToLower(block), "= 0 { + block = strings.TrimSpace(block[:details]) + } + if block == "" { + continue + } + + path, err := url.PathUnescape(body[match[4]:match[5]]) + if err != nil { + path = body[match[4]:match[5]] + } + line, _ := strconv.Atoi(body[match[6]:match[7]]) + title := "" + if titleMatch := boldTitleRE.FindStringSubmatch(block); titleMatch != nil { + title = CleanCodexReviewText(titleMatch[1]) + } + if title == "" { + title = TitleOf(block) + } + finding := Finding{ + Bot: bot, + Severity: CodexPrioritySeverity(block), + Path: path, + Line: line, + Title: title, + Body: CleanCodexReviewText(CompactReviewBody(block)), + ReviewID: review.ID, + Commit: ShortOID(body[match[2]:match[3]]), + URL: review.HTMLURL, + Source: "review_body", + CreatedAt: review.SubmittedAt, + } + if IsActionableFinding(finding) { + out = append(out, finding) + } + } + return out +} diff --git a/internal/dialect/common.go b/internal/dialect/common.go new file mode 100644 index 0000000..3220a14 --- /dev/null +++ b/internal/dialect/common.go @@ -0,0 +1,209 @@ +package dialect + +import ( + "regexp" + "strings" +) + +var ( + boldTitleRE = regexp.MustCompile(`(?m)^\*\*([^*\n]+)\*\*`) + crCommentRE = regexp.MustCompile(``) + htmlCommentRE = regexp.MustCompile(`(?s)`) + rootFileRE = regexp.MustCompile(`^[A-Za-z0-9._+-]+$`) +) + +func SeverityOf(text string) string { + lower := strings.ToLower(text) + switch { + case strings.Contains(lower, "critical"), strings.Contains(lower, "🔴"): + return "critical" + case strings.Contains(lower, "major"), strings.Contains(lower, "high"), strings.Contains(lower, "🟠"): + return "major" + case strings.Contains(lower, "potential issue"), strings.Contains(lower, "medium"), strings.Contains(lower, "🟡"): + return "potential" + case strings.Contains(lower, "nitpick"), strings.Contains(lower, "minor"), strings.Contains(lower, "low"), strings.Contains(lower, "🔵"): + return "minor" + default: + return "unknown" + } +} + +func RankSeverity(sev string) int { + switch sev { + case "critical": + return 5 + case "major": + return 4 + case "potential": + return 3 + case "minor": + return 2 + default: + return 1 + } +} + +func TitleOf(body string) string { + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + line = strings.Trim(line, "#*_` ") + if line != "" && !strings.HasPrefix(line, " 180 { + line = line[:180] + } + return line + } + } + return "Review finding" +} + +func TitleFromDetailedBlock(body string) string { + if match := boldTitleRE.FindStringSubmatch(body); match != nil { + return strings.TrimSpace(match[1]) + } + return "" +} + +func StripMarkdownQuote(body string) string { + lines := strings.Split(body, "\n") + for i, line := range lines { + line = strings.TrimRight(line, " \t") + // CodeRabbit nests review-body sections (outside-diff-range, + // duplicates, nitpicks) several blockquote levels deep — strip + // every leading quote marker, not just the first. + for strings.HasPrefix(line, ">") { + line = strings.TrimPrefix(line, ">") + line = strings.TrimPrefix(line, " ") + } + lines[i] = line + } + return strings.Join(lines, "\n") +} + +func CompactReviewBody(body string) string { + body = crCommentRE.ReplaceAllString(body, "") + body = htmlCommentRE.ReplaceAllString(body, "") + body = strings.ReplaceAll(body, "\r\n", "\n") + lines := strings.Split(body, "\n") + var out []string + skipFence := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") { + skipFence = !skipFence + continue + } + if skipFence || strings.HasPrefix(trimmed, "= 3 && strings.Trim(trimmed, "-_* ") == "" + if trimmed != "" && !isRule { + out = append(out, trimmed) + } + } + return strings.Join(out, "\n") +} + +func LooksLikePath(summary string) bool { + summary = strings.TrimSpace(summary) + if summary == "" || strings.Contains(summary, " ") { + return false + } + if strings.Contains(summary, "/") || strings.Contains(summary, ".") { + return true + } + // Root-level files often have neither a slash nor a dot (Dockerfile, Makefile, + // LICENSE). In a " (N)" detail summary a single filename-safe token is a + // file, so accept it rather than dropping its findings. + return rootFileRE.MatchString(summary) +} + +func IsNonActionableText(text string) bool { + text = NormalizeReviewText(text) + if IsCodexNoActionReviewCompletion(text) { + return true + } + nonActionable := []string{ + "lgtm", + "also applies to:", + "no issue here", + "incorrect or invalid review comment", + "likely an incorrect or invalid review comment", + "version claim", + "both referenced files exist", + "good regression test", + "already fixed", + "now fixed", + "no further action is needed", + "confirm intended ux", + "worth confirming", + "skipped: comment is from another github bot", + "you have reached your codex usage limits for code reviews", + } + for _, phrase := range nonActionable { + if strings.Contains(text, phrase) { + return true + } + } + return false +} + +func IsNoActionReviewCompletion(text string) bool { + text = NormalizeReviewText(text) + return strings.Contains(text, "no actionable comments were generated in the recent review") || + IsCodexNoActionReviewCompletion(text) +} + +func NormalizeReviewText(text string) string { + return strings.NewReplacer("’", "'", "‘", "'").Replace(strings.ToLower(text)) +} + +func ShortOID(oid string) string { + if len(oid) >= 9 { + return oid[:9] + } + return oid +} + +func BotSet(bots []string) map[string]struct{} { + out := map[string]struct{}{} + for _, bot := range bots { + bot = strings.TrimSpace(bot) + if bot != "" { + out[bot] = struct{}{} + } + } + return out +} + +// InBots matches a comment author against the configured bots, tolerating the +// "[bot]" suffix: GitHub's REST API reports "coderabbitai[bot]" but GraphQL +// (review threads) reports "coderabbitai", and the config may use either form. +// Without this, crq missed every review-thread finding and so never surfaced a +// thread_id to resolve. +func InBots(bots map[string]struct{}, login string) bool { + if _, ok := bots[login]; ok { + return true + } + stripped := strings.TrimSuffix(login, "[bot]") + if _, ok := bots[stripped]; ok { + return true + } + _, ok := bots[stripped+"[bot]"] + return ok +} + +func NormalizeBotName(login string) string { + return strings.TrimSuffix(login, "[bot]") +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/dialect/finding.go b/internal/dialect/finding.go new file mode 100644 index 0000000..61db621 --- /dev/null +++ b/internal/dialect/finding.go @@ -0,0 +1,51 @@ +// Package dialect holds every text-format assumption crq makes about review +// bots: CodeRabbit's and Codex's completion phrases, rate-limit notices, +// findings markup, SHA conventions, and severity vocabulary. Each heuristic +// exists because a real bot message broke crq once; testdata/ pins them as a +// golden corpus. The package is pure text → structure: no I/O, no config +// access (markers are injected as struct fields), no GitHub types. +package dialect + +import ( + "strings" + "time" +) + +// Finding is one actionable review item surfaced to agents. The JSON shape is +// part of crq's frozen external contract (llms.txt) — do not change the tags. +type Finding struct { + ID string `json:"id"` + Bot string `json:"bot"` + Severity string `json:"severity"` + Path string `json:"path,omitempty"` + Line int `json:"line,omitempty"` + Title string `json:"title"` + Body string `json:"body"` + ThreadID string `json:"thread_id,omitempty"` + CommentID int64 `json:"comment_id,omitempty"` + ReviewID int64 `json:"review_id,omitempty"` + Commit string `json:"commit,omitempty"` + URL string `json:"url,omitempty"` + Source string `json:"source"` + CreatedAt time.Time `json:"created_at,omitempty"` +} + +// ReviewMeta carries the fields of a submitted review that the body parsers +// attach to extracted findings. It exists so this package needs no GitHub +// wire types. +type ReviewMeta struct { + ID int64 + CommitID string + HTMLURL string + SubmittedAt time.Time +} + +func IsActionableFinding(finding Finding) bool { + title := CompactReviewBody(finding.Title) + body := CompactReviewBody(finding.Body) + if title == "" && body == "" { + return false + } + text := strings.ToLower(title + "\n" + body) + return !IsNonActionableText(text) +} diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go new file mode 100644 index 0000000..0cb5940 --- /dev/null +++ b/internal/dialect/golden_test.go @@ -0,0 +1,181 @@ +package dialect + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// goldenCR mirrors the marker defaults in Config, so the corpus classifies +// exactly as production does. +var goldenCR = CodeRabbit{ + CompletionMarker: "Review finished", + RateLimitMarker: "rate limited by coderabbit.ai", + CalibrationMarker: "auto-generated reply by CodeRabbit", +} + +func readGolden(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", filepath.FromSlash(name))) + if err != nil { + t.Fatalf("read corpus file: %v", err) + } + return string(data) +} + +// TestGoldenClassification pins one corpus file per known bot-message format. +// When a bot ships a new phrasing, add a file and a row — the row IS the spec. +func TestGoldenClassification(t *testing.T) { + cases := []struct { + file string + rateLimited bool + paused bool + inProgress bool + failed bool + alreadyDone bool + completionReply bool + autoReply bool + noAction bool + codexClean bool + nonActionable bool + availableIn time.Duration // 0 = no window must parse + reviewedSHA string + }{ + {file: "coderabbit/rate-limit-fair-usage.md", rateLimited: true, autoReply: true, availableIn: 48 * time.Minute}, + // Contains the "does not re-review" boilerplate in its help section — + // must still classify as a rate limit, NOT as an already-reviewed ack. + {file: "coderabbit/rate-limit-bold-window.md", rateLimited: true, autoReply: true, availableIn: 40 * time.Minute}, + {file: "coderabbit/rate-limit-legacy.md", rateLimited: true, availableIn: 3 * time.Minute}, + {file: "coderabbit/review-in-progress.md", inProgress: true}, + {file: "coderabbit/review-failed.md", failed: true}, + {file: "coderabbit/reviews-paused.md", paused: true}, + {file: "coderabbit/no-actionable-comments.md", noAction: true}, + {file: "coderabbit/already-reviewed.md", alreadyDone: true, autoReply: true}, + {file: "coderabbit/completion-reply.md", completionReply: true, autoReply: true}, + {file: "codex/clean-summary-legacy.md", codexClean: true, noAction: true, nonActionable: true}, + {file: "codex/clean-summary-tada.md", codexClean: true, noAction: true, nonActionable: true, reviewedSHA: "4d9e8bca82"}, + {file: "codex/usage-limit.md", nonActionable: true}, + } + base := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) + for _, tc := range cases { + t.Run(tc.file, func(t *testing.T) { + body := readGolden(t, tc.file) + checks := []struct { + name string + got bool + want bool + }{ + {"IsRateLimited", goldenCR.IsRateLimited(body), tc.rateLimited}, + {"IsReviewsPaused", goldenCR.IsReviewsPaused(body), tc.paused}, + {"IsReviewInProgress", goldenCR.IsReviewInProgress(body), tc.inProgress}, + {"IsReviewFailure", goldenCR.IsReviewFailure(body), tc.failed}, + {"IsReviewAlreadyDone", goldenCR.IsReviewAlreadyDone(body), tc.alreadyDone}, + {"IsCompletionReply", goldenCR.IsCompletionReply(body), tc.completionReply}, + {"IsAutoReply", goldenCR.IsAutoReply(body), tc.autoReply}, + {"IsNoActionReviewCompletion", IsNoActionReviewCompletion(body), tc.noAction}, + {"IsCodexNoActionReviewCompletion", IsCodexNoActionReviewCompletion(body), tc.codexClean}, + {"IsNonActionableText", IsNonActionableText(body), tc.nonActionable}, + } + for _, c := range checks { + if c.got != c.want { + t.Errorf("%s = %v, want %v", c.name, c.got, c.want) + } + } + reset := ParseAvailableIn(body, base) + if tc.availableIn == 0 { + if reset != nil { + t.Errorf("ParseAvailableIn = %v, want none", reset) + } + } else if reset == nil || !reset.Equal(base.Add(tc.availableIn)) { + t.Errorf("ParseAvailableIn = %v, want base+%v", reset, tc.availableIn) + } + if got := CodexReviewedCommitSHA(body); got != tc.reviewedSHA { + t.Errorf("CodexReviewedCommitSHA = %q, want %q", got, tc.reviewedSHA) + } + }) + } +} + +// TestGoldenFindings pins the review-body finding extractors against real +// review-body markup shapes. +func TestGoldenFindings(t *testing.T) { + meta := ReviewMeta{ + ID: 99, + CommitID: "abcdef1234567890", + HTMLURL: "https://example.test/r/99", + SubmittedAt: time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC), + } + type want struct { + path string + line int + severity string // "" = don't check + title string // "" = don't check + source string + commit string // "" = don't check + } + cases := []struct { + file string + bot string + want []want + }{ + { + file: "coderabbit/findings-outside-diff.md", + bot: "coderabbitai[bot]", + want: []want{{path: "internal/foo.go", line: 42, severity: "major", title: "Fix the cancellation path.", source: "review_body"}}, + }, + { + file: "coderabbit/findings-nested-quotes.md", + bot: "coderabbitai[bot]", + want: []want{ + {path: "internal/deep.go", line: 10, severity: "major", title: "Nested finding one.", source: "review_body"}, + {path: "internal/deeper.go", line: 20, severity: "minor", title: "Nested finding two.", source: "review_body"}, + }, + }, + { + file: "coderabbit/findings-failed-to-post.md", + bot: "coderabbitai[bot]", + want: []want{{path: "src-tauri/inject/messenger.js", line: 561, severity: "major", title: "Move the hide-names toggle out of `messenger.js` or update the allowlist first.", source: "review_body"}}, + }, + { + file: "coderabbit/findings-prompt-block.md", + bot: "coderabbitai[bot]", + want: []want{ + {path: "src/app.ts", line: 12, source: "review_prompt"}, + {path: "README.md", line: 7, source: "review_prompt"}, + }, + }, + { + file: "codex/findings-outside-diff.md", + bot: "chatgpt-codex-connector[bot]", + want: []want{{path: "convex/sections/aiCommands.ts", line: 2170, severity: "minor", title: "Query learning history by topic before taking", source: "review_body", commit: "347388ffd"}}, + }, + } + for _, tc := range cases { + t.Run(tc.file, func(t *testing.T) { + body := readGolden(t, tc.file) + got := ParseReviewBodyFindings(body, meta, tc.bot) + if len(got) != len(tc.want) { + t.Fatalf("got %d findings, want %d: %#v", len(got), len(tc.want), got) + } + for i, w := range tc.want { + f := got[i] + if f.Path != w.path || f.Line != w.line { + t.Errorf("finding %d location = %s:%d, want %s:%d", i, f.Path, f.Line, w.path, w.line) + } + if w.severity != "" && f.Severity != w.severity { + t.Errorf("finding %d severity = %q, want %q", i, f.Severity, w.severity) + } + if w.title != "" && f.Title != w.title { + t.Errorf("finding %d title = %q, want %q", i, f.Title, w.title) + } + if f.Source != w.source { + t.Errorf("finding %d source = %q, want %q", i, f.Source, w.source) + } + if w.commit != "" && f.Commit != w.commit { + t.Errorf("finding %d commit = %q, want %q", i, f.Commit, w.commit) + } + } + }) + } +} diff --git a/internal/dialect/testdata/coderabbit/already-reviewed.md b/internal/dialect/testdata/coderabbit/already-reviewed.md new file mode 100644 index 0000000..52bba2f --- /dev/null +++ b/internal/dialect/testdata/coderabbit/already-reviewed.md @@ -0,0 +1,2 @@ + +CodeRabbit does not re-review already reviewed commits. This commit was reviewed in an earlier round; push new changes to trigger a fresh review. diff --git a/internal/dialect/testdata/coderabbit/completion-reply.md b/internal/dialect/testdata/coderabbit/completion-reply.md new file mode 100644 index 0000000..faef016 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/completion-reply.md @@ -0,0 +1,2 @@ + +Review finished. diff --git a/internal/dialect/testdata/coderabbit/findings-failed-to-post.md b/internal/dialect/testdata/coderabbit/findings-failed-to-post.md new file mode 100644 index 0000000..1f31ebe --- /dev/null +++ b/internal/dialect/testdata/coderabbit/findings-failed-to-post.md @@ -0,0 +1,15 @@ +
+🛑 Comments failed to post (1)
+ +
+src-tauri/inject/messenger.js (1)
+ +561-573: _📐 Maintainability & Code Quality_ | _🟠 Major_ | _⚡ Quick win_ + +**Move the hide-names toggle out of `messenger.js` or update the allowlist first.** + +This adds a new injection-layer responsibility outside the documented scope. + +
+ +
diff --git a/internal/dialect/testdata/coderabbit/findings-nested-quotes.md b/internal/dialect/testdata/coderabbit/findings-nested-quotes.md new file mode 100644 index 0000000..25d4260 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/findings-nested-quotes.md @@ -0,0 +1,27 @@ +> [!WARNING] +> Review had issues posting inline. +> +>
+> Outside diff range comments (2)
+> +> >
+> > internal/deep.go (1)
+> > +> > `10-12`: _Functional Correctness_ | _Major_ +> > +> > **Nested finding one.** +> > +> > Body of the first nested finding. +> > +> >
+> >
+> > internal/deeper.go (1)
+> > +> > > `20-21`: _Maintainability_ | _Minor_ +> > > +> > > **Nested finding two.** +> > > +> > > Body of the second, even deeper finding. +> > +> >
+>
diff --git a/internal/dialect/testdata/coderabbit/findings-outside-diff.md b/internal/dialect/testdata/coderabbit/findings-outside-diff.md new file mode 100644 index 0000000..fe3813b --- /dev/null +++ b/internal/dialect/testdata/coderabbit/findings-outside-diff.md @@ -0,0 +1,19 @@ +> [!CAUTION] +> Some comments are outside the diff and can't be posted inline. +> +>
+> Outside diff range comments (1)
+> +>
+> internal/foo.go (1)
+> +> `42-43`: _Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_ +> +> **Fix the cancellation path.** +> +> The operation can continue after cancellation. +> +> +> +>
+>
diff --git a/internal/dialect/testdata/coderabbit/findings-prompt-block.md b/internal/dialect/testdata/coderabbit/findings-prompt-block.md new file mode 100644 index 0000000..bcdcde6 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/findings-prompt-block.md @@ -0,0 +1,16 @@ +
+🤖 Prompt for all review comments with AI agents + +``` +Verify each finding against current code. + +Inline comments: +In `@src/app.ts`: +- Around line 12-14: The parser accepts stale state. Re-read the latest state + before writing so concurrent updates are not lost. + +Outside diff comments: +In @README.md: +- Line 7: Add the missing install warning. +``` +
diff --git a/internal/dialect/testdata/coderabbit/no-actionable-comments.md b/internal/dialect/testdata/coderabbit/no-actionable-comments.md new file mode 100644 index 0000000..d5be9c2 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/no-actionable-comments.md @@ -0,0 +1,3 @@ +**Actionable comments posted: 0** + +No actionable comments were generated in the recent review. diff --git a/internal/dialect/testdata/coderabbit/rate-limit-bold-window.md b/internal/dialect/testdata/coderabbit/rate-limit-bold-window.md new file mode 100644 index 0000000..542994a --- /dev/null +++ b/internal/dialect/testdata/coderabbit/rate-limit-bold-window.md @@ -0,0 +1,8 @@ + + +> [!WARNING] +> You have exceeded the free tier review allowance. You're currently rate limited under our Fair Usage Limits Policy. +> +> **Next review available in:** **40 minutes** + +Please note that CodeRabbit does not re-review already reviewed commits. diff --git a/internal/dialect/testdata/coderabbit/rate-limit-fair-usage.md b/internal/dialect/testdata/coderabbit/rate-limit-fair-usage.md new file mode 100644 index 0000000..4e7c671 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/rate-limit-fair-usage.md @@ -0,0 +1,2 @@ + +You're currently rate limited under our Fair Usage Limits Policy. Your next review will be available in 48 minutes. diff --git a/internal/dialect/testdata/coderabbit/rate-limit-legacy.md b/internal/dialect/testdata/coderabbit/rate-limit-legacy.md new file mode 100644 index 0000000..ca164e2 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/rate-limit-legacy.md @@ -0,0 +1 @@ +You are rate limited by coderabbit.ai. Reviews available in 3 minutes. diff --git a/internal/dialect/testdata/coderabbit/review-failed.md b/internal/dialect/testdata/coderabbit/review-failed.md new file mode 100644 index 0000000..9321a7e --- /dev/null +++ b/internal/dialect/testdata/coderabbit/review-failed.md @@ -0,0 +1,5 @@ + + +## Review failed + +The pull request is closed. diff --git a/internal/dialect/testdata/coderabbit/review-in-progress.md b/internal/dialect/testdata/coderabbit/review-in-progress.md new file mode 100644 index 0000000..7079246 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/review-in-progress.md @@ -0,0 +1,8 @@ + + +Currently processing new changes in this PR. This may take a few minutes, please wait... + +
+Commits +Reviewing files that changed from the base of the PR and between abc1234 and def5678. +
diff --git a/internal/dialect/testdata/coderabbit/reviews-paused.md b/internal/dialect/testdata/coderabbit/reviews-paused.md new file mode 100644 index 0000000..8fd5d7f --- /dev/null +++ b/internal/dialect/testdata/coderabbit/reviews-paused.md @@ -0,0 +1,4 @@ +> [!NOTE] +> Reviews paused + +CodeRabbit has automatically paused this review because the branch is under active development (auto_pause_after_reviewed_commits). Use `@coderabbitai review` to trigger a review once the branch is ready. diff --git a/internal/dialect/testdata/codex/clean-summary-legacy.md b/internal/dialect/testdata/codex/clean-summary-legacy.md new file mode 100644 index 0000000..2e3002e --- /dev/null +++ b/internal/dialect/testdata/codex/clean-summary-legacy.md @@ -0,0 +1 @@ +Codex Review: Didn't find any major issues. Keep them coming! diff --git a/internal/dialect/testdata/codex/clean-summary-tada.md b/internal/dialect/testdata/codex/clean-summary-tada.md new file mode 100644 index 0000000..194f67f --- /dev/null +++ b/internal/dialect/testdata/codex/clean-summary-tada.md @@ -0,0 +1,3 @@ +Codex Review: Didn't find any major issues. :tada: + +**Reviewed commit:** `4d9e8bca82` diff --git a/internal/dialect/testdata/codex/findings-outside-diff.md b/internal/dialect/testdata/codex/findings-outside-diff.md new file mode 100644 index 0000000..2122ac9 --- /dev/null +++ b/internal/dialect/testdata/codex/findings-outside-diff.md @@ -0,0 +1,10 @@ +### 💡 Codex Review + +https://github.com/kristofferR/krisHQ/blob/347388ffda8ae3eb7060a6b960ea437a78780045/convex/sections/aiCommands.ts#L2170-L2174 +**![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat) Query learning history by topic before taking** + +Taking the newest sessions for the whole user before filtering can hide older sessions for the requested topic. + +
ℹ️ About Codex in GitHub +This boilerplate must not become part of the finding. +
diff --git a/internal/dialect/testdata/codex/usage-limit.md b/internal/dialect/testdata/codex/usage-limit.md new file mode 100644 index 0000000..77f090b --- /dev/null +++ b/internal/dialect/testdata/codex/usage-limit.md @@ -0,0 +1 @@ +You have reached your Codex usage limits for code reviews. Limits reset periodically. From 9d1bf738af8a250926834304c6f8041700f68234 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 15:06:33 +0200 Subject: [PATCH 02/20] Extract the GitHub transport into internal/gh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit github.go and its tests move verbatim to a gh package. The GitHub REST quota vocabulary is renamed to Throttle (IsThrottled/ThrottleWait) so it can no longer be confused with CodeRabbit's account-quota rate limits, which stay a dialect concern. The 2500-comment cap check — calibration policy, not transport — hoists into crq, and the User-Agent version is injected at init since gh cannot import crq. crq keeps compiling unchanged through type/func aliases in gh_aliases.go, deleted once the engine extraction rewrites the callers. --- internal/crq/auto.go | 2 +- internal/crq/feedback.go | 2 +- internal/crq/gh_aliases.go | 38 ++++++++++++++++++ internal/crq/service.go | 11 ++++++ internal/{crq => gh}/github.go | 61 ++++++++++++++--------------- internal/{crq => gh}/github_test.go | 2 +- 6 files changed, 81 insertions(+), 35 deletions(-) create mode 100644 internal/crq/gh_aliases.go rename internal/{crq => gh}/github.go (96%) rename internal/{crq => gh}/github_test.go (99%) diff --git a/internal/crq/auto.go b/internal/crq/auto.go index 88be41e..7c3da17 100644 --- a/internal/crq/auto.go +++ b/internal/crq/auto.go @@ -262,7 +262,7 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t // A rate limit must abort the pass so AutoReview's outer backoff kicks // in, instead of scanning the rest of the candidates under the same // throttle (and skipping them until a later poll). - if IsRateLimited(nerr) { + if isThrottled(nerr) { return false, nerr } if s.log != nil { diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index cbf165a..cfc93bf 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -116,7 +116,7 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe } } } - } else if IsRateLimited(err) { + } else if isThrottled(err) { // A transient GraphQL rate limit must not silently degrade to the REST // fallback, which loses thread resolution/outdated state and the cross-commit // unresolved findings this command promises. Surface it so Loop rides it out diff --git a/internal/crq/gh_aliases.go b/internal/crq/gh_aliases.go new file mode 100644 index 0000000..60ef4e2 --- /dev/null +++ b/internal/crq/gh_aliases.go @@ -0,0 +1,38 @@ +package crq + +import ( + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" +) + +// Aliases for the GitHub transport that moved to internal/gh. They keep +// existing call sites and tests stable while the refactor lands package by +// package; new code should import internal/gh directly. Deleted once the +// engine extraction rewrites the callers. (The import is named ghapi because +// crq code conventionally uses gh as a variable name for the client.) + +type ( + GitHub = ghapi.GitHub + APIError = ghapi.APIError + RateLimitError = ghapi.RateLimitError + Issue = ghapi.Issue + Pull = ghapi.Pull + RepoInfo = ghapi.RepoInfo + IssueComment = ghapi.IssueComment + Review = ghapi.Review + ReviewComment = ghapi.ReviewComment + Reaction = ghapi.Reaction + SearchPR = ghapi.SearchPR + gitCommit = ghapi.Commit +) + +var ( + ErrNotFound = ghapi.ErrNotFound + NewGitHub = ghapi.NewGitHub + isThrottled = ghapi.IsThrottled + rateLimitWait = ghapi.ThrottleWait + sleepCtx = ghapi.SleepCtx +) + +func init() { + ghapi.UserAgent = "crq/" + Version +} diff --git a/internal/crq/service.go b/internal/crq/service.go index 2a8f97b..cb239e6 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -1554,3 +1554,14 @@ func (s *Service) isRateLimited(body string) bool { return s.cr.IsRateLimited(bo func (s *Service) isReviewsPaused(body string) bool { return s.cr.IsReviewsPaused(body) } func (s *Service) isReviewAlreadyDone(body string) bool { return s.cr.IsReviewAlreadyDone(body) } + +// isCommentCapError reports whether err is GitHub's hard cap of 2500 comments per +// issue ("Commenting is disabled on issues with more than 2500 comments"). +func isCommentCapError(err error) bool { + var api *APIError + if !errors.As(err, &api) { + return false + } + b := strings.ToLower(api.Body) + return strings.Contains(b, "commenting is disabled") || strings.Contains(b, "more than 2500 comments") +} diff --git a/internal/crq/github.go b/internal/gh/github.go similarity index 96% rename from internal/crq/github.go rename to internal/gh/github.go index 7dd21d1..ed7f290 100644 --- a/internal/crq/github.go +++ b/internal/gh/github.go @@ -1,4 +1,4 @@ -package crq +package gh import ( "bytes" @@ -33,6 +33,10 @@ func (e *APIError) Error() string { return fmt.Sprintf("github %s %s failed: %d %s", e.Method, e.URL, e.Status, strings.TrimSpace(e.Body)) } +type Logger interface { + Printf(string, ...any) +} + type GitHub struct { token string tokenMu sync.Mutex @@ -341,7 +345,7 @@ func (g *GitHub) GraphQL(ctx context.Context, query string, variables map[string g.log.Printf("github graphql rate limit; backing off %s (attempt %d/%d)", wait.Round(time.Second), attempt+1, g.maxRetries) } } - if serr := sleepCtx(ctx, wait); serr != nil { + if serr := SleepCtx(ctx, wait); serr != nil { return serr } continue @@ -355,11 +359,15 @@ func (g *GitHub) GraphQL(ctx context.Context, query string, variables map[string } } +// UserAgent identifies crq to the GitHub API; the crq package stamps its +// version in at init time (gh cannot import crq for it). +var UserAgent = "crq" + func (g *GitHub) decorate(req *http.Request) { req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("X-GitHub-Api-Version", "2022-11-28") req.Header.Set("Authorization", "Bearer "+g.authToken()) - req.Header.Set("User-Agent", "crq/"+Version) + req.Header.Set("User-Agent", UserAgent) } func marshalBody(in any) ([]byte, error) { @@ -391,27 +399,16 @@ func (e *RateLimitError) Error() string { return fmt.Sprintf("github %s rate limit hit (%s %s); resets %s (~%s)", e.Kind, e.Method, shortURL(e.URL), e.Until.UTC().Format(time.RFC3339), wait) } -// IsRateLimited reports whether err is (or wraps) a GitHub rate-limit error. -func IsRateLimited(err error) bool { +// IsThrottled reports whether err is (or wraps) a GitHub rate-limit error. +func IsThrottled(err error) bool { var rl *RateLimitError return errors.As(err, &rl) } -// isCommentCapError reports whether err is GitHub's hard cap of 2500 comments per -// issue ("Commenting is disabled on issues with more than 2500 comments"). -func isCommentCapError(err error) bool { - var api *APIError - if !errors.As(err, &api) { - return false - } - b := strings.ToLower(api.Body) - return strings.Contains(b, "commenting is disabled") || strings.Contains(b, "more than 2500 comments") -} - -// rateLimitWait returns how long to wait before retrying a rate-limited error. +// ThrottleWait returns how long to wait before retrying a rate-limited error. // The bool is true when err is a rate limit; the duration is 0 when GitHub gave // no reset hint (the caller should apply its own default backoff). -func rateLimitWait(err error) (time.Duration, bool) { +func ThrottleWait(err error) (time.Duration, bool) { var rl *RateLimitError if !errors.As(err, &rl) { return 0, false @@ -524,7 +521,7 @@ func (g *GitHub) send(ctx context.Context, method, fullURL string, body []byte) } g.log.Printf("github unreachable on %s %s (%v); retrying in %s (offline %s / cap %s)", method, shortURL(fullURL), err, wait.Round(time.Second), down.Round(time.Second), capStr) } - if serr := sleepCtx(ctx, wait); serr != nil { + if serr := SleepCtx(ctx, wait); serr != nil { return nil, serr } continue @@ -548,7 +545,7 @@ func (g *GitHub) send(ctx context.Context, method, fullURL string, body []byte) if g.log != nil { g.log.Printf("github %s %s: HTTP %d; retrying in %s (attempt %d/%d)", method, shortURL(fullURL), resp.StatusCode, wait.Round(time.Second), attempt, g.maxRetries) } - if serr := sleepCtx(ctx, wait); serr != nil { + if serr := SleepCtx(ctx, wait); serr != nil { return nil, serr } continue @@ -569,7 +566,7 @@ func (g *GitHub) send(ctx context.Context, method, fullURL string, body []byte) if g.log != nil { g.log.Printf("github %s %s: HTTP %d with an HTML body (edge error); retrying in %s (attempt %d/%d)", method, shortURL(fullURL), resp.StatusCode, wait.Round(time.Second), attempt, g.maxRetries) } - if serr := sleepCtx(ctx, wait); serr != nil { + if serr := SleepCtx(ctx, wait); serr != nil { return nil, serr } continue @@ -591,7 +588,7 @@ func (g *GitHub) send(ctx context.Context, method, fullURL string, body []byte) if g.log != nil { g.log.Printf("github %s %s: 401 unauthorized; refreshing token and retrying in %s (attempt %d/%d)", method, shortURL(fullURL), wait.Round(time.Second), attempt, g.maxRetries) } - if serr := sleepCtx(ctx, wait); serr != nil { + if serr := SleepCtx(ctx, wait); serr != nil { return nil, serr } continue @@ -630,7 +627,7 @@ func (g *GitHub) send(ctx context.Context, method, fullURL string, body []byte) g.log.Printf("github %s rate limit on %s %s; backing off %s (attempt %d/%d)", rl.Kind, method, shortURL(fullURL), wait.Round(time.Second), attempt, g.maxRetries) } } - if err := sleepCtx(ctx, wait); err != nil { + if err := SleepCtx(ctx, wait); err != nil { return nil, err } } @@ -741,7 +738,7 @@ func isRetryableNetErr(err error) bool { return false } -func sleepCtx(ctx context.Context, d time.Duration) error { +func SleepCtx(ctx context.Context, d time.Duration) error { select { case <-ctx.Done(): return ctx.Err() @@ -1065,7 +1062,7 @@ type gitRef struct { } `json:"object"` } -type gitCommit struct { +type Commit struct { SHA string `json:"sha"` Tree struct { SHA string `json:"sha"` @@ -1075,7 +1072,7 @@ type gitCommit struct { } `json:"committer"` } -type gitTree struct { +type Tree struct { SHA string `json:"sha"` Tree []struct { Path string `json:"path"` @@ -1127,13 +1124,13 @@ func (g *GitHub) CreateTree(ctx context.Context, repo, baseTree string, entries if baseTree != "" { in["base_tree"] = baseTree } - var out gitTree + var out Tree err := g.request(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/git/trees", repoPath(repo)), in, &out) return out.SHA, err } func (g *GitHub) CreateCommit(ctx context.Context, repo, message, tree string, parents []string) (string, error) { - var out gitCommit + var out Commit err := g.request(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/git/commits", repoPath(repo)), map[string]any{ "message": message, "tree": tree, @@ -1142,14 +1139,14 @@ func (g *GitHub) CreateCommit(ctx context.Context, repo, message, tree string, p return out.SHA, err } -func (g *GitHub) GetCommit(ctx context.Context, repo, sha string) (gitCommit, error) { - var out gitCommit +func (g *GitHub) GetCommit(ctx context.Context, repo, sha string) (Commit, error) { + var out Commit err := g.request(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/git/commits/%s", repoPath(repo), url.PathEscape(sha)), nil, &out) return out, err } -func (g *GitHub) GetTree(ctx context.Context, repo, sha string) (gitTree, error) { - var out gitTree +func (g *GitHub) GetTree(ctx context.Context, repo, sha string) (Tree, error) { + var out Tree err := g.request(ctx, http.MethodGet, fmt.Sprintf("/repos/%s/git/trees/%s?recursive=1", repoPath(repo), url.PathEscape(sha)), nil, &out) return out, err } diff --git a/internal/crq/github_test.go b/internal/gh/github_test.go similarity index 99% rename from internal/crq/github_test.go rename to internal/gh/github_test.go index 7c44a43..b94c6cc 100644 --- a/internal/crq/github_test.go +++ b/internal/gh/github_test.go @@ -1,4 +1,4 @@ -package crq +package gh import ( "context" From f7d742795e0dc454303a1e7bd8e1e4d302939ad8 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 15:15:34 +0200 Subject: [PATCH 03/20] Add the round state machine and pure decision engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/state is schema v3: one Round per PR with owned transitions (queued/reserved/fired/reviewing/awaiting_retry/completed/abandoned), a global FireSlot, and AccountQuota for CodeRabbit's quota. Rounds are never deleted — only transitioned or archived on supersede — so the #448 failure (a requeue deleting the fired marker, re-firing the same head every pass) is structurally unrepresentable, and illegal transitions are errors. internal/engine is the pure decision core both the daemon and crq loop will drive: DecideFire (the single fire owner), Progress (round transitions; ports inflightStatus plus requeueInflight's same-comment rate-limit window logic), and Completion (the ONE round-done answer; ports reviewedByForRound, the completion-reply pairing fold with its prior-review/nonterminal/failed gates, and codexInactiveOrThumbed). Deliberate fixes over v2: the in-progress/failed summary states now gate the daemon path too, and every retry carries a RetryAt cooldown. dialect gains Classifier/BotEvent: issue comments become typed events at the observation boundary, so the engine never touches message text. Old v2 code is untouched and still runs; cutover comes next. --- internal/dialect/event.go | 114 +++++++++ internal/engine/completion.go | 286 +++++++++++++++++++++++ internal/engine/engine.go | 100 ++++++++ internal/engine/engine_test.go | 270 +++++++++++++++++++++ internal/engine/findings.go | 41 ++++ internal/engine/fire.go | 94 ++++++++ internal/engine/progress.go | 160 +++++++++++++ internal/state/state.go | 414 +++++++++++++++++++++++++++++++++ internal/state/state_test.go | 200 ++++++++++++++++ 9 files changed, 1679 insertions(+) create mode 100644 internal/dialect/event.go create mode 100644 internal/engine/completion.go create mode 100644 internal/engine/engine.go create mode 100644 internal/engine/engine_test.go create mode 100644 internal/engine/findings.go create mode 100644 internal/engine/fire.go create mode 100644 internal/engine/progress.go create mode 100644 internal/state/state.go create mode 100644 internal/state/state_test.go diff --git a/internal/dialect/event.go b/internal/dialect/event.go new file mode 100644 index 0000000..cd3c91d --- /dev/null +++ b/internal/dialect/event.go @@ -0,0 +1,114 @@ +package dialect + +import ( + "strings" + "time" +) + +// EventKind is the dominant classification of one issue comment. Priority +// order in Classify encodes load-bearing semantics: a rate-limit notice wins +// 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. +type EventKind int + +const ( + EvOther EventKind = iota + EvCommand // the review 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 + EvInProgress // editable top summary: review still processing + 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 + EvCodexNotice // non-actionable Codex notice (usage limits, acks) +) + +// 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. +type BotEvent struct { + Kind EventKind + Bot string // author login as observed (may carry the [bot] suffix) + 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 // EvCodexClean: reviewed-commit sha, "" if absent +} + +// PairTime is the timestamp used for command↔reply pairing (CreatedAt, with +// UpdatedAt as fallback for API responses that omit it). +func (e BotEvent) PairTime() time.Time { + if !e.CreatedAt.IsZero() { + return e.CreatedAt + } + return e.UpdatedAt +} + +// ObservedTime is the timestamp used for round-window checks. In-place-edited +// comments (top summary, rate-limit notice) belong to the round of their last +// edit, so UpdatedAt wins when it is later. +func (e BotEvent) ObservedTime() time.Time { + if e.UpdatedAt.After(e.CreatedAt) { + return e.UpdatedAt.UTC() + } + return e.CreatedAt.UTC() +} + +// Classifier classifies issue comments into BotEvents. Bot is the configured +// CodeRabbit login; ReviewCommand is the exact trigger comment body. +type Classifier struct { + CodeRabbit CodeRabbit + Bot string + ReviewCommand string +} + +// Classify maps one issue comment to its BotEvent. Unrecognized comments +// (including all human commentary) come back as EvOther. +func (c Classifier) Classify(author, body string, id int64, createdAt, updatedAt time.Time) BotEvent { + ev := BotEvent{Kind: EvOther, Bot: author, CommentID: id, CreatedAt: createdAt, UpdatedAt: updatedAt} + trimmed := strings.TrimSpace(body) + fromConfigured := NormalizeBotName(author) == NormalizeBotName(c.Bot) + + if command := strings.TrimSpace(c.ReviewCommand); command != "" && trimmed == command && !fromConfigured { + ev.Kind = EvCommand + return ev + } + if IsCodexBot(author) { + if IsCodexNoActionReviewCompletion(body) { + ev.Kind = EvCodexClean + ev.SHA = CodexReviewedCommitSHA(body) + } else if IsNonActionableText(body) { + ev.Kind = EvCodexNotice + } + return ev + } + if !fromConfigured { + return ev + } + ev.AutoReply = c.CodeRabbit.IsAutoReply(body) + switch { + case c.CodeRabbit.IsRateLimited(body): + ev.Kind = EvRateLimited + ev.Window = ParseAvailableIn(body, updatedAt) + ev.Remaining = ParseRemainingReviews(body) + case c.CodeRabbit.IsReviewsPaused(body): + ev.Kind = EvPaused + case c.CodeRabbit.IsReviewInProgress(body): + ev.Kind = EvInProgress + case c.CodeRabbit.IsReviewFailure(body): + ev.Kind = EvFailed + case c.CodeRabbit.IsReviewAlreadyDone(body): + ev.Kind = EvAlreadyReviewed + case IsNoActionReviewCompletion(body): + ev.Kind = EvNoAction + case c.CodeRabbit.IsCompletionReply(body): + ev.Kind = EvCompletion + } + return ev +} diff --git a/internal/engine/completion.go b/internal/engine/completion.go new file mode 100644 index 0000000..0ddeff4 --- /dev/null +++ b/internal/engine/completion.go @@ -0,0 +1,286 @@ +package engine + +import ( + "sort" + "strings" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// CompletionStatus is the ONE answer to "is this round done?" — shared by the +// daemon (slot release / round completion) and the loop (convergence). It +// replaces v2's divergent inflightStatus and Feedback implementations. +type CompletionStatus struct { + ReviewedBy map[string]bool + Done bool +} + +// Completion decides which required bots have review evidence for the round's +// head. The rules are exact ports of v2: +// +// 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. +// 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. +// 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 +// sound, and no in-progress/rate-limited/paused/failed top-summary state +// contradicts it (the c22eb4b/e2aa2f0 gates). +func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { + reviewedBy := map[string]bool{} + for _, bot := range p.RequiredBots { + bot = strings.TrimSpace(bot) + if bot != "" { + reviewedBy[bot] = false + } + } + cutoff := time.Time{} + if r.FiredAt != nil { + cutoff = r.FiredAt.UTC() + } + + // 1. Submitted reviews. + for _, review := range obs.Reviews { + if r.Head != "" { + if strings.HasPrefix(review.Commit, r.Head) { + markReviewed(reviewedBy, review.Bot) + } + continue + } + if notBefore(review.SubmittedAt, cutoff) { + markReviewed(reviewedBy, review.Bot) + } + } + + // 2. Codex clean-summary issue comments. + for _, ev := range obs.Events { + if ev.Kind != dialect.EvCodexClean { + continue + } + 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) + } + continue + } + if r.FiredAt != nil && notBefore(ev.ObservedTime(), cutoff) { + markReviewed(reviewedBy, ev.Bot) + } + } + + // 3. CodeRabbit clean-review summary, Codex-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) { + markReviewed(reviewedBy, ev.Bot) + } + } + } + + // 4. Completion-reply fallback. + if needsBotReview(reviewedBy, p.Bot) && r.FiredAt != nil && + completionReplyForRound(obs, p, cutoff) { + markReviewed(reviewedBy, p.Bot) + } + + 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 { + codexActive := dialect.HasCodexBot(p.RequiredBots) + codexReviewed := reviewedByBot(reviewedBy, "chatgpt-codex-connector[bot]") + for _, review := range obs.Reviews { + if !dialect.IsCodexBot(review.Bot) { + continue + } + if r.Head != "" && review.Commit != "" && strings.HasPrefix(review.Commit, r.Head) { + codexActive, codexReviewed = true, true + break + } + if review.Commit == "" && !review.SubmittedAt.IsZero() && notBefore(review.SubmittedAt, cutoff) { + codexActive, codexReviewed = true, true + break + } + } + if codexReviewed { + return true + } + if !codexActive { + for _, ev := range obs.Events { + // Any potentially-actionable Codex comment in this round means Codex + // is participating (its notices — usage limits, acks — do not). + if dialect.IsCodexBot(ev.Bot) && ev.Kind == dialect.EvOther && notBefore(ev.ObservedTime(), cutoff) { + codexActive = true + break + } + if ev.Kind == dialect.EvCodexClean && notBefore(ev.ObservedTime(), cutoff) { + codexActive = true + break + } + } + } + if !codexActive { + return true + } + if obs.CodexThumbsUp { + markReviewed(reviewedBy, "chatgpt-codex-connector[bot]") + return true + } + return false +} + +// completionReplyForRound ports v2's completionReplyForFiredCommand: replies +// pair chronologically with the earliest unanswered command, submitted +// reviews consume the command they answered, and a completion only stands +// when the bot has a prior submitted review and no nonterminal or failed +// top-summary state contradicts it. +func completionReplyForRound(obs Observation, p Policy, firedAt time.Time) bool { + if !botHasAnyReview(obs.Reviews, p.Bot) { + return false + } + for _, reply := range commandReplies(obs, p) { + if reply.completion && notBefore(reply.commandAt, firedAt) && + !stateSince(obs, p, reply.commandAt, dialect.EvInProgress, dialect.EvRateLimited, dialect.EvPaused) && + !stateSince(obs, p, reply.commandAt, dialect.EvFailed) { + return true + } + } + return false +} + +func botHasAnyReview(reviews []ReviewSeen, bot string) bool { + for _, review := range reviews { + if sameBot(review.Bot, bot) { + return true + } + } + return false +} + +// stateSince reports whether the configured bot exposes one of the given +// comment states at/after since. The top summary is edited in place, so +// ObservedTime (UpdatedAt) decides which round the current body belongs to. +func stateSince(obs Observation, p Policy, since time.Time, kinds ...dialect.EventKind) bool { + for _, ev := range obs.Events { + if !sameBot(ev.Bot, p.Bot) || !notBefore(ev.ObservedTime(), since) { + continue + } + for _, kind := range kinds { + if ev.Kind == kind { + return true + } + } + } + return false +} + +type commandReply struct { + commandID int64 + commandAt time.Time + completion bool +} + +// commandReplies folds the classified event stream (plus submitted reviews) +// into command→reply pairs, exactly as v2's reviewCommandReplies did. +func commandReplies(obs Observation, p Policy) []commandReply { + type kind int + const ( + kCommand kind = iota + kAutoReply + kReview + ) + type event struct { + kind kind + at time.Time + id int64 + ev dialect.BotEvent + } + var events []event + for _, ev := range obs.Events { + switch { + case ev.Kind == dialect.EvCommand: + events = append(events, event{kind: kCommand, at: ev.PairTime(), id: ev.CommentID, ev: ev}) + case ev.AutoReply && sameBot(ev.Bot, p.Bot): + events = append(events, event{kind: kAutoReply, at: ev.PairTime(), id: ev.CommentID, ev: ev}) + } + } + for _, review := range obs.Reviews { + if !sameBot(review.Bot, p.Bot) || review.SubmittedAt.IsZero() { + continue + } + events = append(events, event{kind: kReview, at: review.SubmittedAt, id: review.ReviewID}) + } + sort.SliceStable(events, func(i, j int) bool { + if !events[i].at.Equal(events[j].at) { + return events[i].at.Before(events[j].at) + } + if events[i].kind != events[j].kind { + return events[i].kind < events[j].kind + } + return events[i].id < events[j].id + }) + + var out []commandReply + var pending []event + for _, ev := range events { + switch ev.kind { + case kCommand: + pending = append(pending, ev) + case kReview: + if len(pending) > 0 { + pending = pending[1:] + } + case kAutoReply: + if len(pending) == 0 { + continue + } + cmd := pending[0] + pending = pending[1:] + out = append(out, commandReply{ + commandID: cmd.id, + commandAt: cmd.at, + completion: ev.ev.Kind == dialect.EvCompletion, + }) + } + } + return out +} + +// needsBotReview reports whether login gates completion (has a ReviewedBy +// key) and its review hasn't been seen yet. +func needsBotReview(reviewedBy map[string]bool, login string) bool { + norm := dialect.NormalizeBotName(login) + for bot, reviewed := range reviewedBy { + if bot == login || dialect.NormalizeBotName(bot) == norm { + return !reviewed + } + } + return false +} + +func reviewedByBot(reviewedBy map[string]bool, login string) bool { + norm := dialect.NormalizeBotName(login) + for bot, reviewed := range reviewedBy { + if reviewed && (bot == login || dialect.NormalizeBotName(bot) == norm) { + return true + } + } + return false +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..c559b4e --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,100 @@ +// Package engine holds crq's pure decision logic: whether to fire a review +// command (fire.go), how a fired round progresses (progress.go), and when a +// round is complete (completion.go). Every function takes explicit inputs — +// a Round, an Observation, a clock value, a Policy — and performs no I/O, so +// the daemon and `crq loop` share ONE implementation of each decision and +// every rule is table-testable. +package engine + +import ( + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" +) + +// Policy carries the configured knobs the decisions depend on. +type Policy struct { + Bot string // configured CodeRabbit login + RequiredBots []string // bots that gate round completion + + 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 + RetryBackoff time.Duration // cooldown after a non-rate-limit retry (timeout, failure) +} + +func (p Policy) rateLimitFallback() time.Duration { + if p.RateLimitFallback > 0 { + return p.RateLimitFallback + } + return 15 * time.Minute +} + +func (p Policy) retryBackoff() time.Duration { + if p.RetryBackoff > 0 { + return p.RetryBackoff + } + return 5 * time.Minute +} + +// ReviewSeen is one submitted bot review, reduced to what decisions need. +type ReviewSeen struct { + Bot string + ReviewID int64 + Commit string // short OID ("" when GitHub omitted it) + SubmittedAt time.Time +} + +// CommandSeen is an adoptable review-command comment already on the PR. +// observe() applies the adoption cutoffs (requeue time, force-push time, +// already-answered checks) before it reaches the engine. +type CommandSeen struct { + ID int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +// 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 { + Head string // 9-char short head; "" when unreadable + Open bool + Reviews []ReviewSeen + Events []dialect.BotEvent + // Commands are adoptable trigger comments (cutoff-filtered by observe). + Commands []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 +} + +// 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) } + +func sameBot(a, b string) bool { + return dialect.NormalizeBotName(a) == dialect.NormalizeBotName(b) +} + +// markReviewed flips the required-bot key that login matches, tolerating the +// "[bot]" suffix difference between REST and GraphQL logins. +func markReviewed(reviewedBy map[string]bool, login string) { + norm := dialect.NormalizeBotName(login) + for bot := range reviewedBy { + if bot == login || dialect.NormalizeBotName(bot) == norm { + reviewedBy[bot] = true + return + } + } +} + +func allReviewed(reviewedBy map[string]bool) bool { + for _, ok := range reviewedBy { + if !ok { + return false + } + } + return true +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..e80f330 --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,270 @@ +package engine + +import ( + "testing" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +var ( + t0 = time.Date(2026, 7, 16, 14, 0, 0, 0, time.UTC) + policy = Policy{ + Bot: "coderabbitai[bot]", + RequiredBots: []string{"coderabbitai[bot]"}, + MinInterval: 90 * time.Second, + InflightTimeout: 15 * time.Minute, + RateLimitFallback: 15 * time.Minute, + RetryBackoff: 5 * time.Minute, + } +) + +func firedRound(t *testing.T, head string) state.Round { + t.Helper() + s := state.New() + r, err := s.NewRound("owner/repo", 448, head, t0) + if err != nil { + t.Fatal(err) + } + if err := r.Reserve("tok", "host", t0); err != nil { + t.Fatal(err) + } + if err := r.Fire(1001, t0.Add(2*time.Second)); err != nil { + t.Fatal(err) + } + return *r +} + +func rateLimitEvent(id int64, at time.Time, window *time.Time) dialect.BotEvent { + return dialect.BotEvent{ + Kind: dialect.EvRateLimited, Bot: "coderabbitai[bot]", + CommentID: id, CreatedAt: at, UpdatedAt: at, AutoReply: true, Window: window, + } +} + +// TestRateLimitedRoundParksAndHoldsWindow is the #448 scenario at engine +// level: a fired head that comes back rate limited must park with a real +// window, and re-observing the SAME edited comment must not extend it. +func TestRateLimitedRoundParksAndHoldsWindow(t *testing.T) { + r := firedRound(t, "a21da4aeb") + window := t0.Add(40 * time.Minute) + obs := Observation{Head: "a21da4aeb", Open: true, + Events: []dialect.BotEvent{rateLimitEvent(555, t0.Add(10*time.Second), &window)}} + + tr := Progress(r, state.AccountQuota{}, obs, t0.Add(time.Minute), policy) + if tr.Outcome != OutRetry || !tr.RetryAt.Equal(window) { + t.Fatalf("want retry at the parsed window, got %+v", tr) + } + if tr.Blocked == nil || tr.Blocked.CommentID != 555 { + t.Fatalf("must record the rate-limit comment identity, got %+v", tr.Blocked) + } + + // Apply: the round parks. It is not fire-eligible before the window. + if err := r.AwaitRetry(tr.RetryAt, tr.Reason, t0.Add(time.Minute)); err != nil { + t.Fatal(err) + } + if r.FireEligible(window.Add(-time.Second)) { + t.Fatal("round must stay parked inside the block window") + } + + // The daemon re-observes the SAME comment (edited in place, later + // UpdatedAt, later parse base → later window). The standing block wins. + quota := state.AccountQuota{RLCommentID: 555, BlockedUntil: &window} + later := t0.Add(5 * time.Minute) + laterWindow := later.Add(40 * time.Minute) + obs2 := Observation{Head: "a21da4aeb", Open: true, + Events: []dialect.BotEvent{rateLimitEvent(555, later, &laterWindow)}} + r2 := firedRound(t, "a21da4aeb") + tr2 := Progress(r2, quota, obs2, later, policy) + if tr2.Outcome != OutRetry || !tr2.RetryAt.Equal(window) { + t.Fatalf("re-observation must reuse the standing window %v, got %+v", window, tr2) + } +} + +func TestUnparseableRateLimitFallsBackConservatively(t *testing.T) { + r := firedRound(t, "a21da4aeb") + now := t0.Add(time.Minute) + obs := Observation{Head: "a21da4aeb", Open: true, + Events: []dialect.BotEvent{rateLimitEvent(555, t0.Add(10*time.Second), nil)}} + tr := Progress(r, state.AccountQuota{}, obs, now, policy) + if tr.Outcome != OutRetry || !tr.RetryAt.Equal(now.Add(15*time.Minute)) { + t.Fatalf("want the 15m fallback window, got %+v", tr) + } +} + +// TestInstantCompletionReplyDoesNotConverge encodes the 865ef40 fix: a +// "Review finished" ack on the FIRST-ever command (no prior submitted +// review) must not complete the round. +func TestInstantCompletionReplyDoesNotConverge(t *testing.T) { + r := firedRound(t, "abcdef123") + obs := Observation{Head: "abcdef123", Open: true, Events: []dialect.BotEvent{ + {Kind: dialect.EvCommand, Bot: "kristofferR", CommentID: 1001, CreatedAt: t0.Add(2 * time.Second), UpdatedAt: t0.Add(2 * time.Second)}, + {Kind: dialect.EvCompletion, Bot: "coderabbitai[bot]", CommentID: 1002, AutoReply: true, CreatedAt: t0.Add(7 * time.Second), UpdatedAt: t0.Add(7 * time.Second)}, + }} + if got := Completion(r, obs, policy); got.Done { + t.Fatalf("instant ack with no prior review must not converge: %+v", got) + } + // With a prior review on an older commit, the same ack DOES stand in for a + // no-findings re-review. + obs.Reviews = []ReviewSeen{{Bot: "coderabbitai[bot]", ReviewID: 9, Commit: "000011122", SubmittedAt: t0.Add(-time.Hour)}} + if got := Completion(r, obs, policy); !got.Done { + t.Fatalf("re-review completion reply must converge: %+v", got) + } +} + +// TestProcessingSummaryBlocksCompletion encodes the c22eb4b fix, now applied +// on the daemon path too: while the in-place-edited top summary says the +// review is processing, a completion reply must not converge or complete. +func TestProcessingSummaryBlocksCompletion(t *testing.T) { + r := firedRound(t, "abcdef123") + obs := Observation{Head: "abcdef123", Open: true, + Reviews: []ReviewSeen{{Bot: "coderabbitai[bot]", ReviewID: 9, Commit: "000011122", SubmittedAt: t0.Add(-time.Hour)}}, + Events: []dialect.BotEvent{ + {Kind: dialect.EvCommand, Bot: "kristofferR", CommentID: 1001, CreatedAt: t0.Add(2 * time.Second), UpdatedAt: t0.Add(2 * time.Second)}, + {Kind: dialect.EvCompletion, Bot: "coderabbitai[bot]", CommentID: 1002, AutoReply: true, CreatedAt: t0.Add(7 * time.Second), UpdatedAt: t0.Add(7 * time.Second)}, + {Kind: dialect.EvInProgress, Bot: "coderabbitai[bot]", CommentID: 900, CreatedAt: t0.Add(-time.Hour), UpdatedAt: t0.Add(8 * time.Second)}, + }} + if got := Completion(r, obs, policy); got.Done { + t.Fatalf("processing summary must block convergence: %+v", got) + } + tr := Progress(r, state.AccountQuota{}, obs, t0.Add(time.Minute), policy) + if tr.Outcome != OutReviewing { + t.Fatalf("daemon path should release the slot but keep the round open, got %+v", tr) + } +} + +// TestFailedSummaryParksTheRound encodes the e2aa2f0 fix on the daemon path: +// a failed review must not complete the round, and retries after a cooldown. +func TestFailedSummaryParksTheRound(t *testing.T) { + r := firedRound(t, "abcdef123") + now := t0.Add(time.Minute) + obs := Observation{Head: "abcdef123", Open: true, + Reviews: []ReviewSeen{{Bot: "coderabbitai[bot]", ReviewID: 9, Commit: "000011122", SubmittedAt: t0.Add(-time.Hour)}}, + Events: []dialect.BotEvent{ + {Kind: dialect.EvFailed, Bot: "coderabbitai[bot]", CommentID: 900, CreatedAt: t0.Add(-time.Hour), UpdatedAt: t0.Add(9 * time.Second)}, + }} + tr := Progress(r, state.AccountQuota{}, obs, now, policy) + if tr.Outcome != OutRetry || !tr.RetryAt.Equal(now.Add(5*time.Minute)) { + t.Fatalf("failed review must park with backoff, got %+v", tr) + } +} + +func TestReviewAtHeadCompletesRound(t *testing.T) { + r := firedRound(t, "abcdef123") + obs := Observation{Head: "abcdef123", Open: true, + Reviews: []ReviewSeen{{Bot: "coderabbitai[bot]", ReviewID: 9, Commit: "abcdef1234567890", SubmittedAt: t0.Add(3 * time.Minute)}}} + tr := Progress(r, state.AccountQuota{}, obs, t0.Add(4*time.Minute), policy) + if tr.Outcome != OutComplete { + t.Fatalf("review at head must complete, got %+v", tr) + } + // A review of a DIFFERENT commit must not. + obs.Reviews[0].Commit = "999888777" + tr = Progress(r, state.AccountQuota{}, obs, t0.Add(4*time.Minute), policy) + if tr.Outcome == OutComplete { + t.Fatalf("review of another head must not complete, got %+v", tr) + } +} + +func TestInflightTimeoutCarriesCooldown(t *testing.T) { + r := firedRound(t, "abcdef123") + now := t0.Add(16 * time.Minute) + tr := Progress(r, state.AccountQuota{}, Observation{Head: "abcdef123", Open: true}, now, policy) + if tr.Outcome != OutRetry || !tr.RetryAt.Equal(now.Add(5*time.Minute)) { + t.Fatalf("timeout must park with a cooldown (v2 had none — re-fire vector), got %+v", tr) + } +} + +func TestDecideFireGuards(t *testing.T) { + free := Global{SlotFree: true} + now := t0.Add(10 * time.Minute) + + queued := state.Round{Repo: "owner/repo", PR: 448, Head: "abcdef123", Phase: state.PhaseQueued, Seq: 1} + open := Observation{Head: "abcdef123", Open: true} + + if d := DecideFire(free, queued, Observation{Head: "abcdef123", Open: false}, now, policy); d.Verdict != FireDrop { + t.Fatalf("closed PR must drop, got %+v", d) + } + if d := DecideFire(free, queued, Observation{Head: "999888777", Open: true}, now, policy); d.Verdict != FireSupersede { + t.Fatalf("moved head must supersede, got %+v", d) + } + fired := firedRound(t, "abcdef123") + if d := DecideFire(free, fired, open, now, policy); d.Verdict != FireNo { + t.Fatalf("a fired round must never fire again, got %+v", d) + } + if d := DecideFire(Global{SlotFree: false}, queued, open, now, policy); d.Verdict != FireNo { + t.Fatalf("busy slot must block, got %+v", d) + } + blocked := now.Add(10 * time.Minute) + if d := DecideFire(Global{SlotFree: true, BlockedUntil: &blocked}, queued, open, now, policy); d.Verdict != FireNo { + t.Fatalf("account block must block, got %+v", d) + } + last := now.Add(-time.Second) + if d := DecideFire(Global{SlotFree: true, LastFired: &last}, queued, open, now, policy); d.Verdict != FireNo { + t.Fatalf("min interval must block, got %+v", d) + } + reviewed := Observation{Head: "abcdef123", Open: true, + Reviews: []ReviewSeen{{Bot: "coderabbitai", Commit: "abcdef1234567890", SubmittedAt: now}}} + if d := DecideFire(free, queued, reviewed, now, policy); d.Verdict != FireDedupe { + t.Fatalf("already-reviewed head must dedupe, got %+v", d) + } + withCommand := Observation{Head: "abcdef123", Open: true, + Commands: []CommandSeen{{ID: 77, CreatedAt: now.Add(-time.Minute)}}} + if d := DecideFire(free, queued, withCommand, now, policy); d.Verdict != FireAdopt || d.AdoptCommandID != 77 { + t.Fatalf("existing command must be adopted, got %+v", d) + } + if d := DecideFire(free, queued, open, now, policy); d.Verdict != FirePost { + t.Fatalf("clean queued round must post, got %+v", d) + } + + // A parked round becomes fire-eligible only after RetryAt. + parked := firedRound(t, "abcdef123") + retryAt := now.Add(15 * time.Minute) + if err := parked.AwaitRetry(retryAt, "rate limited", now); err != nil { + t.Fatal(err) + } + if d := DecideFire(free, parked, open, retryAt.Add(-time.Second), policy); d.Verdict != FireNo { + t.Fatalf("parked round must not fire before RetryAt, got %+v", d) + } + if d := DecideFire(free, parked, open, retryAt, policy); d.Verdict != FirePost { + t.Fatalf("parked round must fire once RetryAt passes, got %+v", d) + } +} + +// TestCodexGatesCleanSummary ports the codexInactiveOrThumbed rules. +func TestCodexGatesCleanSummary(t *testing.T) { + 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 { + t.Fatalf("codex-inactive clean summary must converge: %+v", got) + } + + // Codex active in the round (a real Codex comment) without its review or + // thumbs-up: the summary must NOT converge. + codexComment := dialect.BotEvent{Kind: dialect.EvOther, Bot: "chatgpt-codex-connector[bot]", 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 { + 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 { + t.Fatalf("codex thumbs-up must unblock: %+v", got) + } + + // A Codex clean summary naming the head counts as Codex's review — and if + // Codex gates the round, flips its ReviewedBy too. + gated := policy + gated.RequiredBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"} + codexClean := dialect.BotEvent{Kind: dialect.EvCodexClean, Bot: "chatgpt-codex-connector[bot]", 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 { + t.Fatalf("codex clean summary at head must complete the gated round: %+v", got) + } +} diff --git a/internal/engine/findings.go b/internal/engine/findings.go new file mode 100644 index 0000000..54279bd --- /dev/null +++ b/internal/engine/findings.go @@ -0,0 +1,41 @@ +package engine + +import ( + "strings" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" +) + +// BlockingFindings identifies feedback that can still be acted on or resolved +// before requesting a review of head. Unresolved threads remain actionable +// across commits, while thread-less review-body/prompt findings from an older +// commit cannot be resolved on GitHub — those are superseded by the next +// current-head review and must not deadlock the loop. +func BlockingFindings(findings []dialect.Finding, head string) []dialect.Finding { + blocking := make([]dialect.Finding, 0, len(findings)) + for _, finding := range findings { + if finding.ThreadID != "" || finding.Commit == "" || head == "" || strings.HasPrefix(finding.Commit, head) { + blocking = append(blocking, finding) + } + } + return blocking +} + +// FindingsOnHead excludes carried review artifacts from older commits — the +// narrower filter for deciding whether visible feedback belongs to the +// current head at all. +func FindingsOnHead(findings []dialect.Finding, head string) []dialect.Finding { + current := make([]dialect.Finding, 0, len(findings)) + for _, finding := range findings { + if finding.Commit == "" || head == "" || strings.HasPrefix(finding.Commit, head) { + current = append(current, finding) + } + } + return current +} + +// Converged reports the loop's terminal condition: no findings and every +// required bot reviewed. +func Converged(findings []dialect.Finding, completion CompletionStatus) bool { + return len(findings) == 0 && completion.Done +} diff --git a/internal/engine/fire.go b/internal/engine/fire.go new file mode 100644 index 0000000..1245b54 --- /dev/null +++ b/internal/engine/fire.go @@ -0,0 +1,94 @@ +package engine + +import ( + "strings" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// FireVerdict is what Pump should do with a fire-eligible round. Nothing +// outside DecideFire may conclude "post the review command" — this is the +// single owner of that decision. +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 + FireSupersede // observed head differs — supersede the round first + FireDrop // PR closed/merged — abandon the round +) + +type FireDecision struct { + Verdict FireVerdict + Reason string + // Adopt fields identify the existing command comment (FireAdopt). + AdoptCommandID int64 + AdoptAt time.Time +} + +// Global is the cross-PR state a fire decision needs. +type Global struct { + SlotFree bool + BlockedUntil *time.Time // CodeRabbit account quota block + LastFired *time.Time // global pacing anchor +} + +// 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 +// or post. +func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Policy) FireDecision { + if !obs.Open { + return FireDecision{Verdict: FireDrop, Reason: "pr closed"} + } + if obs.Head == "" { + return FireDecision{Verdict: FireNo, Reason: "could not read head"} + } + if r.Head != obs.Head { + return FireDecision{Verdict: FireSupersede, Reason: "head moved to " + obs.Head} + } + if !r.FireEligible(now) { + reason := "round is " + string(r.Phase) + if r.Phase == state.PhaseAwaitingRetry && r.RetryAt != nil { + reason = "cooling down until " + r.RetryAt.UTC().Format(time.RFC3339) + } + return FireDecision{Verdict: FireNo, Reason: reason} + } + if !g.SlotFree { + return FireDecision{Verdict: FireNo, Reason: "fire slot busy"} + } + if g.BlockedUntil != nil && g.BlockedUntil.After(now) { + return FireDecision{Verdict: FireNo, Reason: "account blocked until " + g.BlockedUntil.UTC().Format(time.RFC3339)} + } + if g.LastFired != nil && now.Sub(*g.LastFired) < p.MinInterval { + return FireDecision{Verdict: FireNo, Reason: "min interval"} + } + // 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). + for _, review := range obs.Reviews { + if sameBot(review.Bot, p.Bot) && review.Commit != "" && strings.HasPrefix(review.Commit, obs.Head) { + return FireDecision{Verdict: FireDedupe, Reason: "bot already reviewed head"} + } + } + // 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 { + at := newest.CreatedAt + if at.IsZero() { + at = newest.UpdatedAt + } + return FireDecision{Verdict: FireAdopt, Reason: "review command already posted", AdoptCommandID: newest.ID, AdoptAt: at} + } + return FireDecision{Verdict: FirePost} +} diff --git a/internal/engine/progress.go b/internal/engine/progress.go new file mode 100644 index 0000000..bd0d393 --- /dev/null +++ b/internal/engine/progress.go @@ -0,0 +1,160 @@ +package engine + +import ( + "strings" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// Outcome is how a fired/reviewing round moves. It replaces v2's +// inflightStatus (slot release) AND the wait sweep — one implementation. +type Outcome int + +const ( + KeepWaiting Outcome = iota + OutComplete // every required bot has evidence → completed + OutReviewing // bot acknowledged; release the slot, keep the round open + OutRetry // park until Transition.RetryAt (rate limit, timeout, failure) + OutReleaseSlot // reserved but never posted → back to queued + OutAbandon // PR closed/merged +) + +// AccountBlock is an account-quota update observed from a rate-limit event. +type AccountBlock struct { + Until time.Time + CommentID int64 + CommentUpdated time.Time +} + +type Transition struct { + Outcome Outcome + Reason string + RetryAt time.Time // OutRetry: earliest re-fire for this head + Blocked *AccountBlock // rate limit: account-wide block to record +} + +// reserveTimeout mirrors v2: a reservation that never posted its command +// releases after 2 minutes. +const reserveTimeout = 2 * time.Minute + +// Progress decides what happened to a reserved/fired/reviewing round. Ports +// v2's inflightStatus order — submitted review → rate limit → reaction → +// other bot comment → timeout — with two deliberate fixes: the in-progress +// and failed top-summary states now gate the daemon path too (v2 applied +// them only in feedback.go), and every retry carries a RetryAt cooldown +// (v2's timeout requeue carried none — the second #448 re-fire vector). +func Progress(r state.Round, q state.AccountQuota, obs Observation, now time.Time, p Policy) Transition { + if !obs.Open { + return Transition{Outcome: OutAbandon, Reason: "pr closed"} + } + if r.Phase == state.PhaseReserved { + if r.ReservedAt != nil && now.Sub(*r.ReservedAt) > reserveTimeout { + return Transition{Outcome: OutReleaseSlot, Reason: "reserved review was never posted"} + } + return Transition{Outcome: KeepWaiting, Reason: "reserving"} + } + if r.FiredAt == nil { + return Transition{Outcome: KeepWaiting, Reason: "no fire recorded"} + } + firedAt := r.FiredAt.UTC() + completion := Completion(r, obs, p) + + // An "already reviewed" ack is only trusted alongside real review + // evidence; a review matching the round completes or hands off the wait. + for _, review := range obs.Reviews { + if !sameBot(review.Bot, p.Bot) || !reviewMatchesRound(review, r.Head, firedAt) { + continue + } + if completion.Done { + return Transition{Outcome: OutComplete, Reason: "review submitted"} + } + return Transition{Outcome: OutReviewing, Reason: "review submitted; awaiting remaining bots"} + } + + // Rate limit beats every ack: the fired command did not produce a review. + for _, ev := range obs.Events { + if ev.Kind != dialect.EvRateLimited || !sameBot(ev.Bot, p.Bot) || ev.UpdatedAt.Before(firedAt) { + continue + } + until := resolveBlockWindow(ev, q, now, p) + return Transition{ + Outcome: OutRetry, + Reason: "rate limited", + RetryAt: until, + Blocked: &AccountBlock{Until: until, CommentID: ev.CommentID, CommentUpdated: ev.UpdatedAt}, + } + } + + // The failed top-summary state: the review itself failed. v2's daemon path + // treated this as a normal bot comment and released the slot with the wait + // still pending; parking with a bounded cooldown retries it instead. + if r.Phase == state.PhaseFired && stateSince(obs, p, firedAt, dialect.EvFailed) { + return Transition{Outcome: OutRetry, Reason: "review failed", RetryAt: now.Add(p.retryBackoff())} + } + + if completion.Done { + return Transition{Outcome: OutComplete, Reason: "feedback complete"} + } + + if r.Phase == state.PhaseFired { + // A bare reaction acknowledges the command; the review is still running. + if obs.Reacted { + return Transition{Outcome: OutReviewing, Reason: "bot reacted"} + } + // Any other bot comment in the round window acknowledges it too — but a + // rate-limit/paused/already-reviewed notice is not an ack (v2), and + // neither is the in-progress summary of a PREVIOUS round edit... which + // it cannot be: UpdatedAt gates the window. An in-progress summary IS + // an ack that reviewing started. + for _, ev := range obs.Events { + if !sameBot(ev.Bot, p.Bot) || ev.CommentID == r.CommandID || ev.UpdatedAt.Before(firedAt) { + continue + } + switch ev.Kind { + case dialect.EvRateLimited, dialect.EvPaused, dialect.EvAlreadyReviewed: + continue + } + return Transition{Outcome: OutReviewing, Reason: "bot responded"} + } + if now.Sub(firedAt) > p.InflightTimeout { + return Transition{Outcome: OutRetry, Reason: "in-flight timeout", RetryAt: now.Add(p.retryBackoff())} + } + return Transition{Outcome: KeepWaiting, Reason: "review in flight"} + } + + // Reviewing: the slot is long released; the wait deadline bounds the round. + if r.WaitDeadline != nil && now.After(*r.WaitDeadline) { + return Transition{Outcome: OutRetry, Reason: "wait deadline expired", RetryAt: now} + } + return Transition{Outcome: KeepWaiting, Reason: "reviewing"} +} + +// resolveBlockWindow ports v2's requeueInflight window logic: reuse the +// standing block when the SAME edited rate-limit comment is re-observed +// (CodeRabbit edits one comment in place — a re-observation must not extend +// the window on every bounce), and fall back to a conservative fixed window +// when no "available in" duration parsed. +func resolveBlockWindow(ev dialect.BotEvent, q state.AccountQuota, now time.Time, p Policy) time.Time { + until := ev.Window + sameComment := ev.CommentID != 0 && ev.CommentID == q.RLCommentID + if sameComment && q.BlockedUntil != nil && q.BlockedUntil.After(now) { + until = q.BlockedUntil + } + if until == nil || !until.After(now) { + t := now.Add(p.rateLimitFallback()) + return t + } + return until.UTC() +} + +// reviewMatchesRound mirrors v2: a known head must match the review commit; +// submission time alone could otherwise let a delayed review of an older +// head complete the new one. +func reviewMatchesRound(review ReviewSeen, head string, firedAt time.Time) bool { + if head != "" { + return strings.HasPrefix(review.Commit, head) + } + return notBefore(review.SubmittedAt, firedAt) +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..fb891c2 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,414 @@ +// Package state defines crq's persisted schema v3: one Round per tracked PR, +// a single global fire slot, and the CodeRabbit account quota. A Round is +// never deleted, only transitioned (or archived when superseded by a new +// head) — the invariant that makes "forgot we already requested a review at +// this head" unrepresentable. That amnesia — a rate-limited requeue deleting +// the fired marker — is what let the daemon spam `@coderabbitai review` 19 +// times on one PR in a day. +package state + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// Phase is a Round's position in its lifecycle. Legal transitions are owned +// by the methods on Round; everything else must go through them. +// +// queued → reserved → fired → reviewing → completed +// ↑ │ │ │ +// └─────────┘ ├─────────┴→ awaiting_retry (→ fire-eligible again) +// (post failed) └→ completed (review lands while slot held) +// any → abandoned (PR closed, cancelled, or superseded by a new head) +type Phase string + +const ( + PhaseQueued Phase = "queued" // waiting for a fire slot + PhaseReserved Phase = "reserved" // slot held, command not yet posted + PhaseFired Phase = "fired" // command posted (or adopted), review pending + PhaseReviewing Phase = "reviewing" // bot acknowledged; slot released, review runs + PhaseAwaitingRetry Phase = "awaiting_retry" // throttled or timed out; may re-fire at RetryAt + PhaseCompleted Phase = "completed" // every required bot reviewed this head + PhaseAbandoned Phase = "abandoned" // closed, cancelled, or superseded +) + +// Round is one review cycle for a repo#pr at a specific head. RetryAt is the +// per-head cooldown that survives every transition: an awaiting_retry round +// refuses to fire again before it, no matter how many daemon passes observe +// "no bot review at head" in the meantime. +type Round struct { + Repo string `json:"repo"` + PR int `json:"pr"` + Head string `json:"head"` // 9-char short SHA + Seq int64 `json:"seq"` + Phase Phase `json:"phase"` + Attempts int `json:"attempts,omitempty"` // fire attempts for this head + + EnqueuedAt time.Time `json:"enqueued_at"` + ReservedAt *time.Time `json:"reserved_at,omitempty"` + FiredAt *time.Time `json:"fired_at,omitempty"` + + // CommandID is the review-command comment that fired this round (posted or + // adopted). It anchors completion-reply pairing to this round. + CommandID int64 `json:"command_id,omitempty"` + + // RetryAt is the earliest time this head may fire again (awaiting_retry). + RetryAt *time.Time `json:"retry_at,omitempty"` + + // LastAttemptAt is the adoption cutoff: command comments older than the + // most recent failed/abandoned attempt must not be adopted as this round's + // fire. + LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` + + // WaitDeadline bounds how long a fired/reviewing round is waited on before + // the round is retried or surfaced as timed out. + WaitDeadline *time.Time `json:"wait_deadline,omitempty"` + + Token string `json:"token,omitempty"` // reservation token (CAS race detection) + ByHost string `json:"by_host,omitempty"` + Note string `json:"note,omitempty"` // human-readable reason for the last transition +} + +// 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 { + Key string `json:"key"` // repo#pr holding the slot + Token string `json:"token"` + Since time.Time `json:"since"` +} + +// AccountQuota is the CodeRabbit account-wide review quota (NOT the GitHub +// REST quota — that is internal/gh's Throttle). Set only from classified +// CodeRabbit comments. +type AccountQuota struct { + Scope string `json:"scope,omitempty"` + BlockedUntil *time.Time `json:"blocked_until,omitempty"` + Remaining *int `json:"remaining,omitempty"` + Source string `json:"source,omitempty"` + CheckedAt *time.Time `json:"checked_at,omitempty"` + CalibAskedAt *time.Time `json:"calib_asked_at,omitempty"` + // RLCommentID/RLCommentUpdated identify the rate-limit comment whose "next + // review available in" window produced the current block. CodeRabbit edits a + // single rate-limit comment in place instead of posting a new one, so its + // UpdatedAt advances past every later fire; tracking it lets a re-observed + // edit reuse the standing block instead of being counted as a fresh event + // that extends the window on every bounce. + RLCommentID int64 `json:"rl_comment_id,omitempty"` + RLCommentUpdated *time.Time `json:"rl_comment_updated,omitempty"` +} + +type LeaderLease struct { + Owner string `json:"owner"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// State is schema v3. It persists as state.json in the git state ref exactly +// like v2; only the payload shape changed (no migration — v2 payloads +// auto-reinit, crq is pre-release). +type State struct { + Version int `json:"v"` // 3 + Rev int64 `json:"rev"` + NextSeq int64 `json:"next_seq"` + + Rounds map[string]Round `json:"rounds"` + FireSlot *FireSlot `json:"fire_slot,omitempty"` + LastFired *time.Time `json:"last_fired,omitempty"` + Account AccountQuota `json:"account"` + Leader *LeaderLease `json:"leader,omitempty"` + + // CalibrationIssue overrides the configured calibration PR/issue when the + // original hit GitHub's hard 2500-comment cap and crq rotated to a fresh + // one. Persisted in the shared state so the whole fleet uses the new issue. + CalibrationIssue int `json:"calibration_issue,omitempty"` + + // Archive keeps recently finished rounds (superseded, closed, cancelled) + // for the dashboard and debugging. Bounded by ArchiveMax. + Archive []Round `json:"archive,omitempty"` + + Warn string `json:"warn,omitempty"` + UpdatedAt *time.Time `json:"wrote_at,omitempty"` + DashboardSHA string `json:"dashboard_sha,omitempty"` +} + +const SchemaVersion = 3 + +// ArchiveMax bounds the finished-rounds ring. Active rounds are never +// evicted — only Archive is trimmed — so a live "already fired at this head" +// marker cannot be lost to an eviction cap. +const ArchiveMax = 50 + +func Key(repo string, pr int) string { + return fmt.Sprintf("%s#%d", strings.ToLower(repo), pr) +} + +func New() State { + return State{Version: SchemaVersion, Rounds: map[string]Round{}} +} + +// --- Round transitions ----------------------------------------------------- + +type TransitionError struct { + From, To Phase +} + +func (e *TransitionError) Error() string { + return fmt.Sprintf("illegal round transition %s → %s", e.From, e.To) +} + +func (r *Round) illegal(to Phase) error { return &TransitionError{From: r.Phase, To: to} } + +// Reserve takes the fire slot for this round: queued (or retry-eligible +// awaiting_retry) → reserved. +func (r *Round) Reserve(token, host string, now time.Time) error { + if r.Phase != PhaseQueued && !r.retryEligible(now) { + return r.illegal(PhaseReserved) + } + r.Phase = PhaseReserved + r.Token = token + r.ByHost = host + t := now.UTC() + r.ReservedAt = &t + r.Note = "" + return nil +} + +// Fire records the posted (or adopted) review command: reserved → fired. +// Adoption of an already-posted command fires straight from queued. +func (r *Round) Fire(commandID int64, at time.Time) error { + if r.Phase != PhaseReserved && r.Phase != PhaseQueued { + return r.illegal(PhaseFired) + } + r.Phase = PhaseFired + r.CommandID = commandID + t := at.UTC() + r.FiredAt = &t + r.Attempts++ + r.Note = "" + return nil +} + +// ReleaseToQueue returns a reservation that never posted: reserved → queued. +// The attempt still counts and LastAttemptAt moves, so a stale command comment +// from before the failure cannot be adopted later. +func (r *Round) ReleaseToQueue(reason string, now time.Time) error { + if r.Phase != PhaseReserved { + return r.illegal(PhaseQueued) + } + r.Phase = PhaseQueued + r.Token = "" + r.ReservedAt = nil + r.Attempts++ + t := now.UTC() + r.LastAttemptAt = &t + r.Note = reason + return nil +} + +// Acknowledge records that the bot has seen the fired command (reaction, +// in-progress summary, or other non-terminal reply): fired → reviewing. The +// fire slot may be released; the round itself stays open until Complete. +func (r *Round) Acknowledge() error { + if r.Phase == PhaseReviewing { + return nil // idempotent: acks arrive repeatedly while a review runs + } + if r.Phase != PhaseFired { + return r.illegal(PhaseReviewing) + } + r.Phase = PhaseReviewing + r.Note = "" + return nil +} + +// AwaitRetry parks the round until retryAt: fired|reviewing|reserved → +// awaiting_retry. This REPLACES the v2 "delete the fired marker and requeue" +// path — the round keeps its head, attempts, and fire history, so the next +// daemon pass sees "already requested, waiting" instead of "never fired". +func (r *Round) AwaitRetry(retryAt time.Time, reason string, now time.Time) error { + switch r.Phase { + case PhaseFired, PhaseReviewing, PhaseReserved: + default: + return r.illegal(PhaseAwaitingRetry) + } + r.Phase = PhaseAwaitingRetry + t := retryAt.UTC() + r.RetryAt = &t + n := now.UTC() + r.LastAttemptAt = &n + r.Token = "" + r.ReservedAt = nil + r.Note = reason + return nil +} + +// Complete finishes the round: fired|reviewing → completed. A completed round +// stays in Rounds (it IS the "this head was reviewed" dedup marker) until a +// new head supersedes it or the PR closes. +func (r *Round) Complete() error { + if r.Phase != PhaseFired && r.Phase != PhaseReviewing { + return r.illegal(PhaseCompleted) + } + r.Phase = PhaseCompleted + r.Note = "" + return nil +} + +// Abandon ends the round from any phase (PR closed/merged, cancelled, or +// superseded by a new head). The caller archives it via State.EndRound. +func (r *Round) Abandon(reason string) { + r.Phase = PhaseAbandoned + r.Token = "" + r.Note = reason +} + +func (r *Round) retryEligible(now time.Time) bool { + return r.Phase == PhaseAwaitingRetry && r.RetryAt != nil && !now.Before(*r.RetryAt) +} + +// FireEligible reports whether Pump may consider this round for firing now. +func (r *Round) FireEligible(now time.Time) bool { + return r.Phase == PhaseQueued || r.retryEligible(now) +} + +// Active reports whether the round still occupies its PR slot (i.e. is not +// finished). Completed rounds are NOT active but still occupy Rounds as the +// reviewed-head marker. +func (r *Round) Active() bool { + switch r.Phase { + case PhaseQueued, PhaseReserved, PhaseFired, PhaseReviewing, PhaseAwaitingRetry: + return true + } + return false +} + +// --- State operations ------------------------------------------------------ + +// Round returns the current round for repo#pr, or nil. +func (s *State) Round(repo string, pr int) *Round { + if s.Rounds == nil { + return nil + } + r, ok := s.Rounds[Key(repo, pr)] + if !ok { + return nil + } + return &r +} + +// PutRound stores r as the current round for its PR. +func (s *State) PutRound(r Round) { + if s.Rounds == nil { + s.Rounds = map[string]Round{} + } + s.Rounds[Key(r.Repo, r.PR)] = r +} + +// NewRound begins a round for a head with no current round. It refuses to +// clobber an existing round — supersede via EndRound first — so "two rounds +// for one PR" cannot happen by accident. +func (s *State) NewRound(repo string, pr int, head string, now time.Time) (*Round, error) { + key := Key(repo, pr) + if s.Rounds == nil { + s.Rounds = map[string]Round{} + } + if cur, ok := s.Rounds[key]; ok { + return nil, fmt.Errorf("round already exists for %s@%s (%s)", key, cur.Head, cur.Phase) + } + s.NextSeq++ + r := Round{ + Repo: strings.ToLower(repo), + PR: pr, + Head: head, + Seq: s.NextSeq, + Phase: PhaseQueued, + EnqueuedAt: now.UTC(), + } + s.Rounds[key] = r + return &r, nil +} + +// EndRound abandons the current round (superseded/closed/cancelled) and moves +// it to the archive. The PR has no round afterwards. +func (s *State) EndRound(repo string, pr int, reason string) { + key := Key(repo, pr) + r, ok := s.Rounds[key] + if !ok { + return + } + r.Abandon(reason) + delete(s.Rounds, key) + s.Archive = append(s.Archive, r) + if len(s.Archive) > ArchiveMax { + s.Archive = s.Archive[len(s.Archive)-ArchiveMax:] + } +} + +// Supersede replaces the round for repo#pr with a fresh queued round at the +// new head, archiving the old one. It is the ONLY way a round's head changes. +func (s *State) Supersede(repo string, pr int, head string, now time.Time) (*Round, error) { + s.EndRound(repo, pr, "superseded by "+head) + return s.NewRound(repo, pr, head, now) +} + +// SlotRound returns the round currently holding the fire slot, or nil. A slot +// whose round vanished or moved on is stale and is reported as nil (the +// caller clears it). +func (s *State) SlotRound() *Round { + if s.FireSlot == nil { + return nil + } + r, ok := s.Rounds[s.FireSlot.Key] + if !ok || (r.Phase != PhaseReserved && r.Phase != PhaseFired) || r.Token != s.FireSlot.Token { + return nil + } + return &r +} + +// NextEligible returns the fire-eligible round with the lowest Seq, or nil. +func (s *State) NextEligible(now time.Time) *Round { + var best *Round + for key := range s.Rounds { + r := s.Rounds[key] + if !r.FireEligible(now) { + continue + } + if best == nil || r.Seq < best.Seq { + c := r + best = &c + } + } + return best +} + +// QueuedRounds returns every fire-eligible round ordered by Seq (dashboard). +func (s *State) QueuedRounds(now time.Time) []Round { + var out []Round + for _, r := range s.Rounds { + if r.FireEligible(now) { + out = append(out, r) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Seq < out[j].Seq }) + return out +} + +// Normalize repairs invariants after load: map init, expired retry windows +// (awaiting_retry with a passed RetryAt is simply fire-eligible; nothing to +// do), and a FireSlot pointing at a round that no longer holds it. +func (s *State) Normalize(now time.Time) { + if s.Rounds == nil { + s.Rounds = map[string]Round{} + } + if s.Version == 0 { + s.Version = SchemaVersion + } + if s.FireSlot != nil && s.SlotRound() == nil { + s.FireSlot = nil + } + 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 new file mode 100644 index 0000000..ca12b81 --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,200 @@ +package state + +import ( + "errors" + "testing" + "time" +) + +var t0 = time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) + +func newFired(t *testing.T, s *State) Round { + t.Helper() + r, err := s.NewRound("owner/repo", 7, "abcdef123", t0) + if err != nil { + t.Fatal(err) + } + if err := r.Reserve("tok", "host", t0); err != nil { + t.Fatal(err) + } + if err := r.Fire(101, t0.Add(time.Second)); err != nil { + t.Fatal(err) + } + s.PutRound(*r) + return *r +} + +func TestHappyPathTransitions(t *testing.T) { + s := New() + r := newFired(t, &s) + if r.Phase != PhaseFired || r.Attempts != 1 || r.CommandID != 101 { + t.Fatalf("after fire: %+v", r) + } + if err := r.Acknowledge(); err != nil { + t.Fatal(err) + } + if err := r.Acknowledge(); err != nil { + t.Fatalf("acknowledge must be idempotent: %v", err) + } + if err := r.Complete(); err != nil { + t.Fatal(err) + } + if r.Phase != PhaseCompleted { + t.Fatalf("phase = %s", r.Phase) + } +} + +// TestFiredHeadCannotRefire encodes the #448 invariant: once a head has +// fired, no transition path leads back to another Fire without an explicit +// retry window or a new head. +func TestFiredHeadCannotRefire(t *testing.T) { + s := New() + r := newFired(t, &s) + + var te *TransitionError + if err := r.Fire(102, t0.Add(time.Minute)); !errors.As(err, &te) { + t.Fatalf("double fire must be illegal, got %v", err) + } + if err := r.Reserve("tok2", "host", t0.Add(time.Minute)); !errors.As(err, &te) { + t.Fatalf("re-reserve of a fired round must be illegal, got %v", err) + } + if r.FireEligible(t0.Add(time.Hour)) { + t.Fatal("a fired round is never fire-eligible") + } + + // The rate-limited path parks the round; it stays ineligible until the + // window passes, and its history survives. + retryAt := t0.Add(15 * time.Minute) + if err := r.AwaitRetry(retryAt, "account rate limited", t0.Add(2*time.Second)); err != nil { + t.Fatal(err) + } + if r.FireEligible(retryAt.Add(-time.Second)) { + t.Fatal("must not be eligible before RetryAt") + } + if !r.FireEligible(retryAt) { + t.Fatal("must be eligible once RetryAt passes") + } + if r.Attempts != 1 || r.CommandID != 101 || r.Head != "abcdef123" { + t.Fatalf("retry must keep fire history: %+v", r) + } + // Re-reserving for the retry keeps counting attempts. + if err := r.Reserve("tok3", "host", retryAt); err != nil { + t.Fatal(err) + } + if err := r.Fire(103, retryAt.Add(time.Second)); err != nil { + t.Fatal(err) + } + if r.Attempts != 2 { + t.Fatalf("attempts = %d, want 2", r.Attempts) + } +} + +func TestIllegalCompletions(t *testing.T) { + s := New() + r, err := s.NewRound("owner/repo", 8, "cafebabe1", t0) + if err != nil { + t.Fatal(err) + } + var te *TransitionError + if err := r.Complete(); !errors.As(err, &te) { + t.Fatalf("completing a queued round must be illegal, got %v", err) + } + if err := r.Acknowledge(); !errors.As(err, &te) { + t.Fatalf("acknowledging a queued round must be illegal, got %v", err) + } +} + +func TestReleaseToQueueKeepsAdoptionCutoff(t *testing.T) { + s := New() + r, _ := s.NewRound("owner/repo", 9, "abc123def", t0) + if err := r.Reserve("tok", "host", t0); err != nil { + t.Fatal(err) + } + if err := r.ReleaseToQueue("post failed", t0.Add(time.Second)); err != nil { + t.Fatal(err) + } + if r.Phase != PhaseQueued || r.Attempts != 1 { + t.Fatalf("after release: %+v", r) + } + if r.LastAttemptAt == nil || !r.LastAttemptAt.Equal(t0.Add(time.Second)) { + t.Fatalf("adoption cutoff must advance: %+v", r.LastAttemptAt) + } +} + +func TestOneRoundPerPR(t *testing.T) { + s := New() + if _, err := s.NewRound("Owner/Repo", 7, "abcdef123", t0); err != nil { + t.Fatal(err) + } + if _, err := s.NewRound("owner/repo", 7, "00fedcba9", t0); err == nil { + t.Fatal("second round for the same PR must be refused (case-insensitive key)") + } + r, err := s.Supersede("owner/repo", 7, "00fedcba9", t0.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + if r.Head != "00fedcba9" || r.Phase != PhaseQueued || r.Seq != 2 { + t.Fatalf("superseded round: %+v", r) + } + if len(s.Archive) != 1 || s.Archive[0].Phase != PhaseAbandoned || s.Archive[0].Head != "abcdef123" { + t.Fatalf("old round must be archived abandoned: %+v", s.Archive) + } +} + +func TestSlotRoundStaleness(t *testing.T) { + s := New() + r := newFired(t, &s) + s.FireSlot = &FireSlot{Key: Key(r.Repo, r.PR), Token: "tok", Since: t0} + if s.SlotRound() == nil { + t.Fatal("slot round must resolve") + } + s.FireSlot.Token = "stolen" + if s.SlotRound() != nil { + t.Fatal("token mismatch must read as stale") + } + s.Normalize(t0) + if s.FireSlot != nil { + t.Fatal("Normalize must clear a stale slot") + } +} + +func TestNextEligibleOrdersBySeq(t *testing.T) { + s := New() + a, _ := s.NewRound("owner/repo", 1, "aaaaaaaa1", t0) + s.PutRound(*a) + b, _ := s.NewRound("owner/repo", 2, "bbbbbbbb2", t0) + s.PutRound(*b) + // Round a parks awaiting retry; b becomes the eligible head of queue. + ra := s.Round("owner/repo", 1) + if err := ra.Reserve("tok", "host", t0); err != nil { + t.Fatal(err) + } + if err := ra.Fire(1, t0); err != nil { + t.Fatal(err) + } + if err := ra.AwaitRetry(t0.Add(10*time.Minute), "rate limited", t0); err != nil { + t.Fatal(err) + } + s.PutRound(*ra) + + if got := s.NextEligible(t0.Add(time.Minute)); got == nil || got.PR != 2 { + t.Fatalf("expected PR 2 eligible, got %+v", got) + } + // Once the window passes, the older round wins again by Seq. + if got := s.NextEligible(t0.Add(11 * time.Minute)); got == nil || got.PR != 1 { + t.Fatalf("expected PR 1 eligible after retry window, got %+v", got) + } +} + +func TestArchiveBounded(t *testing.T) { + s := New() + for i := 0; i < ArchiveMax+10; i++ { + if _, err := s.NewRound("owner/repo", i, "abcdef123", t0); err != nil { + t.Fatal(err) + } + s.EndRound("owner/repo", i, "closed") + } + if len(s.Archive) != ArchiveMax { + t.Fatalf("archive = %d, want %d", len(s.Archive), ArchiveMax) + } +} From 235ca7a26c8430ab7d02859df725fc5dc09b199d Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 16:10:24 +0200 Subject: [PATCH 04/20] Cut Pump, autoreview, and the store over to round state v3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 4a of the refactor: persistence and the daemon/pump paths now run on the schema-v3 round state machine instead of the five parallel v2 maps. - Move the git-ref CAS store and dashboard rendering into internal/state (store.go, dashboard.go), operating on state.State. A payload whose schema version isn't 3 (or won't parse) is logged loudly and auto-reinitialized — no migration, crq is pre-release. The default ref is now crq-state-v3, so a stale binary writing the old ref is harmless. - Rewrite Pump as observe -> engine -> apply: observe.go is the one place that fetches a PR's GitHub facts (head/open, reviews, classified comment events, reactions, adoptable commands with the v2 adoption cutoffs) and builds an engine.Observation. Pump progresses the slot round via engine.Progress, sweeps one reviewing round toward completion, then fires the next eligible round via engine.DecideFire. The mirrored dry-run branch is gone: apply() simply writes and posts nothing under DryRun. PumpResult action strings are preserved. - Rewrite Enqueue/enqueueBatch/Cancel/Status and autoreview needsReview on rounds (NewRound/Supersede/EndRound; needsReview skips only when a round already tracks the current head, any phase). - Port the calibration/quota code to AccountQuota (renamed from Blocked). - Keep feedback.go/Loop/Wait compiling on v3 with minimal shims (waitView, roundAnchor, firedMarker, accountBlockedUntil, wait-lifecycle helpers over round transitions); stage 4b rewrites them. - Add a Round.Dedupe transition (bot already reviewed the head) and drop the reviewing-round wall-clock deadline retry from engine.Progress (the loop owns its own wait timeout; the daemon waits for real bot evidence). - Migrate service_test.go and state_test.go to v3; delete tests that only exercised deleted v2 internals (requeueInflight, inflightStatus, the wait sweep/prune, FiredMax trimming, cooldowns) and add the missing edge cases as engine/state tests. --- cmd/crq/main.go | 4 +- internal/crq/auto.go | 54 +- internal/crq/config.go | 2 +- internal/crq/feedback.go | 170 +-- internal/crq/feedback_test.go | 309 +----- internal/crq/observe.go | 246 +++++ internal/crq/service.go | 1712 ++++++++++++------------------ internal/crq/service_test.go | 1785 +++++++++----------------------- internal/crq/state.go | 561 +++------- internal/crq/state_test.go | 113 +- internal/crq/store.go | 211 ---- internal/engine/engine_test.go | 53 + internal/engine/progress.go | 8 +- internal/state/dashboard.go | 209 ++++ internal/state/state.go | 16 + internal/state/state_test.go | 23 + internal/state/store.go | 300 ++++++ 17 files changed, 2264 insertions(+), 3512 deletions(-) create mode 100644 internal/crq/observe.go delete mode 100644 internal/crq/store.go create mode 100644 internal/state/dashboard.go create mode 100644 internal/state/store.go diff --git a/cmd/crq/main.go b/cmd/crq/main.go index cdc8161..6eef355 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -68,7 +68,7 @@ func run(ctx context.Context, args []string) int { return 1 } gh.SetLogger(stderrLogger{}) - store := crq.NewGitStateStore(cfg, gh) + store := crq.NewGitStateStore(cfg, gh, stderrLogger{}) service := crq.NewService(cfg, gh, store, stderrLogger{}) switch args[0] { @@ -241,7 +241,7 @@ func debug(ctx context.Context, service *crq.Service, store crq.StateStore, cfg fatal(err) return 1 } - printJSON(state.Blocked) + printJSON(state.Account) return 0 case "state": state, _, err := store.Load(ctx) diff --git a/internal/crq/auto.go b/internal/crq/auto.go index 7c3da17..f8a2fb4 100644 --- a/internal/crq/auto.go +++ b/internal/crq/auto.go @@ -217,7 +217,7 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t if err != nil { return err } - var candidates []SearchPR + var candidates []queueCandidate lastBeat := time.Now() for _, target := range targets { // Per-target scan budget so one large scope can't consume the whole budget @@ -257,7 +257,7 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t state = st // reuse the freshly written snapshot for later candidates lastBeat = time.Now() } - need, nerr := s.needsReview(ctx, state, repo, pr.Number, opts.Incremental) + need, head, nerr := s.needsReview(ctx, state, repo, pr.Number, opts.Incremental) if nerr != nil { // A rate limit must abort the pass so AutoReview's outer backoff kicks // in, instead of scanning the rest of the candidates under the same @@ -271,7 +271,7 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t return false, nil } if need { - candidates = append(candidates, SearchPR{Repo: repo, Number: pr.Number}) + candidates = append(candidates, queueCandidate{Repo: repo, PR: pr.Number, Head: head}) } return false, nil }) @@ -283,34 +283,24 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t return s.enqueueBatch(ctx, candidates) } -// needsReview reports whether an open PR should be enqueued for review: not -// already queued/fired for its current head, and (incremental) the bot hasn't -// reviewed that head yet, or (first-review) it has never been reviewed. It uses -// the caller's preloaded queue snapshot for the queued/fired checks so a pass -// doesn't reload git-backed state once per candidate. -func (s *Service) needsReview(ctx context.Context, state State, repo string, pr int, incremental bool) (bool, error) { - if state.Contains(repo, pr) { - return false, nil - } +// needsReview reports whether an open PR should be enqueued for review, and its +// current head. A round already tracking that exact head (any phase — Pump owns +// re-firing an awaiting_retry round once its window passes) means "no". Only a +// PR with no round at the current head runs the live checks: (incremental) the +// bot's last review commit differs from the head, or (first-review) the bot has +// never reviewed and no review-done marker is present. It reads the caller's +// preloaded state snapshot so a pass doesn't reload git-backed state per candidate. +func (s *Service) needsReview(ctx context.Context, state State, repo string, pr int, incremental bool) (bool, string, error) { head, err := s.headShort(ctx, repo, pr) if err != nil { - return false, err - } - if state.AwaitingFeedback[QueueKey(repo, pr)].Head == head { - return false, nil - } - if state.Fired[QueueKey(repo, pr)] == head { - return false, nil + return false, "", err } - // A rate-limited requeue clears Fired[key] so the PR can retry once the window - // clears, but the head is under a cooldown until then. Honour it here so a - // bouncing rate limit can't be re-enqueued (and re-fired) every pass. - if state.CooledDown(repo, pr, head, time.Now().UTC()) { - return false, nil + if r := state.Round(repo, pr); r != nil && r.Head == head { + return false, head, nil } reviews, err := s.gh.ListReviews(ctx, repo, pr) if err != nil { - return false, err + return false, "", err } bot := normalizeBotName(s.cfg.Bot) lastBotReview := "" @@ -324,29 +314,29 @@ func (s *Service) needsReview(ctx context.Context, state State, repo string, pr if need { s.logEnqueue(repo, pr, head, "no bot review at head") } - return need, nil + return need, head, nil } if lastBotReview != "" { - return false, nil + return false, head, nil } comments, err := s.gh.ListIssueComments(ctx, repo, pr) if err != nil { - return false, err + return false, "", err } for _, comment := range comments { if normalizeBotName(comment.User.Login) == bot && strings.Contains(comment.Body, s.cfg.ReviewDoneMarker) { - return false, nil + return false, head, nil } } pull, err := s.gh.GetPull(ctx, repo, pr) if err != nil { - return false, err + return false, "", err } if strings.Contains(pull.Body, s.cfg.ReviewDoneMarker) { - return false, nil + return false, head, nil } s.logEnqueue(repo, pr, head, "never reviewed") - return true, nil + return true, head, nil } // logEnqueue records one line per autoreview enqueue decision so a runaway is diff --git a/internal/crq/config.go b/internal/crq/config.go index 0ef7c56..778e084 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -93,7 +93,7 @@ func LoadConfig() (Config, error) { ExcludeRepos: repoSet(env["CRQ_EXCLUDE"]), SkipAuthors: authorSet(stringEnvAllowEmpty(env, "CRQ_AUTOREVIEW_SKIP_AUTHORS", "dependabot[bot]")), SkipMarker: stringEnvAllowEmpty(env, "CRQ_AUTOREVIEW_SKIP_MARKER", ""), - StateRef: stringEnv(env, "CRQ_STATE_REF", "crq-state"), + StateRef: stringEnv(env, "CRQ_STATE_REF", "crq-state-v3"), Bot: bot, RequiredBots: requiredBots, FeedbackBots: listEnv(env, "CRQ_FEEDBACK_BOTS", strings.Join(unionBots(requiredBots, extraFeedbackBots), ",")), diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index cfc93bf..e47573b 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -274,21 +274,8 @@ func (s *Service) feedbackCompletionContext(ctx context.Context, repo string, pr if err != nil { return feedbackCompletionContext{} } - key := QueueKey(repo, pr) - if wait := state.AwaitingFeedback[key]; wait.Head == head && !wait.StartedAt.IsZero() { - firedCommentID := wait.FiredCommentID - if firedCommentID == 0 { - firedCommentID = feedbackWaitFiredCommentID(state, repo, pr, head) - } - return feedbackCompletionContext{Cutoff: wait.StartedAt.UTC(), FiredCommentID: firedCommentID, OK: true} - } - if state.InFlight != nil && state.InFlight.Repo == repo && state.InFlight.PR == pr && state.InFlight.Head == head && state.InFlight.FiredAt != nil { - return feedbackCompletionContext{Cutoff: state.InFlight.FiredAt.UTC(), FiredCommentID: state.InFlight.FiredCommentID, OK: true} - } - for _, item := range state.History { - if NormalizeRepo(item.Repo) == repo && item.PR == pr && item.Commit == head && !item.At.IsZero() { - return feedbackCompletionContext{Cutoff: item.At.UTC(), OK: true} - } + if firedAt, commandID, ok := roundAnchor(&state, repo, pr, head); ok { + return feedbackCompletionContext{Cutoff: firedAt, FiredCommentID: commandID, OK: true} } return feedbackCompletionContext{} } @@ -407,7 +394,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport if loadErr != nil { return FeedbackReport{}, 1, loadErr } - if state.AwaitingFeedback[QueueKey(repo, pr)].Head != head { + if waitView(&state, repo, pr).Head != head { for { report, feedbackErr := s.Feedback(ctx, repo, pr) if feedbackErr != nil { @@ -571,64 +558,31 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport } } -func (s *Service) newFeedbackWait(repo string, pr int, head string, started time.Time, firedCommentID int64) FeedbackWait { - started = started.UTC() - if started.IsZero() { - started = time.Now().UTC() - } - return FeedbackWait{ - Repo: NormalizeRepo(repo), - PR: pr, - Head: head, - StartedAt: started, - Deadline: started.Add(s.cfg.FeedbackWaitTimeout), - FiredCommentID: firedCommentID, - ByHost: s.cfg.Host, - } -} - func (s *Service) ensureFeedbackWait(ctx context.Context, repo string, pr int, head string) (FeedbackWait, error) { repo = NormalizeRepo(repo) - key := QueueKey(repo, pr) - var wait FeedbackWait + st, _, err := s.store.Load(ctx) + if err != nil { + return FeedbackWait{}, err + } + if w := waitView(&st, repo, pr); w.Head == head && !w.Deadline.IsZero() { + return w, nil + } + // Set the wait deadline on the fired/reviewing round if the fire path hasn't + // yet (Pump normally sets it at fire time). changed := false - state, err := s.store.Update(ctx, func(st *State) error { + updated, err := s.store.Update(ctx, func(st *State) error { changed = false - if st.AwaitingFeedback == nil { - st.AwaitingFeedback = map[string]FeedbackWait{} - } - firedCommentID := feedbackWaitFiredCommentID(*st, repo, pr, head) - if existing := st.AwaitingFeedback[key]; existing.Head == head { - wait = existing - if wait.StartedAt.IsZero() { - wait.StartedAt = feedbackWaitStart(*st, repo, pr, head, time.Now().UTC()) - changed = true - } - if wait.Deadline.IsZero() { - wait.Deadline = wait.StartedAt.Add(s.cfg.FeedbackWaitTimeout) - changed = true - } - wait.Repo = repo - wait.PR = pr - if wait.ByHost == "" { - wait.ByHost = s.cfg.Host - changed = true - } - if wait.FiredCommentID == 0 && firedCommentID != 0 { - wait.FiredCommentID = firedCommentID - changed = true - } - if changed { - st.AwaitingFeedback[key] = wait - st.Fired[key] = head - return nil - } + r := st.Round(repo, pr) + if r == nil || r.Head != head || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) || r.WaitDeadline != nil { return ErrNoChange } - started := feedbackWaitStart(*st, repo, pr, head, time.Now().UTC()) - wait = s.newFeedbackWait(repo, pr, head, started, firedCommentID) - st.AwaitingFeedback[key] = wait - st.Fired[key] = head + start := time.Now().UTC() + if r.FiredAt != nil { + start = r.FiredAt.UTC() + } + dl := start.Add(s.cfg.FeedbackWaitTimeout) + r.WaitDeadline = &dl + st.PutRound(*r) changed = true return nil }) @@ -636,45 +590,32 @@ func (s *Service) ensureFeedbackWait(ctx context.Context, repo string, pr int, h return FeedbackWait{}, err } if changed { - s.sync(ctx, state) - } - return wait, nil -} - -func feedbackWaitFiredCommentID(st State, repo string, pr int, head string) int64 { - if st.InFlight != nil && st.InFlight.Repo == repo && st.InFlight.PR == pr && st.InFlight.Head == head { - return st.InFlight.FiredCommentID - } - return 0 -} - -func feedbackWaitStart(st State, repo string, pr int, head string, fallback time.Time) time.Time { - if st.InFlight != nil && st.InFlight.Repo == repo && st.InFlight.PR == pr && st.InFlight.Head == head && st.InFlight.FiredAt != nil { - return st.InFlight.FiredAt.UTC() + s.sync(ctx, updated) } - for _, item := range st.History { - if NormalizeRepo(item.Repo) == repo && item.PR == pr && item.Commit == head { - return item.At.UTC() - } + if w := waitView(&updated, repo, pr); w.Head == head && !w.Deadline.IsZero() { + return w, nil } - return fallback.UTC() + // The round is no longer a wait (completed/none): synthesize a transient + // deadline so the loop still bounds its poll. + now := time.Now().UTC() + return FeedbackWait{Repo: repo, PR: pr, Head: head, StartedAt: now, Deadline: now.Add(s.cfg.FeedbackWaitTimeout), ByHost: s.cfg.Host}, nil } func (s *Service) extendFeedbackWaitDeadline(ctx context.Context, repo string, pr int, head string, deadline time.Time) { repo = NormalizeRepo(repo) - key := QueueKey(repo, pr) changed := false state, err := s.store.Update(ctx, func(st *State) error { changed = false - wait := st.AwaitingFeedback[key] - if wait.Head != head { + r := st.Round(repo, pr) + if r == nil || r.Head != head || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) { return ErrNoChange } - if !deadline.After(wait.Deadline) { + if r.WaitDeadline != nil && !deadline.After(*r.WaitDeadline) { return ErrNoChange } - wait.Deadline = deadline.UTC() - st.AwaitingFeedback[key] = wait + dl := deadline.UTC() + r.WaitDeadline = &dl + st.PutRound(*r) changed = true return nil }) @@ -689,20 +630,26 @@ func (s *Service) extendFeedbackWaitDeadline(ctx context.Context, repo string, p } } +// clearFeedbackWait ends the wait by completing the fired/reviewing round. The +// completed round remains as the dedup marker (v2 kept Fired[key] while deleting +// the wait), so a subsequent enqueue/needsReview at the same head is deduped. func (s *Service) clearFeedbackWait(ctx context.Context, repo string, pr int, head string) { repo = NormalizeRepo(repo) - key := QueueKey(repo, pr) changed := false state, err := s.store.Update(ctx, func(st *State) error { changed = false - wait := st.AwaitingFeedback[key] - if wait.Head == "" { + r := st.Round(repo, pr) + if r == nil || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) { return ErrNoChange } - if head != "" && wait.Head != head { + if head != "" && r.Head != head { return ErrNoChange } - delete(st.AwaitingFeedback, key) + if err := r.Complete(); err != nil { + return err + } + releaseSlot(st, QueueKey(repo, pr)) + st.PutRound(*r) changed = true return nil }) @@ -786,19 +733,11 @@ func extendDeadlineForBlock(deadline time.Time, blockedUntil *time.Time, now tim } // feedbackBlockedUntil returns the latest active block that prevents this exact -// PR head from firing. The account-wide Blocked value can be cleared or replaced -// by a later calibration pass, while the per-head cooldown intentionally -// survives a rate-limited requeue. Feedback waiters must honor both or they can -// claim to be waiting on a review that crq is still forbidden to request. +// PR head from firing: the account-wide quota block, or this round's own +// awaiting_retry window. Feedback waiters must honor both or they can claim to +// be waiting on a review that crq is still forbidden to request. func feedbackBlockedUntil(st State, repo string, pr int, head string, now time.Time) (time.Time, bool) { - var until time.Time - if st.Blocked.BlockedUntil != nil && st.Blocked.BlockedUntil.After(now) { - until = st.Blocked.BlockedUntil.UTC() - } - if cooldown, ok := st.Cooldown[QueueKey(repo, pr)]; ok && cooldown.Head == head && cooldown.Until.After(now) && cooldown.Until.After(until) { - until = cooldown.Until.UTC() - } - return until, !until.IsZero() + return accountBlockedUntil(&st, repo, pr, head, now) } // feedbackWaitElapsed reports only reviewable time. A rate-limit block extends @@ -1158,13 +1097,8 @@ func (s *Service) completionFallbackFiredAt(ctx context.Context, repo string, pr if err != nil { return time.Time{}, false } - firedAt := feedbackWaitStart(st, repo, pr, head, time.Time{}) - if wait := st.AwaitingFeedback[QueueKey(repo, pr)]; firedAt.IsZero() && wait.Head == head && !wait.StartedAt.IsZero() { - // History is bounded and shared across the fleet, so the entry can be - // evicted during a long wait; the live wait carries the same fire anchor. - firedAt = wait.StartedAt - } - if firedAt.IsZero() { + firedAt, _, ok := roundAnchor(&st, repo, pr, head) + if !ok { return time.Time{}, false } return firedAt, true diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index 6556ca8..2681c10 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -110,12 +110,7 @@ func TestFeedbackCountsCompletionReplyForFiredHead(t *testing.T) { } store := NewMemoryStore(cfg) if seedHistory { - if _, err := store.Update(context.Background(), func(st *State) error { - st.History = append(st.History, HistoryItem{Repo: "o/repo", PR: 3, Commit: head, At: firedAt, Host: "testhost"}) - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "o/repo", 3, head, PhaseReviewing, firedAt, 1) } return NewService(cfg, gh, store, nil) } @@ -218,12 +213,7 @@ func TestFeedbackRejectsCompletionReplyWhileTopSummaryIsProcessing(t *testing.T) prior.User.Login = cfg.Bot gh.reviews[fakeKey("o/repo", 3)] = []Review{prior} store := NewMemoryStore(cfg) - if _, err := store.Update(context.Background(), func(st *State) error { - st.History = append(st.History, HistoryItem{Repo: "o/repo", PR: 3, Commit: head, At: firedAt, Host: "testhost"}) - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "o/repo", 3, head, PhaseReviewing, firedAt, 1) service := NewService(cfg, gh, store, nil) report, err := service.Feedback(context.Background(), "o/repo", 3) @@ -277,12 +267,7 @@ func TestFeedbackRejectsCompletionReplyWhenTopSummaryFailed(t *testing.T) { prior.User.Login = cfg.Bot gh.reviews[fakeKey("o/repo", 3)] = []Review{prior} store := NewMemoryStore(cfg) - if _, err := store.Update(context.Background(), func(st *State) error { - st.History = append(st.History, HistoryItem{Repo: "o/repo", PR: 3, Commit: head, At: firedAt, Host: "testhost"}) - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "o/repo", 3, head, PhaseReviewing, firedAt, 1) service := NewService(cfg, gh, store, nil) report, err := service.Feedback(context.Background(), "o/repo", 3) @@ -333,12 +318,7 @@ func TestFeedbackRejectsCompletionReplyFromEarlierRound(t *testing.T) { prior.User.Login = "coderabbitai[bot]" gh.reviews[fakeKey("o/repo", 3)] = []Review{prior} store := NewMemoryStore(cfg) - if _, err := store.Update(context.Background(), func(st *State) error { - st.History = append(st.History, HistoryItem{Repo: "o/repo", PR: 3, Commit: head, At: firedAt, Host: "testhost"}) - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "o/repo", 3, head, PhaseReviewing, firedAt, 1) return NewService(cfg, gh, store, nil) } @@ -396,12 +376,7 @@ func TestFeedbackSkipsReviewAnsweredCommandsWhenPairingCompletionReplies(t *test oldReview.User.Login = cfg.Bot gh.reviews[fakeKey("o/repo", 3)] = []Review{oldReview} store := NewMemoryStore(cfg) - if _, err := store.Update(context.Background(), func(st *State) error { - st.History = append(st.History, HistoryItem{Repo: "o/repo", PR: 3, Commit: head, At: firedAt, Host: "testhost"}) - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "o/repo", 3, head, PhaseReviewing, firedAt, 1) svc := NewService(cfg, gh, store, nil) rep, err := svc.Feedback(context.Background(), "o/repo", 3) @@ -782,20 +757,7 @@ func TestFeedbackCurrentRoundDoesNotResurfacePreRoundBodyFindings(t *testing.T) gh.comments[fakeKey("o/repo", 5)] = []IssueComment{completion} store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("o/repo", 5) - st.Fired[key] = head[:9] - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "o/repo", - PR: 5, - Head: head[:9], - StartedAt: started, - Deadline: started.Add(time.Hour), - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "o/repo", 5, head[:9], PhaseReviewing, started, 0) rep, err := NewService(cfg, gh, store, nil).Feedback(ctx, "o/repo", 5) if err != nil { @@ -839,20 +801,7 @@ Fetch by topic before applying the result limit.`, gh.reviews[fakeKey("o/repo", 5)] = []Review{codex, codeRabbit} store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("o/repo", 5) - st.Fired[key] = head[:9] - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "o/repo", - PR: 5, - Head: head[:9], - StartedAt: started, - Deadline: started.Add(time.Hour), - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "o/repo", 5, head[:9], PhaseReviewing, started, 0) rep, err := NewService(cfg, gh, store, nil).Feedback(ctx, "o/repo", 5) if err != nil { @@ -996,20 +945,7 @@ func TestFeedbackMarksCurrentNoActionCompletionCommentReviewed(t *testing.T) { comment.User.Login = "coderabbitai[bot]" gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("o/repo", 1) - st.Fired[key] = "abcdef123" - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "o/repo", - PR: 1, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(time.Hour), - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "o/repo", 1, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) report, err := svc.Feedback(ctx, "o/repo", 1) @@ -1048,20 +984,7 @@ func TestFeedbackIgnoresStaleNoActionCompletionComment(t *testing.T) { comment.User.Login = "coderabbitai[bot]" gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("o/repo", 1) - st.Fired[key] = "abcdef123" - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "o/repo", - PR: 1, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(time.Hour), - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "o/repo", 1, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) report, err := svc.Feedback(ctx, "o/repo", 1) @@ -1094,20 +1017,7 @@ func TestFeedbackDoesNotUseNoActionCompletionWhileCodexRequiredWithoutThumbsUp(t comment.User.Login = "coderabbitai[bot]" gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("o/repo", 1) - st.Fired[key] = "abcdef123" - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "o/repo", - PR: 1, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(time.Hour), - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "o/repo", 1, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) report, err := svc.Feedback(ctx, "o/repo", 1) @@ -1146,21 +1056,7 @@ func TestFeedbackUsesNoActionCompletionAfterCodexThumbsUp(t *testing.T) { thumb.User.Login = "chatgpt-codex-connector[bot]" gh.reactions[99] = []Reaction{thumb} store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("o/repo", 1) - st.Fired[key] = "abcdef123" - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "o/repo", - PR: 1, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(time.Hour), - FiredCommentID: 99, - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "o/repo", 1, "abcdef123", PhaseReviewing, started, 99) svc := NewService(cfg, gh, store, nil) report, err := svc.Feedback(ctx, "o/repo", 1) @@ -1238,20 +1134,7 @@ func TestFeedbackMarksRequiredCodexCleanReviewSummaryReviewed(t *testing.T) { comment.User.Login = "chatgpt-codex-connector[bot]" gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("o/repo", 1) - st.Fired[key] = "abcdef123" - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "o/repo", - PR: 1, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(time.Hour), - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "o/repo", 1, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) report, err := svc.Feedback(ctx, "o/repo", 1) @@ -1293,20 +1176,7 @@ func TestFeedbackDoesNotUseStaleCodexCleanReviewSummary(t *testing.T) { comment.User.Login = "chatgpt-codex-connector[bot]" gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("o/repo", 1) - st.Fired[key] = "abcdef123" - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "o/repo", - PR: 1, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(time.Hour), - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "o/repo", 1, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) report, err := svc.Feedback(ctx, "o/repo", 1) @@ -1351,20 +1221,7 @@ func TestLoopConvergesOnCurrentNoActionCompletionComment(t *testing.T) { comment.User.Login = "coderabbitai[bot]" gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("owner/repo", 12) - st.Fired[key] = "abcdef123" - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "owner/repo", - PR: 12, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(time.Millisecond), - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) report, code, err := svc.Loop(ctx, "owner/repo", 12) @@ -1500,21 +1357,39 @@ func TestExtendDeadlineForBlock(t *testing.T) { } } +// parkedRound builds a State whose repo#pr round is parked awaiting_retry at +// head until retryAt — the v3 per-head cooldown. +func parkedRound(t *testing.T, repo string, pr int, head string, retryAt, now time.Time) State { + t.Helper() + st := DefaultState(Config{}) + r, err := st.NewRound(repo, pr, head, now.Add(-time.Hour)) + if err != nil { + t.Fatal(err) + } + if err := r.Reserve("t", "h", now.Add(-time.Hour)); err != nil { + t.Fatal(err) + } + if err := r.Fire(1, now.Add(-time.Hour)); err != nil { + t.Fatal(err) + } + if err := r.AwaitRetry(retryAt, "rate limited", now.Add(-time.Minute)); err != nil { + t.Fatal(err) + } + st.PutRound(*r) + return st +} + func TestFeedbackBlockedUntilHonorsPerHeadCooldownAfterGlobalBlockClears(t *testing.T) { now := time.Date(2026, 7, 13, 14, 4, 0, 0, time.UTC) cooldownUntil := now.Add(15 * time.Minute) - st := State{ - Cooldown: map[string]FireCooldown{ - QueueKey("owner/repo", 947): {Head: "168df6ae6", Until: cooldownUntil}, - }, - } + st := parkedRound(t, "owner/repo", 947, "168df6ae6", cooldownUntil, now) got, ok := feedbackBlockedUntil(st, "owner/repo", 947, "168df6ae6", now) if !ok || !got.Equal(cooldownUntil) { - t.Fatalf("matching head cooldown must keep feedback wait blocked: got %v, ok=%v", got, ok) + t.Fatalf("matching head retry window must keep feedback wait blocked: got %v, ok=%v", got, ok) } if _, ok := feedbackBlockedUntil(st, "owner/repo", 947, "different", now); ok { - t.Fatal("a cooldown for an older head must not block the current head") + t.Fatal("a retry window for an older head must not block the current head") } } @@ -1522,12 +1397,8 @@ func TestFeedbackBlockedUntilUsesLatestAccountOrHeadWindow(t *testing.T) { now := time.Date(2026, 7, 13, 14, 4, 0, 0, time.UTC) accountUntil := now.Add(20 * time.Minute) cooldownUntil := now.Add(15 * time.Minute) - st := State{ - Blocked: Blocked{BlockedUntil: &accountUntil}, - Cooldown: map[string]FireCooldown{ - QueueKey("owner/repo", 947): {Head: "168df6ae6", Until: cooldownUntil}, - }, - } + st := parkedRound(t, "owner/repo", 947, "168df6ae6", cooldownUntil, now) + st.Account.BlockedUntil = &accountUntil got, ok := feedbackBlockedUntil(st, "owner/repo", 947, "168df6ae6", now) if !ok || !got.Equal(accountUntil) { @@ -1620,12 +1491,7 @@ func TestLoopRequiresAllRequiredBotsAfterDedupe(t *testing.T) { review.User.Login = "coderabbitai[bot]" gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.Fired[QueueKey("owner/repo", 12)] = "abcdef123" - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseCompleted, time.Now().UTC(), 1) svc := NewService(cfg, gh, store, nil) report, code, err := svc.Loop(ctx, "owner/repo", 12) @@ -1667,21 +1533,7 @@ func TestLoopResumesAwaitingFeedbackWithoutRefiring(t *testing.T) { gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} store := NewMemoryStore(cfg) started := time.Now().UTC().Add(-time.Minute) - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("owner/repo", 12) - st.Fired[key] = "abcdef123" - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "owner/repo", - PR: 12, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(cfg.FeedbackWaitTimeout), - ByHost: "oldhost", - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) report, code, err := svc.Loop(ctx, "owner/repo", 12) @@ -1698,11 +1550,11 @@ func TestLoopResumesAwaitingFeedbackWithoutRefiring(t *testing.T) { if err != nil { t.Fatal(err) } - if wait := state.AwaitingFeedback[QueueKey("owner/repo", 12)]; wait.Head != "" { - t.Fatalf("feedback wait should clear after findings are collected, got %#v", wait) + if waitView(&state, "owner/repo", 12).Head != "" { + t.Fatalf("feedback wait should clear after findings are collected") } - if state.Fired[QueueKey("owner/repo", 12)] != "abcdef123" { - t.Fatalf("fired marker should remain for dedupe after collection: %#v", state.Fired) + if firedMarker(&state, "owner/repo", 12) != "abcdef123" { + t.Fatalf("fired marker should remain for dedupe after collection") } } @@ -1735,20 +1587,7 @@ func TestLoopWaitsForReplacementReviewInsteadOfReturningCarriedPrompt(t *testing gh.reviews[fakeKey("owner/repo", 12)] = []Review{stale} store := NewMemoryStore(cfg) started := time.Now().UTC() - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("owner/repo", 12) - st.Fired[key] = "abcdef123" - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "owner/repo", - PR: 12, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(cfg.FeedbackWaitTimeout), - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, started, 0) go func() { time.Sleep(5 * time.Millisecond) @@ -1807,7 +1646,7 @@ func TestLoopReturnsExistingCodexFeedbackBeforeWaitingForReviewSlot(t *testing.T store := NewMemoryStore(cfg) blockedUntil := time.Now().UTC().Add(time.Hour) if _, err := store.Update(ctx, func(st *State) error { - st.Blocked.BlockedUntil = &blockedUntil + st.Account.BlockedUntil = &blockedUntil return nil }); err != nil { t.Fatal(err) @@ -1916,20 +1755,7 @@ func TestLoopReturnsFindingsBeforeRequiredReviewerTimeout(t *testing.T) { comment.User.Login = "chatgpt-codex-connector[bot]" gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("owner/repo", 12) - st.Fired[key] = "abcdef123" - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "owner/repo", - PR: 12, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(cfg.FeedbackWaitTimeout), - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) report, code, err := svc.Loop(ctx, "owner/repo", 12) @@ -1977,20 +1803,7 @@ func TestLoopReturnsFasterCodexFeedbackBeforeCodeRabbitReviews(t *testing.T) { gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{codexFinding} store := NewMemoryStore(cfg) started := time.Now().UTC() - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("owner/repo", 12) - st.Fired[key] = "abcdef123" - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "owner/repo", - PR: 12, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(cfg.FeedbackWaitTimeout), - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) report, code, err := svc.Loop(ctx, "owner/repo", 12) @@ -2025,21 +1838,7 @@ func TestLoopUsesPersistedFeedbackDeadline(t *testing.T) { gh.pulls[fakeKey("owner/repo", 12)] = pull store := NewMemoryStore(cfg) started := time.Now().UTC().Add(-2 * time.Hour) - if _, err := store.Update(ctx, func(st *State) error { - key := QueueKey("owner/repo", 12) - st.Fired[key] = "abcdef123" - st.AwaitingFeedback[key] = FeedbackWait{ - Repo: "owner/repo", - PR: 12, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(cfg.FeedbackWaitTimeout), - ByHost: "oldhost", - } - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) begin := time.Now() @@ -2060,8 +1859,8 @@ func TestLoopUsesPersistedFeedbackDeadline(t *testing.T) { if err != nil { t.Fatal(err) } - if wait := state.AwaitingFeedback[QueueKey("owner/repo", 12)]; wait.Head != "" { - t.Fatalf("expired feedback wait should clear after timeout, got %#v", wait) + if waitView(&state, "owner/repo", 12).Head != "" { + t.Fatalf("expired feedback wait should clear after timeout") } } diff --git a/internal/crq/observe.go b/internal/crq/observe.go new file mode 100644 index 0000000..578b15b --- /dev/null +++ b/internal/crq/observe.go @@ -0,0 +1,246 @@ +package crq + +import ( + "context" + "strings" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" +) + +// observe is the single place that asks GitHub "what happened on this PR" and +// reduces it to an engine.Observation. The daemon's Pump builds it once for the +// slot round (Progress) and once for the next-eligible round (DecideFire), so +// the "is head reviewed?" duplication of v2 collapses to one implementation. +// +// round anchors the round-relative facts: reactions target its fired command, +// the adoption cutoff is its LastAttemptAt, and reactions/thumbs-up are fetched +// only for a round that has fired. +func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round, now time.Time) (engine.Observation, error) { + pull, err := s.gh.GetPull(ctx, repo, pr) + if err != nil { + return engine.Observation{}, err + } + obs := engine.Observation{Open: pull.State == "open" && !pull.Merged} + if obs.Open && len(pull.Head.SHA) >= 9 { + obs.Head = pull.Head.SHA[:9] + } + if !obs.Open { + // A closed PR needs no further facts; the engine drops/abandons the round. + return obs, nil + } + + reviews, err := s.gh.ListReviews(ctx, repo, pr) + if err != nil { + return engine.Observation{}, err + } + for _, review := range reviews { + obs.Reviews = append(obs.Reviews, engine.ReviewSeen{ + Bot: review.User.Login, + ReviewID: review.ID, + Commit: shortOID(review.CommitID), + SubmittedAt: review.SubmittedAt, + }) + } + + comments, err := s.gh.ListIssueComments(ctx, repo, pr) + if err != nil { + return engine.Observation{}, err + } + classifier := dialect.Classifier{CodeRabbit: s.cr, Bot: s.cfg.Bot, ReviewCommand: s.cfg.ReviewCommand} + for _, c := range comments { + obs.Events = append(obs.Events, classifier.Classify(c.User.Login, c.Body, c.ID, c.CreatedAt, c.UpdatedAt)) + } + + // Reactions and Codex thumbs-up only matter for a round that has fired. + if round != nil && round.FiredAt != nil { + cutoff := round.FiredAt.UTC() + if round.CommandID != 0 { + reactions, err := s.gh.ListCommentReactions(ctx, repo, round.CommandID) + if err != nil { + return engine.Observation{}, err + } + for _, reaction := range reactions { + if s.isConfiguredBot(reaction.User.Login) { + obs.Reacted = true + } + if isCurrentCodexThumbsUp(reaction, cutoff) { + obs.CodexThumbsUp = true + } + } + } + if !obs.CodexThumbsUp && s.codexRelevant(obs) { + reactions, err := s.gh.ListIssueReactions(ctx, repo, pr) + if err != nil { + return engine.Observation{}, err + } + for _, reaction := range reactions { + if isCurrentCodexThumbsUp(reaction, cutoff) { + obs.CodexThumbsUp = true + break + } + } + } + } + + // Adoptable commands are only consulted for a fire-eligible round. + if round != nil && round.FireEligible(now) { + cmds, err := s.adoptableCommands(ctx, repo, pr, obs.Head, adoptCutoff(*round), pull, comments, reviews) + if err != nil { + return engine.Observation{}, err + } + obs.Commands = cmds + } + return obs, nil +} + +// 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. +func adoptCutoff(r Round) time.Time { + if r.LastAttemptAt != nil { + return r.LastAttemptAt.UTC() + } + return time.Time{} +} + +// codexRelevant reports whether Codex participates in this round, so the extra +// issue-reactions fetch for a Codex thumbs-up is only spent when it can matter. +func (s *Service) codexRelevant(obs engine.Observation) bool { + if dialect.HasCodexBot(s.cfg.RequiredBots) { + return true + } + for _, review := range obs.Reviews { + if dialect.IsCodexBot(review.Bot) { + return true + } + } + for _, ev := range obs.Events { + if ev.Kind == dialect.EvCodexClean || dialect.IsCodexBot(ev.Bot) { + return true + } + } + return false +} + +// adoptableCommands ports v2's existingReviewCommand: it returns the newest +// review-command comment safe to adopt as an already-posted fire, or none. The +// cutoffs (LastAttemptAt floor, head-commit date, force-push, already-answered) +// are applied here so the engine only picks the newest survivor. +func (s *Service) adoptableCommands(ctx context.Context, repo string, pr int, head string, notBeforeCutoff time.Time, pull Pull, comments []IssueComment, reviews []Review) ([]engine.CommandSeen, error) { + command := strings.TrimSpace(s.cfg.ReviewCommand) + if command == "" { + return nil, nil + } + // The head-guard and cutoff lookups cost REST/GraphQL calls; skip them + // entirely in the common case of no command comment on the PR at all. + hasCandidate := false + for _, comment := range comments { + if strings.TrimSpace(comment.Body) == command { + hasCandidate = true + break + } + } + if !hasCandidate { + return nil, nil + } + cutoff := notBeforeCutoff + if pull.Head.SHA != "" { + if shortOID(pull.Head.SHA) != head { + return nil, nil + } + commit, err := s.gh.GetCommit(ctx, repo, pull.Head.SHA) + if err != nil { + if _, ok := rateLimitWait(err); ok { + return nil, err + } + // No head-commit cutoff available (unreadable/404 head): skip adoption + // rather than wedge the queue — the worst case is posting a command that + // already exists, the pre-adoption behavior. + return nil, nil + } + if commit.Committer.Date.After(cutoff) { + cutoff = commit.Committer.Date + } + } + // A force-push can point the PR at a commit object whose committer date + // predates commands made for an earlier head, so any command older than the + // last force-push belongs to a previous head and must not be adopted. + if fp := s.headForcePushCutoff(ctx, repo, pr); fp.After(cutoff) { + cutoff = fp + } + var best IssueComment + var bestAt time.Time + ok := false + for _, comment := range comments { + if strings.TrimSpace(comment.Body) != command { + continue + } + when := comment.CreatedAt + if when.IsZero() { + when = comment.UpdatedAt + } + if !cutoff.IsZero() && when.Before(cutoff) { + continue + } + if !ok || when.After(bestAt) { + best = comment + bestAt = when + ok = true + } + } + if !ok { + return nil, nil + } + // A command the bot has already answered with a review belongs to a completed + // round for an earlier head; adopting it would mark the new head fired without + // reviewing it. Skip adoption — the worst case is a duplicate command. + for _, review := range reviews { + if s.isConfiguredBot(review.User.Login) && !review.SubmittedAt.Before(bestAt) { + return nil, nil + } + } + if s.reviewCommandHasCompletionReply(comments, reviews, best.ID) { + return nil, nil + } + return []engine.CommandSeen{{ID: best.ID, CreatedAt: best.CreatedAt, UpdatedAt: best.UpdatedAt}}, nil +} + +// headForcePushCutoff returns when the PR head was last force-pushed, zero if +// unknown or never. Best-effort: on GraphQL failure adoption falls back to the +// commit-date cutoff rather than blocking the pump. +func (s *Service) headForcePushCutoff(ctx context.Context, repo string, pr int) time.Time { + owner, name, found := strings.Cut(repo, "/") + if !found { + return time.Time{} + } + var result struct { + Repository struct { + PullRequest struct { + TimelineItems struct { + Nodes []struct { + CreatedAt time.Time `json:"createdAt"` + } `json:"nodes"` + } `json:"timelineItems"` + } `json:"pullRequest"` + } `json:"repository"` + } + query := `query($owner:String!, $name:String!, $number:Int!) { + repository(owner:$owner, name:$name) { + pullRequest(number:$number) { + timelineItems(itemTypes: HEAD_REF_FORCE_PUSHED_EVENT, last: 1) { + nodes { ... on HeadRefForcePushedEvent { createdAt } } + } + } + } +}` + if err := s.gh.GraphQL(ctx, query, map[string]any{"owner": owner, "name": name, "number": pr}, &result); err != nil { + return time.Time{} + } + nodes := result.Repository.PullRequest.TimelineItems.Nodes + if len(nodes) == 0 { + return time.Time{} + } + return nodes[len(nodes)-1].CreatedAt.UTC() +} diff --git a/internal/crq/service.go b/internal/crq/service.go index cb239e6..8bb1559 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -7,12 +7,12 @@ import ( "errors" "fmt" "io" - "sort" "strconv" "strings" "time" "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" ) type Logger interface { @@ -53,6 +53,11 @@ func NewService(cfg Config, gh GitHubAPI, store StateStore, log Logger) *Service return &Service{cfg: cfg, cr: cr, gh: gh, store: store, log: log} } +// warnRateLimited is the requeue reason for a rate-limited fire. It matches the +// engine's rate-limit Transition.Reason and is surfaced via AccountQuota, not +// the sticky Warn field. +const warnRateLimited = "rate limited" + type EnqueueResult struct { Repo string `json:"repo"` PR int `json:"pr"` @@ -63,48 +68,41 @@ type EnqueueResult struct { Seq int64 `json:"seq,omitempty"` } +// Enqueue records a review round for repo#pr's current head. A round already +// tracking the head is reported (queued/deduped) instead of duplicated; a round +// on a stale head is superseded to track the new one. func (s *Service) Enqueue(ctx context.Context, repo string, pr int) (EnqueueResult, error) { repo = NormalizeRepo(repo) result := EnqueueResult{Repo: repo, PR: pr} - state, err := s.store.Update(ctx, func(state *State) error { - if state.Contains(repo, pr) { - result.AlreadyQueued = true - return ErrNoChange - } - key := QueueKey(repo, pr) - head := "" - if wait := state.AwaitingFeedback[key]; wait.Head != "" { - var err error - head, err = s.headShort(ctx, repo, pr) - if err == nil && head == wait.Head { + head, err := s.headShort(ctx, repo, pr) + if err != nil { + return result, err + } + state, err := s.store.Update(ctx, func(st *State) error { + now := time.Now().UTC() + r := st.Round(repo, pr) + if r != nil && r.Head == head { + switch r.Phase { + case PhaseFired, PhaseReviewing, PhaseCompleted: result.Deduped = true result.Head = head - return ErrNoChange + default: + result.AlreadyQueued = true } + return ErrNoChange } - if fired := state.Fired[key]; fired != "" { - var err error - if head == "" { - head, err = s.headShort(ctx, repo, pr) - } - if err == nil && head == fired { - result.Deduped = true - result.Head = head - return ErrNoChange - } + var nr *Round + if r != nil { + // The tracked head is stale — supersede to the current one. + nr, err = st.Supersede(repo, pr, head, now) + } else { + nr, err = st.NewRound(repo, pr, head, now) } - state.NextSeq++ - item := QueueItem{ - Seq: state.NextSeq, - Owner: ownerOf(repo), - Repo: repo, - PR: pr, - Host: s.cfg.Host, - EnqueuedAt: time.Now().UTC(), + if err != nil { + return err } - state.Queue = append(state.Queue, item) result.Queued = true - result.Seq = item.Seq + result.Seq = nr.Seq return nil }) if err != nil { @@ -114,31 +112,40 @@ func (s *Service) Enqueue(ctx context.Context, repo string, pr int) (EnqueueResu return result, nil } -// enqueueBatch appends several PRs to the queue in a single compare-and-swap -// write plus one dashboard sync, so a large autoreview pass doesn't produce N -// separate state writes / issue edits (the write-storm in #2). PRs already -// queued or in flight are skipped; the fired-head dedup still happens at pump -// time, so a stale candidate can't cause a double review. -func (s *Service) enqueueBatch(ctx context.Context, items []SearchPR) error { +// queueCandidate is one PR the autoreview pass decided to enqueue, carrying the +// head it resolved so enqueueBatch can create the round without re-fetching. +type queueCandidate struct { + Repo string + PR int + Head string +} + +// enqueueBatch appends several PRs in a single compare-and-swap write plus one +// dashboard sync, so a large autoreview pass doesn't produce N separate state +// writes / issue edits. A PR already tracked at the same head is skipped; a +// stale head is superseded. The DecideFire dedup still backstops at pump time. +func (s *Service) enqueueBatch(ctx context.Context, items []queueCandidate) error { if len(items) == 0 { return nil } state, err := s.store.Update(ctx, func(st *State) error { + now := time.Now().UTC() added := 0 for _, it := range items { repo := NormalizeRepo(it.Repo) - if st.Contains(repo, it.Number) { + if r := st.Round(repo, it.PR); r != nil { + if r.Head == it.Head { + continue + } + if _, err := st.Supersede(repo, it.PR, it.Head, now); err != nil { + return err + } + added++ continue } - st.NextSeq++ - st.Queue = append(st.Queue, QueueItem{ - Seq: st.NextSeq, - Owner: ownerOf(repo), - Repo: repo, - PR: it.Number, - Host: s.cfg.Host, - EnqueuedAt: time.Now().UTC(), - }) + if _, err := st.NewRound(repo, it.PR, it.Head, now); err != nil { + return err + } added++ } if added == 0 { @@ -161,673 +168,525 @@ type PumpResult struct { Reason string `json:"reason,omitempty"` } +// Pump advances the queue by one observe → engine → apply step: it progresses +// the round holding the fire slot, sweeps one reviewing round toward +// completion, then fires the next eligible round. In DryRun it computes the +// same decisions but writes and posts nothing. func (s *Service) Pump(ctx context.Context) (PumpResult, error) { - if state, _, err := s.store.Load(ctx); err == nil && state.InFlight != nil { - status, err := s.inflightStatus(ctx, state) - if err != nil { - return PumpResult{}, err - } - if status.Done || status.Requeue { - updated, err := s.store.Update(ctx, func(st *State) error { - if st.InFlight == nil || st.InFlight.Token != state.InFlight.Token { - return nil - } - if status.Requeue { - s.requeueInflight(st, status) - } else { - // The review round is over. If every required bot's feedback - // arrived, the wait is satisfied — clear it here, because in - // autoreview flows no Loop is running to call clearFeedbackWait - // and the entry would linger forever. A bare acknowledgement - // (bot reacted) or an outstanding required bot means reviewing - // is still underway, so that wait stays until feedback lands or - // its deadline expires. - if status.FeedbackComplete { - key := QueueKey(st.InFlight.Repo, st.InFlight.PR) - if wait := st.AwaitingFeedback[key]; wait.Head == st.InFlight.Head { - delete(st.AwaitingFeedback, key) - } - } - st.InFlight = nil - st.Warn = "" - } - return nil - }) - if err != nil { - return PumpResult{}, err - } - s.sync(ctx, updated) - if status.Requeue { - return PumpResult{Action: "requeued", Repo: state.InFlight.Repo, PR: state.InFlight.PR, Reason: status.Reason}, nil - } - return PumpResult{Action: "cleared", Repo: state.InFlight.Repo, PR: state.InFlight.PR, Reason: status.Reason}, nil - } - return PumpResult{Action: "waiting", Repo: state.InFlight.Repo, PR: state.InFlight.PR, Reason: "review in flight"}, nil - } - - state, _, err := s.store.Load(ctx) + now := time.Now().UTC() + st, _, err := s.store.Load(ctx) if err != nil { return PumpResult{}, err } - if pruned := s.pruneExpiredWaits(ctx, state); pruned != nil { - state = *pruned + + // 1. The round holding the fire slot: progress it and return, mirroring v2's + // "handle in-flight first" so a single pump never both progresses and fires. + if slot := st.SlotRound(); slot != nil { + return s.progressSlotRound(ctx, *slot) } - state = s.sweepFeedbackWaits(ctx, state) - queue := state.SortedQueue() - if len(queue) == 0 { + + // 2. Reviewing rounds no longer hold the slot; sweep the oldest one toward + // completion/retry (bounded to one per pump, like v2's feedback sweep). + if updated, err := s.sweepReviewing(ctx, st, now); err != nil { + return PumpResult{}, err + } else { + st = updated + } + + // 3. Fire the next eligible round. + next := st.NextEligible(now) + if next == nil { return PumpResult{Action: "idle"}, nil } - // Terminal PR cleanup is independent of review quota and pacing. Check the - // queue head before either gate so a PR merged while CodeRabbit is blocked (or - // while MinInterval is active) leaves the queue on the next pump instead of - // lingering until another review slot becomes available. - item := queue[0] - if _, open, err := s.pullHead(ctx, item.Repo, item.PR); err != nil { + // Terminal cleanup is independent of quota and pacing: drop a closed/merged + // PR before either gate so it leaves on this pump instead of lingering. + if _, open, err := s.pullHead(ctx, next.Repo, next.PR); err != nil { return PumpResult{}, err } else if !open { - return s.dropClosedQueueItem(ctx, item) + return s.abandonRound(ctx, *next, "pr closed", "skipped") } if refreshed, err := s.RefreshQuota(ctx); err == nil { - state = refreshed + st = refreshed } else { return PumpResult{}, err } - now := time.Now().UTC() - if state.Blocked.BlockedUntil != nil && state.Blocked.BlockedUntil.After(now) { - return PumpResult{Action: "blocked", Reason: state.Blocked.BlockedUntil.Format(time.RFC3339)}, nil + now = time.Now().UTC() + if st.Account.BlockedUntil != nil && st.Account.BlockedUntil.After(now) { + return PumpResult{Action: "blocked", Reason: st.Account.BlockedUntil.Format(time.RFC3339)}, nil } - if state.LastFired != nil && now.Sub(*state.LastFired) < s.cfg.MinInterval { + if st.LastFired != nil && now.Sub(*st.LastFired) < s.cfg.MinInterval { return PumpResult{Action: "min_interval", Reason: s.cfg.MinInterval.String()}, nil } - queue = state.SortedQueue() - if len(queue) == 0 { + next = st.NextEligible(now) + if next == nil { return PumpResult{Action: "idle"}, nil } - item = queue[0] - head, open, err := s.pullHead(ctx, item.Repo, item.PR) + obs, err := s.observe(ctx, next.Repo, next.PR, next, now) if err != nil { return PumpResult{}, err } - if s.cfg.DryRun { - // A dry-run pump only reports the action it would take. Every branch - // below this point mutates persisted state (dropping closed PRs, - // deduping reviewed heads, adopting commands, firing), so simulate the - // same decisions read-only instead of falling through to them. - key := QueueKey(item.Repo, item.PR) - switch { - case !open: - return PumpResult{Action: "skipped", Repo: item.Repo, PR: item.PR, Reason: "pr closed"}, nil - case !isShortSHA(head): - return PumpResult{Action: "skipped", Repo: item.Repo, PR: item.PR, Reason: "could not read head"}, nil - case state.Fired[key] == head || state.AwaitingFeedback[key].Head == head: - return PumpResult{Action: "deduped", Repo: item.Repo, PR: item.PR, Head: head}, nil - } - if reviewed, err := s.botReviewedHead(ctx, item.Repo, item.PR, head); err == nil && reviewed { - return PumpResult{Action: "deduped", Repo: item.Repo, PR: item.PR, Head: head, Reason: "bot already reviewed head"}, nil - } else if err != nil { - return PumpResult{}, err - } - return PumpResult{Action: "dry_run", Repo: item.Repo, PR: item.PR, Head: head}, nil + decision := engine.DecideFire(s.global(st, now), *next, obs, now, s.policy()) + return s.applyFire(ctx, *next, obs, decision, now) +} + +func (s *Service) global(st State, now time.Time) engine.Global { + return engine.Global{ + SlotFree: st.SlotRound() == nil, + BlockedUntil: st.Account.BlockedUntil, + LastFired: st.LastFired, } - if !open { - return s.dropClosedQueueItem(ctx, item) +} + +// progressSlotRound observes and progresses the round holding the fire slot. +func (s *Service) progressSlotRound(ctx context.Context, slot Round) (PumpResult, error) { + now := time.Now().UTC() + st, _, err := s.store.Load(ctx) + if err != nil { + return PumpResult{}, err } - if !isShortSHA(head) { - return PumpResult{Action: "skipped", Repo: item.Repo, PR: item.PR, Reason: "could not read head"}, nil + obs, err := s.observe(ctx, slot.Repo, slot.PR, &slot, now) + if err != nil { + return PumpResult{}, err } - key := QueueKey(item.Repo, item.PR) - pending := state.AwaitingFeedback[key] - if state.Fired[key] == head || pending.Head == head { - deduped := false - updated, err := s.store.Update(ctx, func(st *State) error { - deduped = false - q := st.SortedQueue() - if len(q) == 0 || q[0].Seq != item.Seq { - return ErrNoChange - } - currentPending := st.AwaitingFeedback[key] - if st.Fired[key] != head && currentPending.Head != head { - return ErrNoChange - } - removeQueued(st, item.Seq) - if currentPending.Head == head { - st.Fired[key] = head - } - deduped = true - return nil - }) - if err != nil { - return PumpResult{}, err - } - if !deduped { - return PumpResult{Action: "lost_race"}, nil - } - s.sync(ctx, updated) - return PumpResult{Action: "deduped", Repo: item.Repo, PR: item.PR, Head: head}, nil + tr := engine.Progress(slot, st.Account, obs, now, s.policy()) + if tr.Outcome == engine.KeepWaiting { + return PumpResult{Action: "waiting", Repo: slot.Repo, PR: slot.PR, Reason: tr.Reason}, nil } - if reviewed, err := s.botReviewedHead(ctx, item.Repo, item.PR, head); err == nil && reviewed { - updated, err := s.store.Update(ctx, func(st *State) error { - removeQueued(st, item.Seq) - st.Fired[key] = head - return nil - }) - if err != nil { - return PumpResult{}, err + if s.cfg.DryRun { + return slotResult(slot, tr), nil + } + updated, err := s.store.Update(ctx, func(st *State) error { + r := st.Round(slot.Repo, slot.PR) + if r == nil || st.FireSlot == nil || st.FireSlot.Token != slot.Token { + return ErrNoChange } - s.sync(ctx, updated) - return PumpResult{Action: "deduped", Repo: item.Repo, PR: item.PR, Head: head, Reason: "bot already reviewed head"}, nil - } else if err != nil { + return s.applyTransition(st, r, tr, now) + }) + if err != nil { return PumpResult{}, err } - if existing, ok, err := s.existingReviewCommand(ctx, item.Repo, item.PR, head, item.adoptCutoff()); err != nil { - return PumpResult{}, err - } else if ok { - firedAt := existing.CreatedAt.UTC() - if firedAt.IsZero() { - firedAt = existing.UpdatedAt.UTC() - } - if firedAt.IsZero() { - firedAt = time.Now().UTC() - } - updated, err := s.recordExistingReviewPosted(ctx, item, head, existing.ID, firedAt) - if err != nil { - if errors.Is(err, ErrNoChange) { - return PumpResult{Action: "lost_race"}, nil - } - return PumpResult{}, err - } - s.sync(ctx, updated) - if s.log != nil { - s.log.Printf("fire %s@%s (adopted existing review command)", key, head) + s.sync(ctx, updated) + if s.log != nil && (tr.Outcome == engine.OutRetry || tr.Outcome == engine.OutReleaseSlot) { + blockedUntil := "-" + if updated.Account.BlockedUntil != nil { + blockedUntil = updated.Account.BlockedUntil.UTC().Format(time.RFC3339) } - return PumpResult{Action: "fired", Repo: item.Repo, PR: item.PR, Head: head, Reason: "review command already posted"}, nil + s.log.Printf("requeue %s@%s reason=%q blocked_until=%s", QueueKey(slot.Repo, slot.PR), slot.Head, tr.Reason, blockedUntil) } + return slotResult(slot, tr), nil +} - token := randomToken() - reserved, err := s.store.Update(ctx, func(st *State) error { - // Another worker already holds an in-flight slot, or won the race for this - // queue head (or it was cancelled) since we picked it. These are benign lost - // races, not write conflicts — return ErrNoChange so Update reports lost_race - // rather than failing the loop with "state changed while writing". - if st.InFlight != nil { - return ErrNoChange - } - q := st.SortedQueue() - if len(q) == 0 || q[0].Seq != item.Seq { - return ErrNoChange +// applyTransition applies a fired/reviewing round's engine Transition to state: +// the round transition plus any fire-slot release and account-quota block. +func (s *Service) applyTransition(st *State, r *Round, tr engine.Transition, now time.Time) error { + key := QueueKey(r.Repo, r.PR) + switch tr.Outcome { + case engine.OutComplete: + if err := r.Complete(); err != nil { + return err + } + case engine.OutReviewing: + if err := r.Acknowledge(); err != nil { + return err + } + case engine.OutRetry: + if tr.Blocked != nil { + applyAccountBlock(st, tr.Blocked, now) + } + if err := r.AwaitRetry(tr.RetryAt, tr.Reason, now); err != nil { + return err + } + case engine.OutReleaseSlot: + if err := r.ReleaseToQueue(tr.Reason, now); err != nil { + return err + } + case engine.OutAbandon: + st.EndRound(r.Repo, r.PR, tr.Reason) + releaseSlot(st, key) + return nil + default: + return nil + } + st.PutRound(*r) + releaseSlot(st, key) + return nil +} + +// releaseSlot clears the fire slot when it points at key. +func releaseSlot(st *State, key string) { + if st.FireSlot != nil && st.FireSlot.Key == key { + st.FireSlot = nil + } +} + +// applyAccountBlock ports requeueInflight's rate-limit bookkeeping. The window +// (including same-comment reuse) was resolved by the engine, so only the store +// write happens here. +func applyAccountBlock(st *State, blk *engine.AccountBlock, now time.Time) { + until := blk.Until.UTC() + zero := 0 + st.Account.BlockedUntil = &until + st.Account.Remaining = &zero + st.Account.Source = "warning" + st.Account.CheckedAt = &now + if blk.CommentID != 0 { + st.Account.RLCommentID = blk.CommentID + u := blk.CommentUpdated.UTC() + st.Account.RLCommentUpdated = &u + } + st.Warn = "" +} + +func slotResult(slot Round, tr engine.Transition) PumpResult { + r := PumpResult{Repo: slot.Repo, PR: slot.PR, Head: slot.Head, Reason: tr.Reason} + switch tr.Outcome { + case engine.OutComplete, engine.OutReviewing: + r.Action = "cleared" + case engine.OutRetry, engine.OutReleaseSlot: + r.Action = "requeued" + case engine.OutAbandon: + r.Action = "cleared" + default: + r.Action = "waiting" + } + return r +} + +// sweepReviewing progresses the oldest fired/reviewing round that is not holding +// the fire slot, so a round whose slot was released on a bot ack still reaches +// completion (or parks) without a Loop running. Bounded to one per pump. +func (s *Service) sweepReviewing(ctx context.Context, st State, now time.Time) (State, error) { + if s.cfg.DryRun { + return st, nil + } + var target *Round + for key := range st.Rounds { + r := st.Rounds[key] + if r.Phase != PhaseFired && r.Phase != PhaseReviewing { + continue } - removeQueued(st, item.Seq) - st.InFlight = &InFlight{ - Seq: item.Seq, - Repo: item.Repo, - PR: item.PR, - Head: head, - Token: token, - Phase: "reserved", - ReservedAt: now, - ByHost: s.cfg.Host, + if target == nil || firedOrEnqueuedAt(r).Before(firedOrEnqueuedAt(*target)) { + c := r + target = &c } - return nil - }) - if err != nil { - return PumpResult{}, err } - s.sync(ctx, reserved) - if reserved.InFlight == nil || reserved.InFlight.Token != token { - return PumpResult{Action: "lost_race"}, nil + if target == nil { + return st, nil } - comment, err := s.gh.PostIssueComment(ctx, item.Repo, item.PR, s.cfg.ReviewCommand) + obs, err := s.observe(ctx, target.Repo, target.PR, target, now) if err != nil { - updated, uerr := s.store.Update(ctx, func(st *State) error { - if st.InFlight == nil || st.InFlight.Token != token { - return nil - } - st.Queue = append(st.Queue, item) - st.InFlight = nil - st.Warn = "failed to post review command: " + err.Error() - return nil - }) - if uerr == nil { - s.sync(ctx, updated) + if s.log != nil { + s.log.Printf("warning: reviewing-round sweep for %s#%d failed: %v", target.Repo, target.PR, err) } - return PumpResult{Action: "post_failed", Repo: item.Repo, PR: item.PR, Head: head, Reason: err.Error()}, err + return st, nil } - // Baseline completion detection on the trigger comment's GitHub timestamp, not a - // local clock that may run ahead of GitHub's: a completion landing in the same - // second (or before a fast local clock) would otherwise fail the strict After - // check in inflightStatus and get missed, refiring a duplicate review. - firedAt := comment.CreatedAt.UTC() - if firedAt.IsZero() { - firedAt = time.Now().UTC() + tr := engine.Progress(*target, st.Account, obs, now, s.policy()) + if tr.Outcome == engine.KeepWaiting { + return st, nil } - updated, err := s.markReviewPosted(ctx, token, item, head, comment.ID, firedAt) - if err != nil { - retryCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - updated, err = s.markReviewPosted(retryCtx, token, item, head, comment.ID, firedAt) - if err != nil { - if errors.Is(err, ErrNoChange) { - return PumpResult{Action: "lost_race"}, nil - } - return PumpResult{}, err + updated, err := s.store.Update(ctx, func(st *State) error { + r := st.Round(target.Repo, target.PR) + if r == nil || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) { + return ErrNoChange } + return s.applyTransition(st, r, tr, now) + }) + if err != nil { + return st, err } s.sync(ctx, updated) - if s.log != nil { - s.log.Printf("fire %s@%s (posted %s)", key, head, strings.TrimSpace(s.cfg.ReviewCommand)) + return updated, nil +} + +func firedOrEnqueuedAt(r Round) time.Time { + if r.FiredAt != nil { + return *r.FiredAt + } + return r.EnqueuedAt +} + +// applyFire executes a DecideFire verdict. +func (s *Service) applyFire(ctx context.Context, round Round, obs engine.Observation, d engine.FireDecision, now time.Time) (PumpResult, error) { + switch d.Verdict { + case engine.FireDrop: + return s.abandonRound(ctx, round, "pr closed", "skipped") + case engine.FireDedupe: + return s.dedupeRound(ctx, round, now, d.Reason) + 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, now) + case engine.FirePost: + return s.fireRound(ctx, round, obs, true, 0, time.Time{}, "", now) + default: // FireNo + return PumpResult{Action: mapFireNo(d.Reason), Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: d.Reason}, nil } - return PumpResult{Action: "fired", Repo: item.Repo, PR: item.PR, Head: head}, nil } -// dropClosedQueueItem removes a closed or merged queue entry without consuming -// review readiness. The sequence check makes a concurrent cancel/pump a benign -// lost race instead of writing a no-op state revision. -func (s *Service) dropClosedQueueItem(ctx context.Context, item QueueItem) (PumpResult, error) { - result := PumpResult{Action: "skipped", Repo: item.Repo, PR: item.PR, Reason: "pr closed"} +func mapFireNo(reason string) string { + switch { + case strings.Contains(reason, "could not read head"): + return "skipped" + case strings.Contains(reason, "min interval"): + return "min_interval" + case strings.Contains(reason, "account blocked"): + return "blocked" + case strings.Contains(reason, "fire slot busy"): + return "lost_race" + default: + return "waiting" + } +} + +// abandonRound ends a round (closed/merged PR) without consuming review +// readiness. The existence check makes a concurrent cancel a benign lost race. +func (s *Service) abandonRound(ctx context.Context, round Round, reason, action string) (PumpResult, error) { + result := PumpResult{Action: action, Repo: round.Repo, PR: round.PR, Reason: reason} if s.cfg.DryRun { return result, nil } - removed := false + ended := false updated, err := s.store.Update(ctx, func(st *State) error { - removed = false - for _, queued := range st.Queue { - if queued.Seq == item.Seq { - removeQueued(st, item.Seq) - removed = true - return nil - } + if st.Round(round.Repo, round.PR) == nil { + return ErrNoChange } - return ErrNoChange + st.EndRound(round.Repo, round.PR, reason) + releaseSlot(st, QueueKey(round.Repo, round.PR)) + ended = true + return nil }) if err != nil { return PumpResult{}, err } - if !removed { + if !ended { return PumpResult{Action: "lost_race"}, nil } s.sync(ctx, updated) return result, nil } -func (s *Service) markReviewPosted(ctx context.Context, token string, item QueueItem, head string, commentID int64, firedAt time.Time) (State, error) { - key := QueueKey(item.Repo, item.PR) - recorded := false - state, err := s.store.Update(ctx, func(st *State) error { - recorded = false - if st.InFlight == nil || st.InFlight.Token != token { +// dedupeRound completes a not-yet-fired round because the bot already reviewed +// its head, leaving the completed round as the dedupe marker (v2's Fired[key]). +func (s *Service) dedupeRound(ctx context.Context, round Round, now time.Time, reason string) (PumpResult, error) { + result := PumpResult{Action: "deduped", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason} + if s.cfg.DryRun { + return result, nil + } + deduped := false + updated, err := s.store.Update(ctx, func(st *State) error { + deduped = false + r := st.Round(round.Repo, round.PR) + if r == nil || r.Head != round.Head || !r.FireEligible(now) { return ErrNoChange } - recorded = true - st.InFlight.Phase = "posted" - st.InFlight.FiredAt = &firedAt - st.InFlight.FiredCommentID = commentID - st.LastFired = &firedAt - st.Warn = "" - st.Fired[key] = head - if st.AwaitingFeedback == nil { - st.AwaitingFeedback = map[string]FeedbackWait{} - } - st.AwaitingFeedback[key] = s.newFeedbackWait(item.Repo, item.PR, head, firedAt, commentID) - st.History = append([]HistoryItem{{ - Repo: item.Repo, - PR: item.PR, - Commit: head, - At: firedAt, - Host: s.cfg.Host, - }}, st.History...) - if len(st.History) > 20 { - st.History = st.History[:20] + if err := r.Dedupe(now); err != nil { + return err } + st.PutRound(*r) + deduped = true return nil }) if err != nil { - return State{}, err + return PumpResult{}, err } - if !recorded { - return state, ErrNoChange + if !deduped { + return PumpResult{Action: "lost_race"}, nil } - return state, nil + s.sync(ctx, updated) + return result, nil } -// existingReviewCommand looks for a review command already posted at the PR's -// current head so Pump can adopt it instead of posting a duplicate. notBefore -// bounds adoption: comments created before it (e.g. the stale command left -// behind by a requeued fire) are never adopted — re-adopting one would replay -// the very rate-limit reply or timeout that caused the requeue, looping forever -// without ever posting a fresh command. -func (s *Service) existingReviewCommand(ctx context.Context, repo string, pr int, expectedHead string, notBefore time.Time) (IssueComment, bool, error) { - comments, err := s.gh.ListIssueComments(ctx, repo, pr) - if err != nil { - return IssueComment{}, false, err - } - command := strings.TrimSpace(s.cfg.ReviewCommand) - if command == "" { - return IssueComment{}, false, nil +// supersedeRound retargets a queued round whose live head moved since it was +// enqueued; the fresh round fires on a later pump. +func (s *Service) supersedeRound(ctx context.Context, round Round, head string, now time.Time) (PumpResult, error) { + result := PumpResult{Action: "requeued", Repo: round.Repo, PR: round.PR, Head: head, Reason: "head moved"} + if s.cfg.DryRun || head == "" { + result.Action = "skipped" + return result, nil } - // The head-guard and cutoff lookups below cost REST/GraphQL calls; in the - // common case — no command comment on the PR at all — skip them entirely. - hasCandidate := false - for _, comment := range comments { - if strings.TrimSpace(comment.Body) == command { - hasCandidate = true - break + updated, err := s.store.Update(ctx, func(st *State) error { + r := st.Round(round.Repo, round.PR) + if r == nil || r.Head == head { + return ErrNoChange } - } - if !hasCandidate { - return IssueComment{}, false, nil - } - cutoff := notBefore - pull, err := s.gh.GetPull(ctx, repo, pr) + _, err := st.Supersede(round.Repo, round.PR, head, now) + return err + }) if err != nil { - return IssueComment{}, false, err - } - if pull.Head.SHA != "" { - if shortOID(pull.Head.SHA) != expectedHead { - return IssueComment{}, false, nil - } - commit, err := s.gh.GetCommit(ctx, repo, pull.Head.SHA) - if err != nil { - if _, ok := rateLimitWait(err); ok { - return IssueComment{}, false, err - } - // No head-commit cutoff available (e.g. an unreadable or 404 head): - // skip adoption rather than wedging the queue on this PR — the worst - // case is posting a command that already exists, which is exactly the - // pre-adoption behavior. - return IssueComment{}, false, nil - } - if commit.Committer.Date.After(cutoff) { - cutoff = commit.Committer.Date - } + return PumpResult{}, err } - // A force-push can point the PR at a commit object whose committer date - // predates commands made for an earlier head, so the commit date alone - // is not a safe cutoff — any command older than the last force-push - // belongs to a previous head and must not be adopted. - if fp := s.headForcePushCutoff(ctx, repo, pr); fp.After(cutoff) { - cutoff = fp + s.sync(ctx, updated) + return result, nil +} + +// fireRound posts (or adopts) the review command and records the fire on the +// round, reserving the global slot under compare-and-swap. +func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observation, post bool, adoptID int64, adoptAt time.Time, 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 } - var best IssueComment - var bestAt time.Time - ok := false - for _, comment := range comments { - if strings.TrimSpace(comment.Body) != command { - continue - } - when := comment.CreatedAt - if when.IsZero() { - when = comment.UpdatedAt - } - if !cutoff.IsZero() && when.Before(cutoff) { - continue + token := randomToken() + + if !post { + // Adopt an already-posted command: reserve the slot and record the fire in + // one write (no network post in between). + firedAt := adoptAt.UTC() + if firedAt.IsZero() { + firedAt = now } - if !ok || when.After(bestAt) { - best = comment - bestAt = when - ok = true + recorded := false + updated, err := s.store.Update(ctx, func(st *State) error { + recorded = false + if st.FireSlot != nil { + return ErrNoChange + } + r := st.Round(round.Repo, round.PR) + if r == nil || !r.FireEligible(now) { + return ErrNoChange + } + if err := r.Reserve(token, s.cfg.Host, now); err != nil { + return err + } + if err := r.Fire(adoptID, firedAt); err != nil { + return err + } + lf := firedAt + st.LastFired = &lf + dl := firedAt.Add(s.cfg.FeedbackWaitTimeout) + r.WaitDeadline = &dl + st.Warn = "" + st.FireSlot = &FireSlot{Key: key, Token: token, Since: now} + st.PutRound(*r) + recorded = true + return nil + }) + if err != nil { + return PumpResult{}, err } - } - if !ok { - return IssueComment{}, false, nil - } - // A command the bot has already answered with a review belongs to a - // completed round for an earlier head. No cutoff can prove this case away: - // a regular push has no timestamped head-update event, and the new head's - // committer date can predate an old command (a local commit pushed later). - // Adopting a consumed command would mark the new head fired without ever - // reviewing it — skip adoption instead; the worst case is a duplicate - // command, the pre-adoption behavior. - reviews, err := s.gh.ListReviews(ctx, repo, pr) - if err != nil { - if _, ok := rateLimitWait(err); ok { - return IssueComment{}, false, err + if !recorded { + return PumpResult{Action: "lost_race"}, nil } - return IssueComment{}, false, nil - } - for _, review := range reviews { - if s.isConfiguredBot(review.User.Login) && !review.SubmittedAt.Before(bestAt) { - return IssueComment{}, false, nil + s.sync(ctx, updated) + if s.log != nil { + s.log.Printf("fire %s@%s (adopted existing review command)", key, round.Head) } + return PumpResult{Action: "fired", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil } - if s.reviewCommandHasCompletionReply(comments, reviews, best.ID) { - return IssueComment{}, false, nil - } - return best, ok, nil -} - -// headForcePushCutoff returns when the PR head was last force-pushed, zero if -// unknown or never. Best-effort: on GraphQL failure adoption falls back to the -// commit-date cutoff rather than blocking the pump. -func (s *Service) headForcePushCutoff(ctx context.Context, repo string, pr int) time.Time { - owner, name, found := strings.Cut(repo, "/") - if !found { - return time.Time{} - } - var result struct { - Repository struct { - PullRequest struct { - TimelineItems struct { - Nodes []struct { - CreatedAt time.Time `json:"createdAt"` - } `json:"nodes"` - } `json:"timelineItems"` - } `json:"pullRequest"` - } `json:"repository"` - } - query := `query($owner:String!, $name:String!, $number:Int!) { - repository(owner:$owner, name:$name) { - pullRequest(number:$number) { - timelineItems(itemTypes: HEAD_REF_FORCE_PUSHED_EVENT, last: 1) { - nodes { ... on HeadRefForcePushedEvent { createdAt } } - } - } - } -}` - if err := s.gh.GraphQL(ctx, query, map[string]any{"owner": owner, "name": name, "number": pr}, &result); err != nil { - return time.Time{} - } - nodes := result.Repository.PullRequest.TimelineItems.Nodes - if len(nodes) == 0 { - return time.Time{} - } - return nodes[len(nodes)-1].CreatedAt.UTC() -} -func (s *Service) recordExistingReviewPosted(ctx context.Context, item QueueItem, head string, commentID int64, firedAt time.Time) (State, error) { - key := QueueKey(item.Repo, item.PR) - recorded := false - state, err := s.store.Update(ctx, func(st *State) error { - recorded = false - if st.InFlight != nil { + // Reserve the slot, then post the command. + reserved, err := s.store.Update(ctx, func(st *State) error { + if st.FireSlot != nil { return ErrNoChange } - q := st.SortedQueue() - if len(q) == 0 || q[0].Seq != item.Seq { + r := st.Round(round.Repo, round.PR) + if r == nil || !r.FireEligible(now) { return ErrNoChange } - recorded = true - removeQueued(st, item.Seq) - st.InFlight = &InFlight{ - Seq: item.Seq, - Repo: item.Repo, - PR: item.PR, - Head: head, - Token: randomToken(), - Phase: "posted", - ReservedAt: firedAt, - FiredAt: &firedAt, - FiredCommentID: commentID, - ByHost: firstNonEmpty(item.Host, s.cfg.Host), - } - st.LastFired = &firedAt - st.Warn = "" - st.Fired[key] = head - if st.AwaitingFeedback == nil { - st.AwaitingFeedback = map[string]FeedbackWait{} - } - st.AwaitingFeedback[key] = s.newFeedbackWait(item.Repo, item.PR, head, firedAt, commentID) - st.History = append([]HistoryItem{{ - Repo: item.Repo, - PR: item.PR, - Commit: head, - At: firedAt, - Host: firstNonEmpty(item.Host, s.cfg.Host), - }}, st.History...) - if len(st.History) > 20 { - st.History = st.History[:20] + if err := r.Reserve(token, s.cfg.Host, now); err != nil { + return err } + st.FireSlot = &FireSlot{Key: key, Token: token, Since: now} + st.PutRound(*r) return nil }) if err != nil { - return State{}, err + return PumpResult{}, err } - if !recorded { - return state, ErrNoChange + if reserved.FireSlot == nil || reserved.FireSlot.Token != token { + return PumpResult{Action: "lost_race"}, nil } - return state, nil -} + s.sync(ctx, reserved) -func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, int, error) { - repo = NormalizeRepo(repo) - start := time.Now() - enqueued := false - var lastLog time.Time - var lastFeedbackCheck time.Time - feedbackCheckEvery := queuedFeedbackCheckEvery(s.cfg.PollInterval) - for { - if s.cfg.WaitTimeout > 0 && time.Since(start) > s.cfg.WaitTimeout { - return PumpResult{Action: "timeout", Repo: repo, PR: pr}, 2, nil - } - if !enqueued { - result, err := s.Enqueue(ctx, repo, pr) - if err != nil { - return PumpResult{}, 1, err - } - enqueued = result.Queued || result.AlreadyQueued - if result.Deduped { - state, _, err := s.store.Load(ctx) - if err != nil { - return PumpResult{}, 1, err - } - key := QueueKey(repo, pr) - if state.AwaitingFeedback[key].Head == result.Head { - return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: result.Head}, 3, nil - } - report, err := s.Feedback(ctx, repo, pr) - if err != nil { - return PumpResult{}, 1, err - } - if len(findingsReportedOnHead(report.Findings, report.Head)) > 0 || allReviewed(report.ReviewedBy) { - return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: result.Head}, 3, nil - } - // Older versions could mark a head fired after mistaking carried-over - // review prompts or a faster non-required bot for completion. With no - // active wait or completed required-bot gate, remove that poisoned - // marker and enqueue the real replacement review. - updated, err := s.store.Update(ctx, func(st *State) error { - if st.Fired[key] != result.Head || st.AwaitingFeedback[key].Head == result.Head || st.Contains(repo, pr) { - return ErrNoChange - } - delete(st.Fired, key) - return nil - }) - if err != nil { - return PumpResult{}, 1, err - } - s.sync(ctx, updated) - enqueued = false - continue - } - } - if lastFeedbackCheck.IsZero() || time.Since(lastFeedbackCheck) >= feedbackCheckEvery { - report, err := s.Feedback(ctx, repo, pr) - if err != nil { - return PumpResult{}, 1, err - } - lastFeedbackCheck = time.Now() - // Return current-head findings immediately so the caller can fix locally. - // The queue entry stays active: policy requires holding this head until the - // account slot and every required reviewer finish. - if len(findingsReportedOnHead(report.Findings, report.Head)) > 0 { - if s.log != nil { - s.log.Printf("%s#%d feedback already available on %s; leaving review slot wait", repo, pr, report.Head) - } - return PumpResult{ - Action: "deduped", - Repo: repo, - PR: pr, - Head: report.Head, - Reason: "feedback already available", - }, 3, nil - } - } - 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 - } - if state.InFlight != nil && state.InFlight.Repo == repo && state.InFlight.PR == pr && state.InFlight.Phase == "posted" { - return PumpResult{Action: "fired", Repo: repo, PR: pr, Head: state.InFlight.Head}, 0, nil - } - if !state.Contains(repo, pr) { - head, open, herr := s.pullHead(ctx, repo, pr) - if herr == nil && !open { - // PR was closed/merged and dropped from the queue — nothing to review. - // Return a terminal result so crq loop stops instead of polling forever. - return PumpResult{Action: "skipped", Repo: repo, PR: pr, Reason: "pr closed"}, 2, nil - } - if herr == nil && head != "" && state.Fired[QueueKey(repo, pr)] == head { - return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: head}, 3, nil - } - if result.Action == "fired" && result.Repo == repo && result.PR == pr { - return result, 0, nil + comment, err := s.gh.PostIssueComment(ctx, round.Repo, round.PR, s.cfg.ReviewCommand) + if err != nil { + updated, uerr := s.store.Update(ctx, func(st *State) error { + r := st.Round(round.Repo, round.PR) + if r == nil || r.Token != token { + return ErrNoChange } - enqueued = false - continue - } - if s.log != nil && time.Since(lastLog) >= 30*time.Second { - reason := result.Reason - if reason == "" { - reason = result.Action + if rerr := r.ReleaseToQueue("failed to post review command: "+err.Error(), now); rerr != nil { + return rerr } - s.log.Printf("%s#%d waiting for a review slot — %s (%s elapsed)", repo, pr, reason, time.Since(start).Round(time.Second)) - lastLog = time.Now() + releaseSlot(st, key) + st.Warn = "failed to post review command: " + err.Error() + st.PutRound(*r) + return nil + }) + if uerr == nil { + s.sync(ctx, updated) } - select { - case <-ctx.Done(): - return PumpResult{}, 1, ctx.Err() - case <-time.After(s.cfg.PollInterval): + return PumpResult{Action: "post_failed", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: err.Error()}, err + } + // Baseline the fire on the comment's GitHub timestamp, not a local clock that + // may run ahead of GitHub's — a completion landing in the same second must + // still count against a strict After check. + firedAt := comment.CreatedAt.UTC() + if firedAt.IsZero() { + firedAt = now + } + updated, err := s.recordFire(ctx, round, token, comment.ID, firedAt, now) + if err != nil { + if errors.Is(err, ErrNoChange) { + return PumpResult{Action: "lost_race"}, nil } + return PumpResult{}, err + } + s.sync(ctx, updated) + if s.log != nil { + s.log.Printf("fire %s@%s (posted %s)", key, round.Head, strings.TrimSpace(s.cfg.ReviewCommand)) } + return PumpResult{Action: "fired", Repo: round.Repo, PR: round.PR, Head: round.Head}, nil } -func queuedFeedbackCheckEvery(poll time.Duration) time.Duration { - if poll <= 0 { - return 30 * time.Second +// 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. +func (s *Service) recordFire(ctx context.Context, round Round, token string, commandID int64, 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 { + recorded = false + r := st.Round(round.Repo, round.PR) + if r == nil || st.FireSlot == nil || st.FireSlot.Token != token || r.Token != token { + return ErrNoChange + } + if err := r.Fire(commandID, firedAt); err != nil { + return err + } + lf := firedAt + st.LastFired = &lf + dl := firedAt.Add(s.cfg.FeedbackWaitTimeout) + r.WaitDeadline = &dl + st.Warn = "" + st.PutRound(*r) + recorded = true + return nil + }) + return st, recorded, err + } + st, recorded, err := record(ctx) + if err != nil { + retryCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + st, recorded, err = record(retryCtx) + } + if err != nil { + return State{}, err } - if poll < 30*time.Second { - return poll + if !recorded { + return st, ErrNoChange } - return 30 * time.Second + return st, nil } func (s *Service) Cancel(ctx context.Context, repo string, pr int) error { repo = NormalizeRepo(repo) state, err := s.store.Update(ctx, func(st *State) error { - for i := 0; i < len(st.Queue); i++ { - if st.Queue[i].Repo == repo && st.Queue[i].PR == pr { - st.Queue = append(st.Queue[:i], st.Queue[i+1:]...) - i-- - } - } - if st.InFlight != nil && st.InFlight.Repo == repo && st.InFlight.PR == pr { - st.InFlight = nil + if st.Round(repo, pr) == nil { + return ErrNoChange } - delete(st.Fired, QueueKey(repo, pr)) - delete(st.AwaitingFeedback, QueueKey(repo, pr)) + st.EndRound(repo, pr, "cancelled") + releaseSlot(st, QueueKey(repo, pr)) return nil }) if err != nil { @@ -845,10 +704,6 @@ func (s *Service) Status(ctx context.Context) (State, string, error) { return state, renderDashboard(state, s.cfg), nil } -// warnRateLimited is the inflight requeue reason for a rate-limited fire. It is -// surfaced via the Blocked state, not the sticky Warn field. -const warnRateLimited = "rate limited" - func (s *Service) RefreshQuota(ctx context.Context) (State, error) { state, _, err := s.store.Load(ctx) if err != nil { @@ -860,31 +715,28 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { now := time.Now().UTC() // Honor the freshness shortcut only when the last reading was conclusive. If a // probe is still pending (CalibAskedAt set, no reply yet), keep re-checking so a - // late "rate-limited" reply isn't ignored for the full TTL — which would let Pump - // fire straight into the limit. - if state.Blocked.CalibAskedAt == nil && state.Blocked.CheckedAt != nil && now.Sub(*state.Blocked.CheckedAt) < s.cfg.CalibrationTTL { + // late "rate-limited" reply isn't ignored for the full TTL. + if state.Account.CalibAskedAt == nil && state.Account.CheckedAt != nil && now.Sub(*state.Account.CheckedAt) < s.cfg.CalibrationTTL { return state, nil } - blocked, err := s.readQuota(ctx, s.calibrationIssue(state), now, state.Blocked.CalibAskedAt) + quota, err := s.readQuota(ctx, s.calibrationIssue(state), now, state.Account.CalibAskedAt) if err != nil { return state, err } updated, err := s.store.Update(ctx, func(st *State) error { - if st.Blocked.CalibAskedAt == nil && st.Blocked.CheckedAt != nil && time.Since(*st.Blocked.CheckedAt) < s.cfg.CalibrationTTL { + if st.Account.CalibAskedAt == nil && st.Account.CheckedAt != nil && time.Since(*st.Account.CheckedAt) < s.cfg.CalibrationTTL { return ErrNoChange } - // A fresh calibrate reading replaces the whole Blocked struct; carry the - // rate-limit comment identity over so requeueInflight can still recognise an - // edited comment it already accounted for. - rlID, rlUpdated := st.Blocked.RLCommentID, st.Blocked.RLCommentUpdated - st.Blocked = blocked - if st.Blocked.RLCommentID == 0 { - st.Blocked.RLCommentID = rlID - st.Blocked.RLCommentUpdated = rlUpdated + // A fresh reading replaces the whole quota; carry the rate-limit comment + // identity over so the engine can still recognise an edited comment it + // already accounted for. + rlID, rlUpdated := st.Account.RLCommentID, st.Account.RLCommentUpdated + st.Account = quota + if st.Account.RLCommentID == 0 { + st.Account.RLCommentID = rlID + st.Account.RLCommentUpdated = rlUpdated } - // Clear a stale "rate limited" warning once the window has passed, so the - // dashboard can't show both "not currently limited" and a rate-limit warn. - if st.Warn == warnRateLimited && (blocked.BlockedUntil == nil || !blocked.BlockedUntil.After(now)) { + if st.Warn == warnRateLimited && (quota.BlockedUntil == nil || !quota.BlockedUntil.After(now)) { st.Warn = "" } return nil @@ -896,7 +748,7 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { return updated, nil } -// calibrationIssue returns the calibration issue crq should probe: the rotated +// calibrationIssue returns the calibration issue to probe: the rotated // replacement recorded in state after a cap wedge, or the configured one. func (s *Service) calibrationIssue(state State) int { if state.CalibrationIssue > 0 { @@ -907,12 +759,9 @@ func (s *Service) calibrationIssue(state State) int { const calibrationIssueBody = "crq probes CodeRabbit's account-wide rate-limit state here with `@coderabbitai rate limit` so it never spends a real review to calibrate. Auto-created after a prior calibration thread hit GitHub's 2500-comment cap. Managed by crq — safe to leave alone." -// rotateCalibration creates a fresh calibration issue in the gate repo and -// records its number in the shared state, so the whole fleet abandons a -// calibration thread that hit GitHub's hard 2500-comment cap. A capped thread is -// permanently unpostable — pruning can't recover it once every deletable comment -// is already gone — which otherwise wedges quota calibration and floods the log -// with 403s. Returns the new issue number. +// rotateCalibration creates a fresh calibration issue and records its number in +// the shared state so the whole fleet abandons a thread that hit GitHub's hard +// 2500-comment cap. func (s *Service) rotateCalibration(ctx context.Context, oldIssue int) (int, error) { issue, err := s.gh.CreateIssue(ctx, s.cfg.GateRepo, "crq rate-limit calibration", calibrationIssueBody) if err != nil { @@ -936,32 +785,31 @@ func (s *Service) rotateCalibration(ctx context.Context, oldIssue int) (int, err return issue.Number, nil } -func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendingAsked *time.Time) (Blocked, error) { - blocked := Blocked{Scope: strings.Join(s.cfg.Scope, ","), Source: "calibrate", CheckedAt: &now} +func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendingAsked *time.Time) (AccountQuota, error) { + quota := AccountQuota{Scope: strings.Join(s.cfg.Scope, ","), Source: "calibrate", CheckedAt: &now} cutoff := now.Add(-s.cfg.CalibrationTTL) keepAfter := now.Add(-2 * s.cfg.CalibrationTTL) if reply, ok, err := s.latestCalibrationReply(ctx, issue, cutoff); err != nil { - return blocked, err + return quota, err } else if ok { remaining, reset := parseQuota(reply.Body, reply.UpdatedAt) - blocked.Remaining = remaining - blocked.BlockedUntil = reset + quota.Remaining = remaining + quota.BlockedUntil = reset s.pruneCalibration(ctx, issue, keepAfter, 80) - return blocked, nil + return quota, nil } - // A probe from a previous call is still pending and not yet stale, and the check - // above found no reply to it yet: keep waiting for its (possibly late) reply - // instead of posting another probe every cycle. + // A probe from a previous call is still pending and not yet stale, and no + // reply to it was found: keep waiting for its (possibly late) reply instead of + // posting another probe every cycle. if pendingAsked != nil && pendingAsked.After(cutoff) { - blocked.CalibAskedAt = pendingAsked - return blocked, nil + quota.CalibAskedAt = pendingAsked + return quota, nil } asked, err := s.gh.PostIssueComment(ctx, s.cfg.GateRepo, issue, s.cfg.RateLimitCommand) if err != nil { - // The calibration thread hit GitHub's 2500-comment cap. Prune our old probe - // comments and retry once; if pruning can't drop us back under the cap (all - // deletable comments are already gone), rotate to a fresh issue and retry - // there instead of failing every cycle. + // The calibration thread hit GitHub's 2500-comment cap. Prune old probe + // comments and retry once; if pruning can't drop under the cap, rotate to a + // fresh issue and retry there instead of failing every cycle. if isCommentCapError(err) { if pruned := s.pruneCalibration(ctx, issue, keepAfter, 100); pruned > 0 { asked, err = s.gh.PostIssueComment(ctx, s.cfg.GateRepo, issue, s.cfg.RateLimitCommand) @@ -979,37 +827,34 @@ func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendi if s.log != nil { s.log.Printf("calibration probe on #%d failed: %v", issue, err) } - return blocked, err + return quota, err } } - blocked.CalibAskedAt = &asked.CreatedAt + quota.CalibAskedAt = &asked.CreatedAt for i := 0; i < 6; i++ { select { case <-ctx.Done(): - return blocked, ctx.Err() + return quota, ctx.Err() case <-time.After(2 * time.Second): } reply, ok, err := s.latestCalibrationReply(ctx, issue, asked.CreatedAt.Add(-time.Second)) if err != nil { - return blocked, err + return quota, err } if ok { remaining, reset := parseQuota(reply.Body, reply.UpdatedAt) - blocked.Remaining = remaining - blocked.BlockedUntil = reset - blocked.CalibAskedAt = nil + quota.Remaining = remaining + quota.BlockedUntil = reset + quota.CalibAskedAt = nil s.pruneCalibration(ctx, issue, keepAfter, 80) - return blocked, nil + return quota, nil } } - return blocked, nil + return quota, nil } // pruneCalibration deletes crq's old calibration probe comments and CodeRabbit's -// replies from the calibration PR so it never reaches GitHub's hard 2500-comment -// cap (which silently wedges the whole queue). It reads only the oldest page and -// deletes up to max comments older than keepAfter, so cost stays bounded and the -// most recent reading is preserved. +// replies so the thread never reaches GitHub's hard 2500-comment cap. func (s *Service) pruneCalibration(ctx context.Context, issue int, keepAfter time.Time, max int) int { if issue <= 0 || max <= 0 { return 0 @@ -1081,91 +926,11 @@ func (s *Service) isConfiguredBot(login string) bool { return normalizeBotName(login) == normalizeBotName(s.cfg.Bot) } -type inflightCheck struct { - Done bool - Requeue bool - Reason string - BlockedUntil *time.Time - // FeedbackComplete is set on Done when every required bot has submitted a - // review for the fired round, so Pump knows whether the feedback wait is - // satisfied or must survive for the bots that are still reviewing. - FeedbackComplete bool - // RLCommentID/RLCommentUpdated identify the rate-limit comment that produced a - // warnRateLimited requeue, so requeueInflight can tell a fresh rate-limit event - // from a re-observation of the same edited comment. - RLCommentID int64 - RLCommentUpdated time.Time -} - -// Done reasons from inflightStatus. Pump distinguishes them: a submitted -// review or bot comment means feedback arrived, while a bare reaction only -// acknowledges the command — the review is still in progress. -const ( - doneReviewSubmitted = "review submitted" - doneBotReacted = "bot reacted" - doneBotComment = "bot comment" - doneAlreadyReviewed = "head already reviewed" -) - // notBefore reports whether t is at or after baseline. GitHub timestamps are // second-granular, so a bot completion in the same second as the trigger must // still count — a strict After would miss it and refire a duplicate review. func notBefore(t, baseline time.Time) bool { return !t.Before(baseline) } -// requiredFeedbackComplete reports whether every bot in CRQ_REQUIRED_BOTS has -// submitted a review for the fired round. It mirrors what flips Feedback's -// ReviewedBy — submitted reviews matching the fired head — so Pump never drops -// a wait that Feedback would still report as waiting on another required bot. -// With no required bots configured, the configured reviewer's response that -// produced the Done is all there is to wait for. -func (s *Service) requiredFeedbackComplete(inf *InFlight, reviews []Review, comments []IssueComment) bool { - if len(s.cfg.RequiredBots) == 0 { - return true - } - return s.feedbackCompleteForRound(reviews, comments, s.cfg.RequiredBots, inf.Head, *inf.FiredAt) -} - -// botsReviewedHead reports whether every bot in the set has reviewed head — -// the same check Feedback uses before flipping ReviewedBy: a review whose -// commit matches the fired head counts regardless of when it was submitted, -// because a required bot may have reviewed the commit before this round was -// even triggered. Only when there is no head to match does the submission -// time (at/after since) gate the round, so a review for some other push never -// counts toward it. -func botsReviewedHead(reviews []Review, bots map[string]struct{}, head string, since time.Time) bool { - return allReviewed(reviewedByForRound(reviews, bots, head, since)) -} - -func (s *Service) feedbackCompleteForRound(reviews []Review, comments []IssueComment, awaited []string, head string, since time.Time) bool { - reviewedBy := reviewedByForRound(reviews, botSet(awaited), head, since) - if needsConfiguredBotReview(reviewedBy, s.cfg.Bot) && s.completionReplyForFiredCommand(comments, reviews, since) { - markReviewed(reviewedBy, s.cfg.Bot) - } - return allReviewed(reviewedBy) -} - -func reviewedByForRound(reviews []Review, bots map[string]struct{}, head string, since time.Time) map[string]bool { - reviewedBy := map[string]bool{} - for bot := range bots { - reviewedBy[bot] = false - } - for _, review := range reviews { - if !inBots(bots, review.User.Login) { - continue - } - if head != "" { - if strings.HasPrefix(review.CommitID, head) { - markReviewed(reviewedBy, review.User.Login) - } - continue - } - if notBefore(review.SubmittedAt, since) { - markReviewed(reviewedBy, review.User.Login) - } - } - return reviewedBy -} - func allReviewed(reviewedBy map[string]bool) bool { for _, reviewed := range reviewedBy { if !reviewed { @@ -1175,298 +940,6 @@ func allReviewed(reviewedBy map[string]bool) bool { return true } -func (s *Service) inflightStatus(ctx context.Context, state State) (inflightCheck, error) { - inf := state.InFlight - if inf == nil { - return inflightCheck{Done: true, Reason: "none"}, nil - } - if inf.Phase == "reserved" && time.Since(inf.ReservedAt) > 2*time.Minute { - return inflightCheck{Requeue: true, Reason: "reserved review was never posted"}, nil - } - if inf.FiredAt == nil { - return inflightCheck{}, nil - } - comments, err := s.gh.ListIssueComments(ctx, inf.Repo, inf.PR) - if err != nil { - return inflightCheck{}, err - } - reviews, err := s.gh.ListReviews(ctx, inf.Repo, inf.PR) - if err != nil { - return inflightCheck{}, err - } - // CodeRabbit can post the "does not re-review already reviewed commits" - // boilerplate after a rate-limited first attempt, even though no review exists. - // Treat that acknowledgement as terminal only when GitHub has the evidence it - // claims: a configured-bot review matching this fired head. Without that proof, - // a co-occurring rate-limit notice must requeue the PR for a real later attempt. - alreadyReviewedAck := false - for _, comment := range comments { - if !s.isConfiguredBot(comment.User.Login) || comment.UpdatedAt.Before(*inf.FiredAt) { - continue - } - if s.isReviewAlreadyDone(comment.Body) { - alreadyReviewedAck = true - break - } - } - for _, review := range reviews { - if !s.isConfiguredBot(review.User.Login) || !reviewMatchesRound(review, inf.Head, *inf.FiredAt) { - continue - } - reason := doneReviewSubmitted - if alreadyReviewedAck { - reason = doneAlreadyReviewed - } - return inflightCheck{Done: true, Reason: reason, FeedbackComplete: s.requiredFeedbackComplete(inf, reviews, comments)}, nil - } - for _, comment := range comments { - if !s.isConfiguredBot(comment.User.Login) || comment.UpdatedAt.Before(*inf.FiredAt) { - continue - } - if s.isRateLimited(comment.Body) { - reset := parseAvailableIn(comment.Body, comment.UpdatedAt) - return inflightCheck{Requeue: true, Reason: warnRateLimited, BlockedUntil: reset, RLCommentID: comment.ID, RLCommentUpdated: comment.UpdatedAt}, nil - } - } - if inf.FiredCommentID != 0 { - reactions, err := s.gh.ListCommentReactions(ctx, inf.Repo, inf.FiredCommentID) - if err != nil { - // Don't treat a transient/rate-limited reactions failure as "no reaction": - // that can misclassify an acknowledged review as timed out and refire it. - return inflightCheck{}, err - } - for _, reaction := range reactions { - if s.isConfiguredBot(reaction.User.Login) { - return inflightCheck{Done: true, Reason: doneBotReacted}, nil - } - } - } - for _, comment := range comments { - if s.isConfiguredBot(comment.User.Login) && comment.ID != inf.FiredCommentID && notBefore(comment.UpdatedAt, *inf.FiredAt) && !s.isRateLimited(comment.Body) && !s.isReviewsPaused(comment.Body) && !s.isReviewAlreadyDone(comment.Body) { - return inflightCheck{Done: true, Reason: doneBotComment, FeedbackComplete: s.requiredFeedbackComplete(inf, reviews, comments)}, nil - } - } - if time.Since(*inf.FiredAt) > s.cfg.InflightTimeout { - return inflightCheck{Requeue: true, Reason: "in-flight timeout"}, nil - } - return inflightCheck{}, nil -} - -// reviewMatchesRound reports whether a submitted review is evidence for this -// fired round. A known head must match the review commit; submission time alone -// could otherwise let a delayed review of an older head complete the new one. -func reviewMatchesRound(review Review, head string, firedAt time.Time) bool { - if head != "" { - return strings.HasPrefix(review.CommitID, head) - } - return notBefore(review.SubmittedAt, firedAt) -} - -// pruneExpiredWaits drops AwaitingFeedback entries whose deadline has passed. -// A wait can outlive its review round — a crashed loop, or an autoreview fire -// whose in-flight slot was released on a bot reaction before the review was -// submitted — and nothing else removes it. Any loop resumed after pruning -// reconstructs its start from History and times out immediately, so no wait -// gets a fresh clock out of this. Returns the updated state, or nil if nothing -// was pruned. -func (s *Service) pruneExpiredWaits(ctx context.Context, state State) *State { - if s.cfg.DryRun { - return nil - } - now := time.Now().UTC() - stale := false - for _, wait := range state.AwaitingFeedback { - if !wait.Deadline.IsZero() && now.After(wait.Deadline) { - stale = true - break - } - } - if !stale { - return nil - } - updated, err := s.store.Update(ctx, func(st *State) error { - changed := false - for key, wait := range st.AwaitingFeedback { - if !wait.Deadline.IsZero() && now.After(wait.Deadline) { - delete(st.AwaitingFeedback, key) - changed = true - } - } - if !changed { - return ErrNoChange - } - return nil - }) - if err != nil { - if s.log != nil { - s.log.Printf("warning: failed to prune expired feedback waits: %v", err) - } - return nil - } - s.sync(ctx, updated) - return &updated -} - -// sweepFeedbackWaits checks at most one lingering feedback wait per pump — the -// oldest — against the PR's submitted reviews, and clears it once every awaited -// bot (CRQ_REQUIRED_BOTS, or the configured reviewer when none are set) has -// reviewed the fired head. This is what finishes a round whose in-flight slot -// was released on a bare bot reaction: once InFlight is nil no Pump path runs -// inflightStatus again, and without a Loop nothing else would clear the wait -// before the deadline prune. One wait per pump bounds the sweep's use of the -// shared REST quota; the deadline prune stays the backstop for rounds whose -// feedback never becomes a head-matched review. -func (s *Service) sweepFeedbackWaits(ctx context.Context, state State) State { - if s.cfg.DryRun || len(state.AwaitingFeedback) == 0 { - return state - } - var oldest FeedbackWait - found := false - for _, wait := range state.AwaitingFeedback { - if wait.Head == "" { - continue - } - if !found || wait.StartedAt.Before(oldest.StartedAt) { - oldest = wait - found = true - } - } - if !found { - return state - } - reviews, err := s.gh.ListReviews(ctx, oldest.Repo, oldest.PR) - if err != nil { - if s.log != nil { - s.log.Printf("warning: feedback wait sweep for %s#%d failed: %v", oldest.Repo, oldest.PR, err) - } - return state - } - awaited := s.cfg.RequiredBots - if len(awaited) == 0 { - awaited = []string{s.cfg.Bot} - } - reviewedBy := reviewedByForRound(reviews, botSet(awaited), oldest.Head, oldest.StartedAt) - if allReviewed(reviewedBy) { - s.clearFeedbackWait(ctx, oldest.Repo, oldest.PR, oldest.Head) - if updated, _, err := s.store.Load(ctx); err == nil { - return updated - } - return state - } - if !needsConfiguredBotReview(reviewedBy, s.cfg.Bot) { - return state - } - comments, err := s.gh.ListIssueComments(ctx, oldest.Repo, oldest.PR) - if err != nil { - if s.log != nil { - s.log.Printf("warning: feedback wait completion sweep for %s#%d failed: %v", oldest.Repo, oldest.PR, err) - } - return state - } - if !s.completionReplyForFiredCommand(comments, reviews, oldest.StartedAt) { - return state - } - s.clearFeedbackWait(ctx, oldest.Repo, oldest.PR, oldest.Head) - if updated, _, err := s.store.Load(ctx); err == nil { - return updated - } - return state -} - -func (s *Service) requeueInflight(st *State, status inflightCheck) { - if st.InFlight == nil { - return - } - inf := *st.InFlight - now := time.Now().UTC() - st.Queue = append(st.Queue, QueueItem{ - Seq: inf.Seq, - Owner: ownerOf(inf.Repo), - Repo: inf.Repo, - PR: inf.PR, - Host: inf.ByHost, - // The command comment from the abandoned fire is still on the PR and - // still newer than the head commit; RequeuedAt keeps the next fire from - // adopting it (see existingReviewCommand). - EnqueuedAt: now, - RequeuedAt: &now, - }) - sort.Slice(st.Queue, func(i, j int) bool { return st.Queue[i].Seq < st.Queue[j].Seq }) - key := QueueKey(inf.Repo, inf.PR) - delete(st.Fired, key) - delete(st.AwaitingFeedback, key) - st.InFlight = nil - if status.Reason == warnRateLimited { - // A rate limit is shown by the Blocked state (the dashboard's Rate-limit - // row), not a sticky Warn — otherwise once the window passes the table says - // "not currently limited" while a stale "rate limited" warning lingers. - until := status.BlockedUntil - // CodeRabbit edits one rate-limit comment in place, so a later fire sees the - // same comment with an advanced UpdatedAt. Don't let that re-observation - // extend the block on every bounce: if this is the comment that already set - // the standing block and its window is still open, keep the existing window. - sameComment := status.RLCommentID != 0 && status.RLCommentID == st.Blocked.RLCommentID - if sameComment && st.Blocked.BlockedUntil != nil && st.Blocked.BlockedUntil.After(now) { - until = st.Blocked.BlockedUntil - } - if until == nil || !until.After(now) { - // No parseable "available in" window: back off a conservative fixed - // interval instead of a short re-calibrate, so an unrecognised phrasing - // can't drop us into a couple-of-minutes retry against the shared quota. - t := now.Add(s.rateLimitFallback()) - until = &t - } - zero := 0 - st.Blocked.BlockedUntil = until - st.Blocked.Remaining = &zero - st.Blocked.Source = "warning" - st.Blocked.CheckedAt = &now - if status.RLCommentID != 0 { - st.Blocked.RLCommentID = status.RLCommentID - u := status.RLCommentUpdated - st.Blocked.RLCommentUpdated = &u - } - st.Warn = "" - // Per-head cooldown that survives this requeue: needsReview refuses to - // re-enqueue inf.Head until the window passes. Fired is cleared above so a - // genuinely throttled PR can still retry once the window clears, and this - // cooldown is what keeps that gap from letting the same head be re-fired - // before then — the guard whose absence let one head fire seven times. - setCooldown(st, key, inf.Head, *until) - } else { - st.Warn = status.Reason - } - if s.log != nil { - blockedUntil := "-" - if st.Blocked.BlockedUntil != nil { - blockedUntil = st.Blocked.BlockedUntil.UTC().Format(time.RFC3339) - } - s.log.Printf("requeue %s@%s reason=%q blocked_until=%s", key, inf.Head, status.Reason, blockedUntil) - } -} - -// rateLimitFallback is the block window applied when a rate-limit comment carries -// no parseable "available in" duration. CodeRabbit's real windows run to tens of -// minutes, so a conservative floor is far safer than retrying in a minute or two. -func (s *Service) rateLimitFallback() time.Duration { - if s.cfg.RateLimitFallback > 0 { - return s.cfg.RateLimitFallback - } - return 15 * time.Minute -} - -// setCooldown records a per-head fire cooldown, ignoring an empty head or an -// already-passed deadline so the map never carries dead entries. -func setCooldown(st *State, key, head string, until time.Time) { - if head == "" || !until.After(time.Now().UTC()) { - return - } - if st.Cooldown == nil { - st.Cooldown = map[string]FireCooldown{} - } - st.Cooldown[strings.ToLower(key)] = FireCooldown{Head: head, Until: until} -} - func (s *Service) headShort(ctx context.Context, repo string, pr int) (string, error) { pull, err := s.gh.GetPull(ctx, repo, pr) if err != nil { @@ -1479,9 +952,8 @@ func (s *Service) headShort(ctx context.Context, repo string, pr int) (string, e } // pullHead returns the PR's short head SHA and whether it is still open (neither -// closed nor merged). Pump uses it so a PR that was closed or merged after it was -// queued is dropped instead of having a review fired at a dead PR — which would -// never converge, time out, and requeue forever, wasting the shared slot. +// closed nor merged), so a PR closed after it was queued is dropped instead of +// firing a review at a dead PR. func (s *Service) pullHead(ctx context.Context, repo string, pr int) (head string, open bool, err error) { pull, err := s.gh.GetPull(ctx, repo, pr) if err != nil { @@ -1497,19 +969,6 @@ func (s *Service) pullHead(ctx context.Context, repo string, pr int) (head strin return pull.Head.SHA[:9], open, nil } -func (s *Service) botReviewedHead(ctx context.Context, repo string, pr int, head string) (bool, error) { - reviews, err := s.gh.ListReviews(ctx, repo, pr) - if err != nil { - return false, err - } - for _, review := range reviews { - if normalizeBotName(review.User.Login) == normalizeBotName(s.cfg.Bot) && strings.HasPrefix(review.CommitID, head) { - return true, nil - } - } - return false, nil -} - func (s *Service) sync(ctx context.Context, state State) { if s.log == nil || s.cfg.DashboardIssue <= 0 { return @@ -1519,27 +978,6 @@ func (s *Service) sync(ctx context.Context, state State) { } } -func removeQueued(st *State, seq int64) { - for i := range st.Queue { - if st.Queue[i].Seq == seq { - st.Queue = append(st.Queue[:i], st.Queue[i+1:]...) - return - } - } -} - -func isShortSHA(value string) bool { - if len(value) < 7 || len(value) > 40 { - return false - } - for _, r := range value { - if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { - return false - } - } - return true -} - func randomToken() string { var buf [16]byte if _, err := io.ReadFull(rand.Reader, buf[:]); err != nil { @@ -1548,10 +986,9 @@ func randomToken() string { return hex.EncodeToString(buf[:]) } -// The CodeRabbit comment classifiers live in internal/dialect; these -// forwarders keep call sites and tests stable during the refactor. -func (s *Service) isRateLimited(body string) bool { return s.cr.IsRateLimited(body) } - +// The CodeRabbit comment classifiers live in internal/dialect; these forwarders +// keep call sites and tests stable during the refactor. +func (s *Service) isRateLimited(body string) bool { return s.cr.IsRateLimited(body) } func (s *Service) isReviewsPaused(body string) bool { return s.cr.IsReviewsPaused(body) } func (s *Service) isReviewAlreadyDone(body string) bool { return s.cr.IsReviewAlreadyDone(body) } @@ -1565,3 +1002,126 @@ func isCommentCapError(err error) bool { b := strings.ToLower(api.Body) return strings.Contains(b, "commenting is disabled") || strings.Contains(b, "more than 2500 comments") } + +// Wait enqueues repo#pr and pumps until a review fires for its head (code 0), +// current-head feedback is already available (code 3), the wait times out (code +// 2), or the PR is closed (code 2). It is kept compiling on round state via the +// v2→v3 shims; stage 4b rewrites it. +func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, int, error) { + repo = NormalizeRepo(repo) + start := time.Now() + enqueued := false + var lastLog time.Time + var lastFeedbackCheck time.Time + feedbackCheckEvery := queuedFeedbackCheckEvery(s.cfg.PollInterval) + for { + if s.cfg.WaitTimeout > 0 && time.Since(start) > s.cfg.WaitTimeout { + return PumpResult{Action: "timeout", Repo: repo, PR: pr}, 2, nil + } + if !enqueued { + result, err := s.Enqueue(ctx, repo, pr) + if err != nil { + return PumpResult{}, 1, err + } + enqueued = result.Queued || result.AlreadyQueued + if result.Deduped { + state, _, err := s.store.Load(ctx) + if err != nil { + return PumpResult{}, 1, err + } + if waitView(&state, repo, pr).Head == result.Head { + return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: result.Head}, 3, nil + } + report, err := s.Feedback(ctx, repo, pr) + if err != nil { + return PumpResult{}, 1, err + } + if len(findingsReportedOnHead(report.Findings, report.Head)) > 0 || allReviewed(report.ReviewedBy) { + return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: result.Head}, 3, nil + } + // A completed round at this head with no real head review is a poisoned + // dedup marker (a mistaken completion). Drop it and enqueue the real + // replacement review. + updated, err := s.store.Update(ctx, func(st *State) error { + r := st.Round(repo, pr) + if r == nil || r.Head != result.Head || r.Phase != PhaseCompleted { + return ErrNoChange + } + st.EndRound(repo, pr, "repair: completed head lacked a real review") + return nil + }) + if err != nil { + return PumpResult{}, 1, err + } + s.sync(ctx, updated) + enqueued = false + continue + } + } + if lastFeedbackCheck.IsZero() || time.Since(lastFeedbackCheck) >= feedbackCheckEvery { + report, err := s.Feedback(ctx, repo, pr) + if err != nil { + return PumpResult{}, 1, err + } + lastFeedbackCheck = time.Now() + // Return current-head findings immediately so the caller can fix locally. + // The round stays active: policy holds this head until the slot and every + // required reviewer finish. + if len(findingsReportedOnHead(report.Findings, report.Head)) > 0 { + if s.log != nil { + s.log.Printf("%s#%d feedback already available on %s; leaving review slot wait", repo, pr, report.Head) + } + return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: report.Head, Reason: "feedback already available"}, 3, nil + } + } + 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 + } + if r := state.Round(repo, pr); r != nil && r.Phase == PhaseFired { + return PumpResult{Action: "fired", Repo: repo, PR: pr, Head: r.Head}, 0, nil + } + if !containsActive(&state, repo, pr) { + head, open, herr := s.pullHead(ctx, repo, pr) + if herr == nil && !open { + // PR closed/merged and dropped — nothing to review; stop the loop. + return PumpResult{Action: "skipped", Repo: repo, PR: pr, Reason: "pr closed"}, 2, nil + } + if herr == nil && head != "" && firedMarker(&state, repo, pr) == head { + return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: head}, 3, nil + } + if result.Action == "fired" && result.Repo == repo && result.PR == pr { + return result, 0, nil + } + enqueued = false + continue + } + if s.log != nil && time.Since(lastLog) >= 30*time.Second { + reason := result.Reason + if reason == "" { + reason = result.Action + } + s.log.Printf("%s#%d waiting for a review slot — %s (%s elapsed)", repo, pr, reason, time.Since(start).Round(time.Second)) + lastLog = time.Now() + } + select { + case <-ctx.Done(): + return PumpResult{}, 1, ctx.Err() + case <-time.After(s.cfg.PollInterval): + } + } +} + +func queuedFeedbackCheckEvery(poll time.Duration) time.Duration { + if poll <= 0 { + return 30 * time.Second + } + if poll < 30*time.Second { + return poll + } + return 30 * time.Second +} diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 385bfd6..fd75c0b 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -31,96 +31,6 @@ type fakeGitHub struct { searchPRs []SearchPR } -type failNthUpdateStore struct { - StateStore - n int - err error - calls int -} - -func (s *failNthUpdateStore) Update(ctx context.Context, mutate func(*State) error) (State, error) { - s.calls++ - if s.calls == s.n { - return State{}, s.err - } - return s.StateStore.Update(ctx, mutate) -} - -type retryNoChangeStore struct{} - -func (retryNoChangeStore) Load(context.Context) (State, Revision, error) { - return DefaultState(Config{}), Revision{}, nil -} - -func (retryNoChangeStore) Update(_ context.Context, mutate func(*State) error) (State, error) { - first := DefaultState(Config{}) - first.InFlight = &InFlight{Token: "token"} - if err := mutate(&first); err != nil { - return State{}, err - } - second := DefaultState(Config{}) - if err := mutate(&second); err != nil { - if errors.Is(err, ErrNoChange) { - return second, nil - } - return State{}, err - } - return second, nil -} - -func (retryNoChangeStore) SyncDashboard(context.Context, State) error { return nil } - -type adoptionRaceStore struct { - cfg Config - loadState State -} - -func (s *adoptionRaceStore) Load(context.Context) (State, Revision, error) { - state := cloneState(s.loadState) - state.Normalize(s.cfg) - return state, Revision{}, nil -} - -func (s *adoptionRaceStore) Update(_ context.Context, mutate func(*State) error) (State, error) { - state := DefaultState(s.cfg) - state.InFlight = &InFlight{Repo: "owner/repo", PR: 12, Head: "abcdef123", Token: "other", Phase: "posted"} - if err := mutate(&state); err != nil { - if errors.Is(err, ErrNoChange) { - return state, nil - } - return State{}, err - } - return state, nil -} - -func (s *adoptionRaceStore) SyncDashboard(context.Context, State) error { return nil } - -type staleDedupeStore struct { - cfg Config - loadState State - updateState State -} - -func (s *staleDedupeStore) Load(context.Context) (State, Revision, error) { - state := cloneState(s.loadState) - state.Normalize(s.cfg) - return state, Revision{}, nil -} - -func (s *staleDedupeStore) Update(_ context.Context, mutate func(*State) error) (State, error) { - state := cloneState(s.updateState) - state.Normalize(s.cfg) - if err := mutate(&state); err != nil { - if errors.Is(err, ErrNoChange) { - return state, nil - } - return State{}, err - } - return state, nil -} - -func (s *staleDedupeStore) SyncDashboard(context.Context, State) error { return nil } - func newFakeGitHub() *fakeGitHub { return &fakeGitHub{ pulls: map[string]Pull{}, @@ -269,6 +179,145 @@ func (f *fakeGitHub) GraphQL(_ context.Context, query string, vars map[string]an return errors.New("graphql unavailable") } +// --- test store fakes (v3) --- + +type failNthUpdateStore struct { + StateStore + n int + err error + calls int +} + +func (s *failNthUpdateStore) Update(ctx context.Context, mutate func(*State) error) (State, error) { + s.calls++ + if s.calls == s.n { + return State{}, s.err + } + return s.StateStore.Update(ctx, mutate) +} + +// retryNoChangeStore invokes the mutate closure twice within one Update: first +// against a state whose round holds the fire slot, then a fresh state with no +// round. It verifies recordFire resets its `recorded` flag between attempts. +type retryNoChangeStore struct{ cfg Config } + +func (retryNoChangeStore) Load(context.Context) (State, Revision, error) { + return DefaultState(Config{}), Revision{}, nil +} + +func (s retryNoChangeStore) Update(_ context.Context, mutate func(*State) error) (State, error) { + first := DefaultState(s.cfg) + firstRound, _ := first.NewRound("owner/repo", 12, "abcdef123", time.Now().UTC()) + _ = firstRound.Reserve("token", "host", time.Now().UTC()) + first.PutRound(*firstRound) + first.FireSlot = &FireSlot{Key: QueueKey("owner/repo", 12), Token: "token", Since: time.Now().UTC()} + if err := mutate(&first); err != nil { + return State{}, err + } + second := DefaultState(s.cfg) + if err := mutate(&second); err != nil { + if errors.Is(err, ErrNoChange) { + return second, nil + } + return State{}, err + } + return second, nil +} + +func (retryNoChangeStore) SyncDashboard(context.Context, State) error { return nil } + +// adoptionRaceStore loads a queued round with an adoptable command, but every +// Update simulates another worker already holding the fire slot. +type adoptionRaceStore struct { + cfg Config + loadState State +} + +func (s *adoptionRaceStore) Load(context.Context) (State, Revision, error) { + state := cloneState(s.loadState) + state.Normalize(time.Now().UTC()) + return state, Revision{}, nil +} + +func (s *adoptionRaceStore) Update(_ context.Context, mutate func(*State) error) (State, error) { + state := cloneState(s.loadState) + state.FireSlot = &FireSlot{Key: "owner/repo#99", Token: "other", Since: time.Now().UTC()} + if err := mutate(&state); err != nil { + if errors.Is(err, ErrNoChange) { + return state, nil + } + return State{}, err + } + return state, nil +} + +func (s *adoptionRaceStore) SyncDashboard(context.Context, State) error { return nil } + +// --- test helpers --- + +func cfgTimeout(cfg Config) time.Duration { + if cfg.FeedbackWaitTimeout > 0 { + return cfg.FeedbackWaitTimeout + } + return time.Hour +} + +// seedRound installs a round for repo#pr at head in the given phase. Fired, +// reviewing, and completed phases record a fire at firedAt with commandID; a +// fired round also holds the global fire slot. +func seedRound(t *testing.T, store StateStore, cfg Config, repo string, pr int, head string, phase Phase, firedAt time.Time, commandID int64) { + t.Helper() + _, err := store.Update(context.Background(), func(st *State) error { + r, err := st.NewRound(repo, pr, head, firedAt) + if err != nil { + return err + } + switch phase { + case PhaseQueued: + case PhaseFired, PhaseReviewing, PhaseCompleted: + if err := r.Reserve("seedtok", "seedhost", firedAt); err != nil { + return err + } + if err := r.Fire(commandID, firedAt); err != nil { + return err + } + dl := firedAt.Add(cfgTimeout(cfg)) + r.WaitDeadline = &dl + if phase == PhaseReviewing { + if err := r.Acknowledge(); err != nil { + return err + } + } + if phase == PhaseCompleted { + if err := r.Complete(); err != nil { + return err + } + } + } + st.PutRound(*r) + if phase == PhaseFired { + st.FireSlot = &FireSlot{Key: QueueKey(repo, pr), Token: "seedtok", Since: firedAt} + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func roundPhase(t *testing.T, store StateStore, repo string, pr int) Phase { + t.Helper() + st, _, err := store.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + r := st.Round(repo, pr) + if r == nil { + return "" + } + return r.Phase +} + func TestPruneCalibrationDeletesOldNoiseKeepsRecent(t *testing.T) { gh := newFakeGitHub() cfg := Config{ @@ -289,10 +338,10 @@ func TestPruneCalibrationDeletesOldNoiseKeepsRecent(t *testing.T) { } key := fakeKey("o/gate", 1) gh.comments[key] = []IssueComment{ - mkc(1, "kristofferR", "@coderabbitai rate limit", old), // old probe -> delete - mkc(2, "coderabbitai[bot]", "0 reviews remaining. auto-generated reply by CodeRabbit", old), // old reply -> delete - mkc(3, "someone", "unrelated human comment", old), // not calibration noise -> keep - mkc(4, "kristofferR", "@coderabbitai rate limit", now), // recent -> keep + mkc(1, "kristofferR", "@coderabbitai rate limit", old), + mkc(2, "coderabbitai[bot]", "0 reviews remaining. auto-generated reply by CodeRabbit", old), + mkc(3, "someone", "unrelated human comment", old), + mkc(4, "kristofferR", "@coderabbitai rate limit", now), } deleted := svc.pruneCalibration(context.Background(), 1, now.Add(-2*time.Minute), 80) @@ -335,6 +384,8 @@ func TestAutoReviewScanSkipsConfiguredAuthors(t *testing.T) { GateRepo: "o/gate", Scope: []string{"o"}, Host: "h", + Bot: "coderabbitai[bot]", + ReviewCommand: "@coderabbitai review", LeaderTTL: time.Minute, AutoReviewMaxScan: 10, SkipAuthors: authorSet("dependabot[bot]"), @@ -342,39 +393,30 @@ func TestAutoReviewScanSkipsConfiguredAuthors(t *testing.T) { gh := newFakeGitHub() gh.searchPRs = []SearchPR{ {Repo: "o/app", Number: 1, Author: "dependabot[bot]"}, - {Repo: "o/app", Number: 2, Author: "Dependabot"}, // case + suffix variants match too + {Repo: "o/app", Number: 2, Author: "Dependabot"}, {Repo: "o/app", Number: 3, Author: "alice"}, } - // All three PRs are enqueueable if the author filter fails, so a broken - // filter yields 3 queued items rather than a false pass via missing pulls. for pr := 1; pr <= 3; pr++ { var pull Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/app", pr)] = pull } - svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) if err := svc.AutoReview(ctx, AutoOptions{Once: true, Incremental: true}); err != nil { t.Fatal(err) } - // The one-shot pass also pumps, so the enqueued PR moves queue → fired. - st, _, err := svc.store.Load(ctx) + st, _, err := store.Load(ctx) if err != nil { t.Fatal(err) } - if len(st.Fired) != 1 || st.Fired["o/app#3"] == "" { - t.Fatalf("only the human-authored PR should be enqueued and fired, got fired=%#v", st.Fired) - } - for _, item := range st.Queue { - if item.PR != 3 { - t.Fatalf("bot-authored PR #%d must not be queued, got %#v", item.PR, st.Queue) - } + if firedMarker(&st, "o/app", 3) == "" { + t.Fatalf("only the human-authored PR should be enqueued and fired, got rounds=%#v", st.Rounds) } - for _, h := range st.History { - if h.PR != 3 { - t.Fatalf("bot-authored PR #%d must never fire, got history %#v", h.PR, st.History) - } + if st.Round("o/app", 1) != nil || st.Round("o/app", 2) != nil { + t.Fatalf("bot-authored PRs must never be queued/fired, got rounds=%#v", st.Rounds) } } @@ -384,6 +426,8 @@ func TestAutoReviewScanSkipsMarkedPRs(t *testing.T) { GateRepo: "o/gate", Scope: []string{"o"}, Host: "h", + Bot: "coderabbitai[bot]", + ReviewCommand: "@coderabbitai review", LeaderTTL: time.Minute, AutoReviewMaxScan: 10, SkipMarker: "", @@ -399,20 +443,21 @@ func TestAutoReviewScanSkipsMarkedPRs(t *testing.T) { pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/app", pr)] = pull } - svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) if err := svc.AutoReview(ctx, AutoOptions{Once: true, Incremental: true}); err != nil { t.Fatal(err) } - st, _, err := svc.store.Load(ctx) + st, _, err := store.Load(ctx) if err != nil { t.Fatal(err) } - if len(st.Fired) != 1 || st.Fired["o/app#2"] == "" { - t.Fatalf("only the unmarked PR should be reviewed, got fired=%#v", st.Fired) + if firedMarker(&st, "o/app", 2) == "" { + t.Fatalf("only the unmarked PR should be reviewed, got rounds=%#v", st.Rounds) } - if st.Fired["o/app#1"] != "" { - t.Fatalf("marked PR must never fire, got fired=%#v", st.Fired) + if st.Round("o/app", 1) != nil { + t.Fatalf("marked PR must never fire, got %#v", st.Round("o/app", 1)) } } @@ -420,61 +465,28 @@ func TestEnqueueBatchAppendsOncePerPR(t *testing.T) { cfg := Config{GateRepo: "o/gate", Scope: []string{"o"}, Host: "h"} svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) ctx := context.Background() - items := []SearchPR{ - {Repo: "o/a", Number: 1}, - {Repo: "o/b", Number: 2}, - {Repo: "o/a", Number: 1}, // duplicate within the batch + items := []queueCandidate{ + {Repo: "o/a", PR: 1, Head: "aaaaaaaa1"}, + {Repo: "o/b", PR: 2, Head: "bbbbbbbb2"}, + {Repo: "o/a", PR: 1, Head: "aaaaaaaa1"}, } if err := svc.enqueueBatch(ctx, items); err != nil { t.Fatal(err) } st, _, _ := svc.store.Load(ctx) - if len(st.Queue) != 2 { - t.Fatalf("expected 2 queued (deduped), got %d", len(st.Queue)) + queued := st.QueuedRounds(time.Now().UTC()) + if len(queued) != 2 { + t.Fatalf("expected 2 queued (deduped), got %d", len(queued)) } - if st.Queue[0].Seq == st.Queue[1].Seq || st.Queue[0].Seq == 0 { - t.Fatalf("expected distinct non-zero seqs, got %d and %d", st.Queue[0].Seq, st.Queue[1].Seq) + if queued[0].Seq == queued[1].Seq || queued[0].Seq == 0 { + t.Fatalf("expected distinct non-zero seqs, got %d and %d", queued[0].Seq, queued[1].Seq) } - // Re-batching the same PRs is a no-op since they're already queued. if err := svc.enqueueBatch(ctx, items); err != nil { t.Fatal(err) } st2, _, _ := svc.store.Load(ctx) - if len(st2.Queue) != 2 { - t.Fatalf("expected still 2 after re-batch, got %d", len(st2.Queue)) - } -} - -func TestRequeueInflightRateLimitUsesBlockedNotWarn(t *testing.T) { - cfg := Config{GateRepo: "o/gate", Scope: []string{"o"}, CalibrationTTL: 2 * time.Minute, Host: "h"} - svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - - // Rate-limited requeue: represented via Blocked, not a sticky Warn. - reset := time.Now().UTC().Add(10 * time.Minute) - st := &State{InFlight: &InFlight{Repo: "o/a", PR: 1, Seq: 5}, Fired: map[string]string{"o/a#1": "abc"}} - svc.requeueInflight(st, inflightCheck{Requeue: true, Reason: warnRateLimited, BlockedUntil: &reset}) - if st.Warn != "" { - t.Fatalf("rate-limit requeue must not set a sticky Warn, got %q", st.Warn) - } - if st.Blocked.BlockedUntil == nil || !st.Blocked.BlockedUntil.Equal(reset) { - t.Fatalf("expected Blocked.BlockedUntil=reset, got %v", st.Blocked.BlockedUntil) - } - if st.InFlight != nil { - t.Fatal("inflight should be cleared on requeue") - } - - // Rate-limited with no parseable reset: still blocks (briefly), no Warn. - st = &State{InFlight: &InFlight{Repo: "o/a", PR: 1}, Fired: map[string]string{}} - svc.requeueInflight(st, inflightCheck{Requeue: true, Reason: warnRateLimited}) - if st.Warn != "" || st.Blocked.BlockedUntil == nil { - t.Fatalf("no-reset rate limit should block without a Warn; warn=%q blocked=%v", st.Warn, st.Blocked.BlockedUntil) - } - - // Non-rate-limit requeue keeps an informative Warn. - st = &State{InFlight: &InFlight{Repo: "o/b", PR: 2}, Fired: map[string]string{}} - svc.requeueInflight(st, inflightCheck{Requeue: true, Reason: "in-flight timeout"}) - if st.Warn != "in-flight timeout" { - t.Fatalf("non-rate-limit requeue should set Warn, got %q", st.Warn) + if len(st2.QueuedRounds(time.Now().UTC())) != 2 { + t.Fatalf("expected still 2 after re-batch, got %d", len(st2.QueuedRounds(time.Now().UTC()))) } } @@ -496,74 +508,6 @@ func TestLatestCalibrationReplyToleratesBotSuffix(t *testing.T) { } } -func TestInflightStatusToleratesBotSuffix(t *testing.T) { - now := time.Now().UTC() - cfg := Config{Bot: "coderabbitai", RateLimitMarker: "rate limited by coderabbit.ai", InflightTimeout: time.Hour} - baseState := State{InFlight: &InFlight{Repo: "o/repo", PR: 1, Phase: "posted", FiredAt: &now, FiredCommentID: 99}} - - t.Run("review", func(t *testing.T) { - gh := newFakeGitHub() - review := Review{SubmittedAt: now.Add(time.Second)} - review.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("o/repo", 1)] = []Review{review} - svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) - status, err := svc.inflightStatus(context.Background(), baseState) - if err != nil { - t.Fatal(err) - } - if !status.Done || status.Reason != "review submitted" { - t.Fatalf("expected suffixed bot review to complete in-flight review, got %#v", status) - } - }) - - t.Run("reaction", func(t *testing.T) { - gh := newFakeGitHub() - reaction := Reaction{} - reaction.User.Login = "coderabbitai[bot]" - gh.reactions[99] = []Reaction{reaction} - svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) - status, err := svc.inflightStatus(context.Background(), baseState) - if err != nil { - t.Fatal(err) - } - if !status.Done || status.Reason != "bot reacted" { - t.Fatalf("expected suffixed bot reaction to complete in-flight review, got %#v", status) - } - }) - - t.Run("rate-limit-comment", func(t *testing.T) { - gh := newFakeGitHub() - comment := IssueComment{Body: "You are rate limited by coderabbit.ai. Reviews available in 3 minutes.", UpdatedAt: now.Add(time.Second)} - comment.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} - svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) - status, err := svc.inflightStatus(context.Background(), baseState) - if err != nil { - t.Fatal(err) - } - if !status.Requeue || status.Reason != warnRateLimited { - t.Fatalf("expected suffixed bot rate-limit comment to requeue with blocked state, got %#v", status) - } - }) - - t.Run("reviews-paused-note-is-not-completion", func(t *testing.T) { - gh := newFakeGitHub() - comment := IssueComment{Body: "> [!NOTE]\n> ## Reviews paused\n> It looks like this branch is under active development. CodeRabbit has automatically paused this review. Use `@coderabbitai resume` to resume automatic reviews.", UpdatedAt: now.Add(time.Second)} - comment.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} - svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) - status, err := svc.inflightStatus(context.Background(), baseState) - if err != nil { - t.Fatal(err) - } - // The auto-pause note is not a review of the fired head — the round must keep - // waiting for the real review rather than falsely completing with no findings. - if status.Done || status.Requeue { - t.Fatalf("expected reviews-paused note to leave the in-flight round pending, got %#v", status) - } - }) -} - func TestRenewLeaderRespectsLiveLease(t *testing.T) { cfg := Config{GateRepo: "o/gate", Scope: []string{"o"}, LeaderTTL: time.Minute} svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) @@ -579,21 +523,27 @@ func TestRenewLeaderRespectsLiveLease(t *testing.T) { } } -func TestEnqueueIsIdempotentAndPumpFiresOnce(t *testing.T) { - ctx := context.Background() - cfg := Config{ +func firingConfig() Config { + return Config{ GateRepo: "owner/gate", - StateRef: "crq-state", + StateRef: "crq-state-v3", Host: "testhost", Bot: "coderabbitai[bot]", + RequiredBots: []string{"coderabbitai[bot]"}, ReviewCommand: "@coderabbitai review", RateLimitMarker: "rate limited by coderabbit.ai", + CalibrationMarker: "auto-generated reply by CodeRabbit", + CompletionMarker: "Review finished", MinInterval: 0, InflightTimeout: time.Minute, PollInterval: time.Millisecond, FeedbackWaitTimeout: time.Minute, - FiredMax: 500, } +} + +func TestEnqueueIsIdempotentAndPumpFiresOnce(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() gh := newFakeGitHub() var pull Pull pull.State = "open" @@ -638,18 +588,7 @@ func TestEnqueueIsIdempotentAndPumpFiresOnce(t *testing.T) { func TestPumpPersistsPostedReviewAfterTransientStateFailure(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - RateLimitMarker: "rate limited by coderabbit.ai", - MinInterval: 0, - InflightTimeout: time.Minute, - PollInterval: time.Millisecond, - FiredMax: 500, - } + cfg := firingConfig() gh := newFakeGitHub() var pull Pull pull.State = "open" @@ -673,18 +612,16 @@ func TestPumpPersistsPostedReviewAfterTransientStateFailure(t *testing.T) { if err != nil { t.Fatal(err) } - if state.InFlight == nil || state.InFlight.Phase != "posted" || state.InFlight.FiredCommentID == 0 { - t.Fatalf("posted review metadata was not persisted after retry: %#v", state.InFlight) + r := state.Round("owner/repo", 12) + if r == nil || r.Phase != PhaseFired || r.CommandID == 0 { + t.Fatalf("posted review metadata was not persisted after retry: %#v", r) } - if state.Fired[QueueKey("owner/repo", 12)] != "abcdef123" { - t.Fatalf("fired marker was not persisted after retry: %#v", state.Fired) + if firedMarker(&state, "owner/repo", 12) != "abcdef123" { + t.Fatalf("fired marker was not persisted after retry") } - wait := state.AwaitingFeedback[QueueKey("owner/repo", 12)] - if wait.Head != "abcdef123" { - t.Fatalf("feedback wait marker was not persisted after firing: %#v", state.AwaitingFeedback) - } - if state.InFlight.FiredAt == nil || !wait.StartedAt.Equal(*state.InFlight.FiredAt) { - t.Fatalf("feedback wait should start at the fired timestamp, wait=%#v inflight=%#v", wait, state.InFlight) + wait := waitView(&state, "owner/repo", 12) + if wait.Head != "abcdef123" || r.FiredAt == nil || !wait.StartedAt.Equal(*r.FiredAt) { + t.Fatalf("feedback wait should start at the fired timestamp, wait=%#v round=%#v", wait, r) } if wait.Deadline.Sub(wait.StartedAt) != cfg.FeedbackWaitTimeout { t.Fatalf("feedback wait deadline should use CRQ_FEEDBACK_WAIT_TIMEOUT, got %s", wait.Deadline.Sub(wait.StartedAt)) @@ -693,19 +630,7 @@ func TestPumpPersistsPostedReviewAfterTransientStateFailure(t *testing.T) { func TestPumpAdoptsExistingReviewCommandWithoutRefiring(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - RateLimitMarker: "rate limited by coderabbit.ai", - MinInterval: 0, - InflightTimeout: time.Minute, - PollInterval: time.Millisecond, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } + cfg := firingConfig() gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) var pull Pull @@ -738,26 +663,21 @@ func TestPumpAdoptsExistingReviewCommandWithoutRefiring(t *testing.T) { if err != nil { t.Fatal(err) } - if state.InFlight == nil || state.InFlight.FiredCommentID != comment.ID || state.InFlight.Head != "abcdef123" { - t.Fatalf("existing review command should be persisted as in-flight, got %#v", state.InFlight) + r := state.Round("owner/repo", 12) + if r == nil || r.Phase != PhaseFired || r.CommandID != comment.ID || r.Head != "abcdef123" { + t.Fatalf("existing review command should be persisted as a fired round, got %#v", r) } - if state.Fired[QueueKey("owner/repo", 12)] != "abcdef123" { - t.Fatalf("existing review command should restore fired dedupe state: %#v", state.Fired) + if firedMarker(&state, "owner/repo", 12) != "abcdef123" { + t.Fatalf("existing review command should restore fired dedupe state") } - if wait := state.AwaitingFeedback[QueueKey("owner/repo", 12)]; wait.Head != "abcdef123" || !wait.StartedAt.Equal(comment.CreatedAt) { + if wait := waitView(&state, "owner/repo", 12); wait.Head != "abcdef123" || !wait.StartedAt.Equal(comment.CreatedAt) { t.Fatalf("existing review command should create a feedback wait from the comment timestamp, got %#v", wait) } } -func TestExistingReviewCommandRequiresExpectedHead(t *testing.T) { +func TestAdoptableCommandsRequiresExpectedHead(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - FeedbackWaitTimeout: time.Minute, - } + cfg := Config{GateRepo: "owner/gate", Host: "testhost", Bot: "coderabbitai[bot]", ReviewCommand: "@coderabbitai review"} gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) var pull Pull @@ -772,25 +692,18 @@ func TestExistingReviewCommandRequiresExpectedHead(t *testing.T) { gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} service := NewService(cfg, gh, NewMemoryStore(cfg), nil) - if _, ok, err := service.existingReviewCommand(ctx, "owner/repo", 12, "abcdef123", time.Time{}); err != nil || ok { - t.Fatalf("must not adopt a review command after the PR head changed, ok=%v err=%v", ok, err) + comments, _ := gh.ListIssueComments(ctx, "owner/repo", 12) + reviews, _ := gh.ListReviews(ctx, "owner/repo", 12) + cmds, err := service.adoptableCommands(ctx, "owner/repo", 12, "abcdef123", time.Time{}, pull, comments, reviews) + if err != nil || len(cmds) != 0 { + t.Fatalf("must not adopt a review command after the PR head changed, cmds=%v err=%v", cmds, err) } } func TestPumpDryRunDoesNotAdoptExistingCommand(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - MinInterval: 0, - InflightTimeout: time.Minute, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - DryRun: true, - } + cfg := firingConfig() + cfg.DryRun = true gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) var pull Pull @@ -806,9 +719,9 @@ func TestPumpDryRunDoesNotAdoptExistingCommand(t *testing.T) { store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) - if _, err := service.Enqueue(ctx, "owner/repo", 12); err != nil { - t.Fatal(err) - } + // Seed a queued round directly (Enqueue writes, which DryRun would allow, but + // the point is the pump adopts nothing and posts nothing). + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseQueued, time.Now().UTC(), 0) pumped, err := service.Pump(ctx) if err != nil { t.Fatal(err) @@ -816,28 +729,14 @@ func TestPumpDryRunDoesNotAdoptExistingCommand(t *testing.T) { if pumped.Action != "dry_run" { t.Fatalf("dry-run pump must simulate, not adopt an existing command, got %#v", pumped) } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if state.InFlight != nil || len(state.Queue) != 1 || len(state.AwaitingFeedback) != 0 { - t.Fatalf("dry-run pump must not mutate queue state, got inflight=%#v queue=%#v awaiting=%#v", state.InFlight, state.Queue, state.AwaitingFeedback) + if roundPhase(t, store, "owner/repo", 12) != PhaseQueued { + t.Fatalf("dry-run pump must not mutate the round, got phase %s", roundPhase(t, store, "owner/repo", 12)) } } func TestPumpIgnoresStaleCommandAfterRequeue(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - MinInterval: 0, - InflightTimeout: time.Minute, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } + cfg := firingConfig() gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) var pull Pull @@ -856,9 +755,13 @@ func TestPumpIgnoresStaleCommandAfterRequeue(t *testing.T) { if _, err := service.Enqueue(ctx, "owner/repo", 12); err != nil { t.Fatal(err) } + // The round was requeued after a failed attempt: LastAttemptAt sits after the + // stale command, so it must not be adopted. requeuedAt := stale.CreatedAt.Add(20 * time.Second) if _, err := store.Update(ctx, func(st *State) error { - st.Queue[0].RequeuedAt = &requeuedAt + r := st.Round("owner/repo", 12) + r.LastAttemptAt = &requeuedAt + st.PutRound(*r) return nil }); err != nil { t.Fatal(err) @@ -873,31 +776,15 @@ func TestPumpIgnoresStaleCommandAfterRequeue(t *testing.T) { if len(gh.posted) != 1 { t.Fatalf("expected a fresh review command to be posted after requeue, posted=%v", gh.posted) } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if state.InFlight == nil || state.InFlight.FiredCommentID == stale.ID { - t.Fatalf("in-flight must track the fresh command, not the stale one, got %#v", state.InFlight) + if st, _, _ := store.Load(ctx); st.Round("owner/repo", 12).CommandID == stale.ID { + t.Fatalf("the round must track the fresh command, not the stale one") } } func TestPumpDoesNotAdoptCommandOlderThanForcePush(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - MinInterval: 0, - InflightTimeout: time.Minute, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } + cfg := firingConfig() gh := newFakeGitHub() - // The PR was force-pushed to a commit object that predates the stale - // command: the committer date alone would let the command be adopted. commitTime := time.Now().UTC().Add(-time.Hour) staleAt := commitTime.Add(10 * time.Minute) forcePushAt := commitTime.Add(30 * time.Minute) @@ -935,21 +822,8 @@ func TestPumpDoesNotAdoptCommandOlderThanForcePush(t *testing.T) { func TestPumpDoesNotAdoptCommandAlreadyAnsweredByReview(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - MinInterval: 0, - InflightTimeout: time.Minute, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } + cfg := firingConfig() gh := newFakeGitHub() - // A commit created before an old review command was pushed later: the - // commit-date cutoff cannot exclude the command, but the bot's review - // answering it proves the command belongs to a finished round. commitTime := time.Now().UTC().Add(-time.Hour) commandAt := commitTime.Add(10 * time.Minute) var pull Pull @@ -985,23 +859,8 @@ func TestPumpDoesNotAdoptCommandAlreadyAnsweredByReview(t *testing.T) { func TestPumpDoesNotAdoptCommandAlreadyAnsweredByCompletionReply(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - CalibrationMarker: "auto-generated reply by CodeRabbit", - CompletionMarker: "Review finished", - MinInterval: 0, - InflightTimeout: time.Minute, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } + cfg := firingConfig() gh := newFakeGitHub() - // A later regular push can point at a commit whose committer date predates - // an old no-findings command round. The old completion reply proves that - // command is consumed and must not be adopted for the new head. commitTime := time.Now().UTC().Add(-time.Hour) commandAt := commitTime.Add(10 * time.Minute) var pull Pull @@ -1036,20 +895,7 @@ func TestPumpDoesNotAdoptCommandAlreadyAnsweredByCompletionReply(t *testing.T) { func TestPumpAdoptsCompletionAnsweredCommandWhileTopSummaryIsProcessing(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - RateLimitMarker: "rate limited by coderabbit.ai", - CalibrationMarker: "auto-generated reply by CodeRabbit", - CompletionMarker: "Review finished", - MinInterval: 0, - InflightTimeout: time.Minute, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } + cfg := firingConfig() gh := newFakeGitHub() commitTime := time.Now().UTC().Add(-time.Hour) commandAt := commitTime.Add(10 * time.Minute) @@ -1092,36 +938,22 @@ func TestPumpAdoptsCompletionAnsweredCommandWhileTopSummaryIsProcessing(t *testi func TestPumpDryRunDoesNotDedupeMutably(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - MinInterval: 0, - InflightTimeout: time.Minute, - FeedbackWaitTimeout: time.Hour, - FiredMax: 500, - DryRun: true, - } + cfg := firingConfig() + cfg.DryRun = true + cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() var pull Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull + // The bot already reviewed the head, so DecideFire would dedupe. + review := Review{CommitID: "abcdef1234567890", SubmittedAt: time.Now().UTC()} + review.User.Login = cfg.Bot + gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) - if _, err := service.Enqueue(ctx, "owner/repo", 12); err != nil { - t.Fatal(err) - } - started := time.Now().UTC() - if _, err := store.Update(ctx, func(st *State) error { - st.AwaitingFeedback[QueueKey("owner/repo", 12)] = FeedbackWait{Repo: "owner/repo", PR: 12, Head: "abcdef123", StartedAt: started, Deadline: started.Add(cfg.FeedbackWaitTimeout)} - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseQueued, time.Now().UTC(), 0) pumped, err := service.Pump(ctx) if err != nil { t.Fatal(err) @@ -1129,28 +961,14 @@ func TestPumpDryRunDoesNotDedupeMutably(t *testing.T) { if pumped.Action != "deduped" { t.Fatalf("dry-run should report the dedupe it would perform, got %#v", pumped) } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if len(state.Queue) != 1 { - t.Fatalf("a dry-run dedupe must not remove the queued item, got %#v", state.Queue) + if roundPhase(t, store, "owner/repo", 12) != PhaseQueued { + t.Fatalf("a dry-run dedupe must not mutate the round, got %s", roundPhase(t, store, "owner/repo", 12)) } } func TestPumpSkipsAdoptionWhenCommitLookupFails(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - MinInterval: 0, - InflightTimeout: time.Minute, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } + cfg := firingConfig() gh := newFakeGitHub() var pull Pull pull.State = "open" @@ -1178,499 +996,247 @@ func TestPumpSkipsAdoptionWhenCommitLookupFails(t *testing.T) { } } -func TestPumpClearsFeedbackWaitWhenReviewSubmitted(t *testing.T) { +func TestPumpCompletesRoundWhenReviewSubmitted(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - InflightTimeout: time.Hour, - FeedbackWaitTimeout: time.Hour, - FiredMax: 500, - } + cfg := firingConfig() + cfg.InflightTimeout = time.Hour + cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() firedAt := time.Now().UTC().Add(-5 * time.Minute) + var pull Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls[fakeKey("owner/repo", 12)] = pull review := Review{CommitID: "abcdef1234567890", SubmittedAt: firedAt.Add(time.Minute)} review.User.Login = cfg.Bot gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) - if _, err := store.Update(ctx, func(st *State) error { - st.InFlight = &InFlight{Repo: "owner/repo", PR: 12, Head: "abcdef123", Token: "tok", Phase: "posted", ReservedAt: firedAt, FiredAt: &firedAt, FiredCommentID: 5} - st.AwaitingFeedback[QueueKey("owner/repo", 12)] = FeedbackWait{Repo: "owner/repo", PR: 12, Head: "abcdef123", StartedAt: firedAt, Deadline: firedAt.Add(cfg.FeedbackWaitTimeout)} - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseFired, firedAt, 5) pumped, err := service.Pump(ctx) if err != nil { t.Fatal(err) } - if pumped.Action != "cleared" || pumped.Reason != doneReviewSubmitted { + if pumped.Action != "cleared" { t.Fatalf("expected the submitted review to clear the in-flight slot, got %#v", pumped) } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if len(state.AwaitingFeedback) != 0 { - t.Fatalf("a submitted review must clear the feedback wait, got %#v", state.AwaitingFeedback) + if p := roundPhase(t, store, "owner/repo", 12); p != PhaseCompleted { + t.Fatalf("a submitted review must complete the round, got %s", p) } } -func TestPumpClearsFeedbackWaitWhenCompletionReplySubmitted(t *testing.T) { +func TestPumpCompletesRoundOnCompletionReply(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - RequiredBots: []string{"coderabbitai[bot]"}, - ReviewCommand: "@coderabbitai review", - CalibrationMarker: "auto-generated reply by CodeRabbit", - CompletionMarker: "Review finished", - InflightTimeout: time.Hour, - FeedbackWaitTimeout: time.Hour, - FiredMax: 500, - } + cfg := firingConfig() + cfg.RequiredBots = []string{"coderabbitai[bot]"} + cfg.InflightTimeout = time.Hour + cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() firedAt := time.Now().UTC().Add(-5 * time.Minute) + var pull Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls[fakeKey("owner/repo", 12)] = pull command := IssueComment{ID: 5, Body: cfg.ReviewCommand, CreatedAt: firedAt, UpdatedAt: firedAt} command.User.Login = "kristofferR" reply := IssueComment{ID: 6, Body: "\nReview finished.", CreatedAt: firedAt.Add(time.Minute), UpdatedAt: firedAt.Add(time.Minute)} reply.User.Login = cfg.Bot gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{command, reply} - // The completion-only round is a re-review: the bot's earlier review of a - // previous head must exist for the reply to stand in for a review. + // A completion-only round is a re-review: a prior review must exist. prior := Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} prior.User.Login = cfg.Bot gh.reviews[fakeKey("owner/repo", 12)] = []Review{prior} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) - if _, err := store.Update(ctx, func(st *State) error { - st.InFlight = &InFlight{Repo: "owner/repo", PR: 12, Head: "abcdef123", Token: "tok", Phase: "posted", ReservedAt: firedAt, FiredAt: &firedAt, FiredCommentID: command.ID} - st.AwaitingFeedback[QueueKey("owner/repo", 12)] = FeedbackWait{Repo: "owner/repo", PR: 12, Head: "abcdef123", StartedAt: firedAt, Deadline: firedAt.Add(cfg.FeedbackWaitTimeout)} - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseFired, firedAt, command.ID) pumped, err := service.Pump(ctx) if err != nil { t.Fatal(err) } - if pumped.Action != "cleared" || pumped.Reason != doneBotComment { + if pumped.Action != "cleared" { t.Fatalf("expected the completion reply to clear the in-flight slot, got %#v", pumped) } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if len(state.AwaitingFeedback) != 0 { - t.Fatalf("a completion reply must clear the feedback wait, got %#v", state.AwaitingFeedback) + if p := roundPhase(t, store, "owner/repo", 12); p != PhaseCompleted { + t.Fatalf("a completion reply must complete the round, got %s", p) } } -func TestPumpKeepsFeedbackWaitWhenCompletionReplyOnUnreviewedPR(t *testing.T) { - // Incident regression: CodeRabbit answered the first-ever review command on - // a PR with "Review finished" five seconds after the trigger, while the - // real review (11 findings) was still queued on its side. With no review - // ever submitted on the PR, the ack must not complete the round: the - // feedback wait has to survive so the loop keeps waiting for the actual - // review instead of converging with zero findings (which also let - // autoreview fire a duplicate command for the same head). +func TestPumpKeepsRoundReviewingOnCompletionReplyForUnreviewedPR(t *testing.T) { + // CodeRabbit answered the first-ever review command with an instant "Review + // finished" while the real review was still queued on its side. With no review + // ever submitted, the round must not complete: it goes to reviewing (the slot + // is released, but the round stays open) so the wait survives. ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - RequiredBots: []string{"coderabbitai[bot]"}, - ReviewCommand: "@coderabbitai review", - CalibrationMarker: "auto-generated reply by CodeRabbit", - CompletionMarker: "Review finished", - InflightTimeout: time.Hour, - FeedbackWaitTimeout: time.Hour, - FiredMax: 500, - } + cfg := firingConfig() + cfg.RequiredBots = []string{"coderabbitai[bot]"} + cfg.InflightTimeout = time.Hour + cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() firedAt := time.Now().UTC().Add(-5 * time.Minute) + var pull Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls[fakeKey("owner/repo", 12)] = pull command := IssueComment{ID: 5, Body: cfg.ReviewCommand, CreatedAt: firedAt, UpdatedAt: firedAt} command.User.Login = "kristofferR" reply := IssueComment{ID: 6, Body: "\n✅ Action performed\n\nReview finished.", CreatedAt: firedAt.Add(5 * time.Second), UpdatedAt: firedAt.Add(5 * time.Second)} reply.User.Login = cfg.Bot gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{command, reply} - // No reviews on the PR at all — the bot has never reviewed it. store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) - if _, err := store.Update(ctx, func(st *State) error { - st.InFlight = &InFlight{Repo: "owner/repo", PR: 12, Head: "abcdef123", Token: "tok", Phase: "posted", ReservedAt: firedAt, FiredAt: &firedAt, FiredCommentID: command.ID} - st.AwaitingFeedback[QueueKey("owner/repo", 12)] = FeedbackWait{Repo: "owner/repo", PR: 12, Head: "abcdef123", StartedAt: firedAt, Deadline: firedAt.Add(cfg.FeedbackWaitTimeout)} - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseFired, firedAt, command.ID) - pumped, err := service.Pump(ctx) - if err != nil { + if _, err := service.Pump(ctx); err != nil { t.Fatal(err) } - if pumped.Action != "cleared" || pumped.Reason != doneBotComment { - t.Fatalf("the ack still ends the in-flight command round, got %#v", pumped) - } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) + if p := roundPhase(t, store, "owner/repo", 12); p != PhaseReviewing { + t.Fatalf("the round must survive a completion reply on a never-reviewed PR (reviewing), got %s", p) } - if wait := state.AwaitingFeedback[QueueKey("owner/repo", 12)]; wait.Head != "abcdef123" { - t.Fatalf("the feedback wait must survive a completion reply on a never-reviewed PR, got %#v", state.AwaitingFeedback) + st, _, _ := store.Load(ctx) + if waitView(&st, "owner/repo", 12).Head != "abcdef123" { + t.Fatalf("the feedback wait must survive a completion reply on a never-reviewed PR") } } -func TestPumpKeepsFeedbackWaitWhenBotOnlyReacted(t *testing.T) { +func TestPumpKeepsRoundReviewingWhenBotOnlyReacted(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - InflightTimeout: time.Hour, - FeedbackWaitTimeout: time.Hour, - FiredMax: 500, - } + cfg := firingConfig() + cfg.InflightTimeout = time.Hour + cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() firedAt := time.Now().UTC().Add(-time.Minute) + var pull Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls[fakeKey("owner/repo", 12)] = pull reaction := Reaction{} reaction.User.Login = cfg.Bot gh.reactions[5] = []Reaction{reaction} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) - if _, err := store.Update(ctx, func(st *State) error { - st.InFlight = &InFlight{Repo: "owner/repo", PR: 12, Head: "abcdef123", Token: "tok", Phase: "posted", ReservedAt: firedAt, FiredAt: &firedAt, FiredCommentID: 5} - st.AwaitingFeedback[QueueKey("owner/repo", 12)] = FeedbackWait{Repo: "owner/repo", PR: 12, Head: "abcdef123", StartedAt: firedAt, Deadline: firedAt.Add(cfg.FeedbackWaitTimeout)} - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseFired, firedAt, 5) pumped, err := service.Pump(ctx) if err != nil { t.Fatal(err) } - if pumped.Action != "cleared" || pumped.Reason != doneBotReacted { - t.Fatalf("expected the reaction to clear the in-flight slot, got %#v", pumped) + if pumped.Action != "cleared" { + t.Fatalf("expected the reaction to release the slot, got %#v", pumped) } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if wait := state.AwaitingFeedback[QueueKey("owner/repo", 12)]; wait.Head != "abcdef123" { - t.Fatalf("a bare reaction means the review is still running — the wait must survive, got %#v", state.AwaitingFeedback) + if p := roundPhase(t, store, "owner/repo", 12); p != PhaseReviewing { + t.Fatalf("a bare reaction means the review is still running — the round must stay reviewing, got %s", p) } } -func TestPumpKeepsFeedbackWaitUntilAllRequiredBotsReview(t *testing.T) { +func TestPumpKeepsRoundOpenUntilAllRequiredBotsReview(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - RequiredBots: []string{"coderabbitai[bot]", "chatgpt-codex-connector"}, - ReviewCommand: "@coderabbitai review", - InflightTimeout: time.Hour, - FeedbackWaitTimeout: time.Hour, - FiredMax: 500, - } + cfg := firingConfig() + cfg.RequiredBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector"} + cfg.InflightTimeout = time.Hour + cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() firedAt := time.Now().UTC().Add(-5 * time.Minute) + var pull Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls[fakeKey("owner/repo", 12)] = pull review := Review{SubmittedAt: firedAt.Add(time.Minute), CommitID: "abcdef1234567890"} review.User.Login = cfg.Bot gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) - if _, err := store.Update(ctx, func(st *State) error { - st.InFlight = &InFlight{Repo: "owner/repo", PR: 12, Head: "abcdef123", Token: "tok", Phase: "posted", ReservedAt: firedAt, FiredAt: &firedAt, FiredCommentID: 5} - st.AwaitingFeedback[QueueKey("owner/repo", 12)] = FeedbackWait{Repo: "owner/repo", PR: 12, Head: "abcdef123", StartedAt: firedAt, Deadline: firedAt.Add(cfg.FeedbackWaitTimeout)} - return nil - }); err != nil { - t.Fatal(err) - } + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseFired, firedAt, 5) - pumped, err := service.Pump(ctx) - if err != nil { - t.Fatal(err) - } - if pumped.Action != "cleared" || pumped.Reason != doneReviewSubmitted { - t.Fatalf("expected the submitted review to clear the in-flight slot, got %#v", pumped) - } - state, _, err := store.Load(ctx) - if err != nil { + if _, err := service.Pump(ctx); err != nil { t.Fatal(err) } - if wait := state.AwaitingFeedback[QueueKey("owner/repo", 12)]; wait.Head != "abcdef123" { - t.Fatalf("the wait must survive while a required bot has not reviewed, got %#v", state.AwaitingFeedback) + if p := roundPhase(t, store, "owner/repo", 12); p != PhaseReviewing { + t.Fatalf("the round must stay open while a required bot has not reviewed, got %s", p) } + // A required-bot review for a different commit must not complete it. staleCodex := Review{SubmittedAt: firedAt.Add(2 * time.Minute), CommitID: "0123456789abcdef"} staleCodex.User.Login = "chatgpt-codex-connector" gh.reviews[fakeKey("owner/repo", 12)] = []Review{review, staleCodex} - if _, err := store.Update(ctx, func(st *State) error { - st.InFlight = &InFlight{Repo: "owner/repo", PR: 12, Head: "abcdef123", Token: "tok2", Phase: "posted", ReservedAt: firedAt, FiredAt: &firedAt, FiredCommentID: 5} - return nil - }); err != nil { - t.Fatal(err) - } if _, err := service.Pump(ctx); err != nil { t.Fatal(err) } - state, _, err = store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if wait := state.AwaitingFeedback[QueueKey("owner/repo", 12)]; wait.Head != "abcdef123" { - t.Fatalf("a required-bot review for another commit must not complete the round, got %#v", state.AwaitingFeedback) + if p := roundPhase(t, store, "owner/repo", 12); p != PhaseReviewing { + t.Fatalf("a required-bot review for another commit must not complete the round, got %s", p) } + // The head review completes it. codex := Review{SubmittedAt: firedAt.Add(3 * time.Minute), CommitID: "abcdef1234567890"} codex.User.Login = "chatgpt-codex-connector" gh.reviews[fakeKey("owner/repo", 12)] = []Review{review, staleCodex, codex} - if _, err := store.Update(ctx, func(st *State) error { - st.InFlight = &InFlight{Repo: "owner/repo", PR: 12, Head: "abcdef123", Token: "tok3", Phase: "posted", ReservedAt: firedAt, FiredAt: &firedAt, FiredCommentID: 5} - return nil - }); err != nil { - t.Fatal(err) - } if _, err := service.Pump(ctx); err != nil { t.Fatal(err) } - state, _, err = store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if len(state.AwaitingFeedback) != 0 { - t.Fatalf("once every required bot reviewed the head, the wait must be cleared, got %#v", state.AwaitingFeedback) - } -} - -func TestBotsReviewedHeadCountsPreFireReviews(t *testing.T) { - firedAt := time.Now().UTC() - // Codex reviewed the head before the CodeRabbit round was triggered; the - // round must still count it, exactly as Feedback's ReviewedBy would. - early := Review{SubmittedAt: firedAt.Add(-10 * time.Minute), CommitID: "abcdef1234567890"} - early.User.Login = "chatgpt-codex-connector" - late := Review{SubmittedAt: firedAt.Add(time.Minute), CommitID: "abcdef1234567890"} - late.User.Login = "coderabbitai[bot]" - bots := botSet([]string{"coderabbitai[bot]", "chatgpt-codex-connector"}) - if !botsReviewedHead([]Review{early, late}, bots, "abcdef123", firedAt) { - t.Fatal("a required bot's pre-fire review of the same head must complete the round") - } - other := Review{SubmittedAt: firedAt.Add(time.Minute), CommitID: "0123456789abcdef"} - other.User.Login = "chatgpt-codex-connector" - if botsReviewedHead([]Review{other, late}, bots, "abcdef123", firedAt) { - t.Fatal("a review of a different commit must not complete the round") + if p := roundPhase(t, store, "owner/repo", 12); p != PhaseCompleted { + t.Fatalf("once every required bot reviewed the head, the round must complete, got %s", p) } } -func TestPumpSweepsWaitAfterReactedRoundCompletes(t *testing.T) { +func TestPumpSweepsReviewingRoundToCompletion(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - FeedbackWaitTimeout: time.Hour, - FiredMax: 500, - } + cfg := firingConfig() + cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() - startedAt := time.Now().UTC().Add(-5 * time.Minute) + firedAt := time.Now().UTC().Add(-5 * time.Minute) + var pull Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls[fakeKey("owner/repo", 12)] = pull store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) - if _, err := store.Update(ctx, func(st *State) error { - // The round's in-flight slot was already released on a bot reaction; - // only the wait remains. - st.AwaitingFeedback[QueueKey("owner/repo", 12)] = FeedbackWait{Repo: "owner/repo", PR: 12, Head: "abcdef123", StartedAt: startedAt, Deadline: startedAt.Add(cfg.FeedbackWaitTimeout)} - return nil - }); err != nil { - t.Fatal(err) - } + // A reviewing round whose slot was already released on a bot reaction. + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, firedAt, 5) - // No review submitted yet: the wait must survive the sweep. if _, err := service.Pump(ctx); err != nil { t.Fatal(err) } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if wait := state.AwaitingFeedback[QueueKey("owner/repo", 12)]; wait.Head != "abcdef123" { - t.Fatalf("the wait must survive while the review is still running, got %#v", state.AwaitingFeedback) + if p := roundPhase(t, store, "owner/repo", 12); p != PhaseReviewing { + t.Fatalf("the round must stay reviewing while the review is still running, got %s", p) } - // Once the review lands for the fired head, the next pump sweeps the wait. - review := Review{SubmittedAt: startedAt.Add(2 * time.Minute), CommitID: "abcdef1234567890"} + review := Review{SubmittedAt: firedAt.Add(2 * time.Minute), CommitID: "abcdef1234567890"} review.User.Login = cfg.Bot gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} if _, err := service.Pump(ctx); err != nil { t.Fatal(err) } - state, _, err = store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if len(state.AwaitingFeedback) != 0 { - t.Fatalf("the sweep must clear a wait whose review has been submitted, got %#v", state.AwaitingFeedback) + if p := roundPhase(t, store, "owner/repo", 12); p != PhaseCompleted { + t.Fatalf("the sweep must complete a reviewing round whose review has landed, got %s", p) } } -func TestPumpSweepsWaitAfterCompletionOnlyRoundCompletes(t *testing.T) { +func TestPumpDryRunDoesNotSweepReviewing(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - RequiredBots: []string{"coderabbitai[bot]"}, - ReviewCommand: "@coderabbitai review", - CalibrationMarker: "auto-generated reply by CodeRabbit", - CompletionMarker: "Review finished", - FeedbackWaitTimeout: time.Hour, - FiredMax: 500, - } + cfg := firingConfig() + cfg.DryRun = true + cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() - startedAt := time.Now().UTC().Add(-5 * time.Minute) - command := IssueComment{ID: 5, Body: cfg.ReviewCommand, CreatedAt: startedAt, UpdatedAt: startedAt} - command.User.Login = "kristofferR" - reply := IssueComment{ID: 6, Body: "\nReview finished.", CreatedAt: startedAt.Add(time.Minute), UpdatedAt: startedAt.Add(time.Minute)} - reply.User.Login = cfg.Bot - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{command, reply} - // The completion-only round is a re-review: the bot's earlier review of a - // previous head must exist for the sweep to trust the reply. - prior := Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: startedAt.Add(-time.Hour)} - prior.User.Login = cfg.Bot - gh.reviews[fakeKey("owner/repo", 12)] = []Review{prior} + var pull Pull + pull.State = "open" + pull.Head.SHA = "abcdef1234567890" + gh.pulls[fakeKey("owner/repo", 12)] = pull store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) - if _, err := store.Update(ctx, func(st *State) error { - st.AwaitingFeedback[QueueKey("owner/repo", 12)] = FeedbackWait{Repo: "owner/repo", PR: 12, Head: "abcdef123", StartedAt: startedAt, Deadline: startedAt.Add(cfg.FeedbackWaitTimeout)} - return nil - }); err != nil { + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, time.Now().UTC().Add(-2*time.Hour), 5) + + if _, err := service.Pump(ctx); err != nil { t.Fatal(err) } - - pumped, err := service.Pump(ctx) - if err != nil { - t.Fatal(err) - } - if pumped.Action != "idle" { - t.Fatalf("expected idle pump after sweeping the completed wait, got %#v", pumped) - } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if len(state.AwaitingFeedback) != 0 { - t.Fatalf("the sweep must clear a completion-backed wait, got %#v", state.AwaitingFeedback) - } -} - -func TestPumpDryRunDoesNotPruneWaits(t *testing.T) { - ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - FeedbackWaitTimeout: time.Hour, - FiredMax: 500, - DryRun: true, - } - gh := newFakeGitHub() - store := NewMemoryStore(cfg) - service := NewService(cfg, gh, store, nil) - expired := time.Now().UTC().Add(-2 * time.Hour) - if _, err := store.Update(ctx, func(st *State) error { - st.AwaitingFeedback[QueueKey("owner/repo", 12)] = FeedbackWait{Repo: "owner/repo", PR: 12, Head: "abcdef123", StartedAt: expired, Deadline: expired.Add(time.Hour)} - return nil - }); err != nil { - t.Fatal(err) - } - - if _, err := service.Pump(ctx); err != nil { - t.Fatal(err) - } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if wait := state.AwaitingFeedback[QueueKey("owner/repo", 12)]; wait.Head != "abcdef123" { - t.Fatalf("a dry-run pump must not prune persisted waits, got %#v", state.AwaitingFeedback) - } -} - -func TestPumpPrunesExpiredFeedbackWaits(t *testing.T) { - ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - FeedbackWaitTimeout: time.Hour, - FiredMax: 500, - } - gh := newFakeGitHub() - store := NewMemoryStore(cfg) - service := NewService(cfg, gh, store, nil) - expired := time.Now().UTC().Add(-2 * time.Hour) - live := time.Now().UTC() - if _, err := store.Update(ctx, func(st *State) error { - st.AwaitingFeedback[QueueKey("owner/repo", 12)] = FeedbackWait{Repo: "owner/repo", PR: 12, Head: "abcdef123", StartedAt: expired, Deadline: expired.Add(time.Hour)} - st.AwaitingFeedback[QueueKey("owner/repo", 13)] = FeedbackWait{Repo: "owner/repo", PR: 13, Head: "fedcba321", StartedAt: live, Deadline: live.Add(time.Hour)} - return nil - }); err != nil { - t.Fatal(err) - } - - pumped, err := service.Pump(ctx) - if err != nil { - t.Fatal(err) - } - if pumped.Action != "idle" { - t.Fatalf("expected idle pump, got %#v", pumped) - } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if _, ok := state.AwaitingFeedback[QueueKey("owner/repo", 12)]; ok { - t.Fatalf("expired feedback wait should have been pruned, got %#v", state.AwaitingFeedback) - } - if wait := state.AwaitingFeedback[QueueKey("owner/repo", 13)]; wait.Head != "fedcba321" { - t.Fatalf("live feedback wait must survive pruning, got %#v", state.AwaitingFeedback) - } -} + if p := roundPhase(t, store, "owner/repo", 12); p != PhaseReviewing { + t.Fatalf("a dry-run pump must not sweep/mutate a reviewing round, got %s", p) + } +} func TestPumpTreatsExistingReviewAdoptionRaceAsLostRace(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - MinInterval: 0, - InflightTimeout: time.Minute, - PollInterval: time.Millisecond, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } + cfg := firingConfig() gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) var pull Pull @@ -1683,10 +1249,10 @@ func TestPumpTreatsExistingReviewAdoptionRaceAsLostRace(t *testing.T) { comment := IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: headTime.Add(30 * time.Second), UpdatedAt: headTime.Add(30 * time.Second)} comment.User.Login = "kristofferR" gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} - state := DefaultState(cfg) - state.NextSeq = 1 - state.Queue = []QueueItem{{Seq: 1, Owner: "owner", Repo: "owner/repo", PR: 12, Host: "testhost", EnqueuedAt: headTime}} - service := NewService(cfg, gh, &adoptionRaceStore{cfg: cfg, loadState: state}, nil) + loadState := DefaultState(cfg) + r, _ := loadState.NewRound("owner/repo", 12, "abcdef123", headTime) + loadState.PutRound(*r) + service := NewService(cfg, gh, &adoptionRaceStore{cfg: cfg, loadState: loadState}, nil) pumped, err := service.Pump(ctx) if err != nil { @@ -1700,30 +1266,22 @@ func TestPumpTreatsExistingReviewAdoptionRaceAsLostRace(t *testing.T) { } } -func TestMarkReviewPostedResetsRecordedAcrossRetry(t *testing.T) { - svc := NewService(Config{}, newFakeGitHub(), retryNoChangeStore{}, nil) - _, err := svc.markReviewPosted(context.Background(), "token", QueueItem{Repo: "owner/repo", PR: 12}, "abcdef123", 1, time.Now().UTC()) +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, time.Now().UTC(), time.Now().UTC()) if !errors.Is(err, ErrNoChange) { - t.Fatalf("expected no-change after retry lost the in-flight token, got %v", err) + t.Fatalf("expected no-change after retry lost the fire slot, got %v", err) } } -func TestWaitReenqueuesAfterClearingStaleInflight(t *testing.T) { +func TestWaitReenqueuesAfterClearingStaleRound(t *testing.T) { ctx := context.Background() now := time.Now().UTC().Add(-time.Minute) - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - RateLimitMarker: "rate limited by coderabbit.ai", - MinInterval: 0, - InflightTimeout: time.Hour, - PollInterval: time.Millisecond, - WaitTimeout: time.Second, - FiredMax: 500, - } + cfg := firingConfig() + cfg.InflightTimeout = time.Hour + cfg.WaitTimeout = time.Second gh := newFakeGitHub() var pull Pull pull.State = "open" @@ -1733,13 +1291,8 @@ func TestWaitReenqueuesAfterClearingStaleInflight(t *testing.T) { review.User.Login = "coderabbitai[bot]" gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.InFlight = &InFlight{Repo: "owner/repo", PR: 12, Head: "abcdef123", Token: "old-token", Phase: "posted", FiredAt: &now, FiredCommentID: 7} - st.Fired[QueueKey("owner/repo", 12)] = "abcdef123" - return nil - }); err != nil { - t.Fatal(err) - } + // A stale fired round for a head that has since moved (abcdef123 → abcdef999). + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseFired, now, 7) service := NewService(cfg, gh, store, nil) result, code, err := service.Wait(ctx, "owner/repo", 12) @@ -1747,7 +1300,7 @@ func TestWaitReenqueuesAfterClearingStaleInflight(t *testing.T) { t.Fatal(err) } if code != 0 || result.Action != "fired" || result.Head != "abcdef999" { - t.Fatalf("expected stale in-flight clear to re-enqueue and fire new head, code=%d result=%#v", code, result) + t.Fatalf("expected stale round to be superseded and the new head fired, code=%d result=%#v", code, result) } if len(gh.posted) != 1 { t.Fatalf("expected one review command for the new head, posted=%d", len(gh.posted)) @@ -1755,26 +1308,11 @@ func TestWaitReenqueuesAfterClearingStaleInflight(t *testing.T) { } func TestWaitFiresRealReviewWhenOnlyCarriedThreadVisible(t *testing.T) { - // Regression: a freshly pushed head whose only visible feedback is a - // carried-over unresolved inline thread from an EARLIER commit must trigger a - // real review of the new head, not short-circuit the review-slot wait. Before - // the fix, any finding (including such a stale thread) ended the wait with - // "feedback already available", so the new head was never actually re-reviewed. ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - RequiredBots: []string{"coderabbitai[bot]"}, - ReviewCommand: "@coderabbitai review", - MinInterval: 0, - InflightTimeout: time.Hour, - PollInterval: time.Millisecond, - WaitTimeout: time.Second, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } + cfg := firingConfig() + cfg.RequiredBots = []string{"coderabbitai[bot]"} + cfg.InflightTimeout = time.Hour + cfg.WaitTimeout = time.Second gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) var pull Pull @@ -1784,8 +1322,6 @@ func TestWaitFiresRealReviewWhenOnlyCarriedThreadVisible(t *testing.T) { gc := gitCommit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc - // No bot review of the new head; only an unresolved CodeRabbit thread whose - // comment was filed on an earlier commit. Feedback surfaces it across commits. threadCreated := headTime.Add(-time.Hour).Format(time.RFC3339) gh.graphQL = func(query string, _ map[string]any, out any) error { if strings.Contains(query, "reviewThreads") { @@ -1814,19 +1350,10 @@ func TestWaitFiresRealReviewWhenOnlyCarriedThreadVisible(t *testing.T) { func TestWaitReturnsCurrentHeadFeedbackBeforeReviewSlot(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - RequiredBots: []string{"coderabbitai[bot]"}, - FeedbackBots: []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"}, - ReviewCommand: "@coderabbitai review", - PollInterval: time.Millisecond, - WaitTimeout: time.Second, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } + cfg := firingConfig() + cfg.RequiredBots = []string{"coderabbitai[bot]"} + cfg.FeedbackBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"} + cfg.WaitTimeout = time.Second gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) var pull Pull @@ -1836,12 +1363,7 @@ func TestWaitReturnsCurrentHeadFeedbackBeforeReviewSlot(t *testing.T) { gc := gitCommit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc - comment := IssueComment{ - ID: 91, - Body: "Actionable Codex finding on the queued head", - CreatedAt: headTime.Add(time.Second), - UpdatedAt: headTime.Add(time.Second), - } + comment := IssueComment{ID: 91, Body: "Actionable Codex finding on the queued head", CreatedAt: headTime.Add(time.Second), UpdatedAt: headTime.Add(time.Second)} comment.User.Login = "chatgpt-codex-connector[bot]" gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} service := NewService(cfg, gh, NewMemoryStore(cfg), nil) @@ -1859,25 +1381,12 @@ func TestWaitReturnsCurrentHeadFeedbackBeforeReviewSlot(t *testing.T) { } func TestWaitFiresRealReviewWhenOnlyCarriedReviewPromptVisible(t *testing.T) { - // Review-body prompts intentionally remain visible after a head change until a - // newer review supersedes them. They must not be mistaken for feedback on the - // new head or the replacement review will never be requested. ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - RequiredBots: []string{"coderabbitai[bot]"}, - FeedbackBots: []string{"coderabbitai[bot]"}, - ReviewCommand: "@coderabbitai review", - MinInterval: 0, - InflightTimeout: time.Hour, - PollInterval: time.Millisecond, - WaitTimeout: time.Second, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } + cfg := firingConfig() + cfg.RequiredBots = []string{"coderabbitai[bot]"} + cfg.FeedbackBots = []string{"coderabbitai[bot]"} + cfg.InflightTimeout = time.Hour + cfg.WaitTimeout = time.Second gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) var pull Pull @@ -1910,23 +1419,13 @@ func TestWaitFiresRealReviewWhenOnlyCarriedReviewPromptVisible(t *testing.T) { } } -func TestWaitRepairsPoisonedFiredMarkerWithOnlyCarriedReviewPrompt(t *testing.T) { +func TestWaitRepairsPoisonedCompletedRoundWithOnlyCarriedReviewPrompt(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - RequiredBots: []string{"coderabbitai[bot]"}, - FeedbackBots: []string{"coderabbitai[bot]"}, - ReviewCommand: "@coderabbitai review", - MinInterval: 0, - InflightTimeout: time.Hour, - PollInterval: time.Millisecond, - WaitTimeout: time.Second, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } + cfg := firingConfig() + cfg.RequiredBots = []string{"coderabbitai[bot]"} + cfg.FeedbackBots = []string{"coderabbitai[bot]"} + cfg.InflightTimeout = time.Hour + cfg.WaitTimeout = time.Second gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) var pull Pull @@ -1945,12 +1444,8 @@ func TestWaitRepairsPoisonedFiredMarkerWithOnlyCarriedReviewPrompt(t *testing.T) stale.User.Login = "coderabbitai[bot]" gh.reviews[fakeKey("owner/repo", 12)] = []Review{stale} store := NewMemoryStore(cfg) - if _, err := store.Update(ctx, func(st *State) error { - st.Fired[QueueKey("owner/repo", 12)] = "abcdef123" - return nil - }); err != nil { - t.Fatal(err) - } + // A poisoned completed round at the head with no real head review. + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseCompleted, headTime, 0) service := NewService(cfg, gh, store, nil) result, code, err := service.Wait(ctx, "owner/repo", 12) @@ -1958,33 +1453,14 @@ func TestWaitRepairsPoisonedFiredMarkerWithOnlyCarriedReviewPrompt(t *testing.T) t.Fatal(err) } if code != 0 || result.Action != "fired" { - t.Fatalf("a poisoned fired marker must be repaired before firing the real review, code=%d result=%#v", code, result) + t.Fatalf("a poisoned completed round must be repaired before firing the real review, code=%d result=%#v", code, result) } if len(gh.posted) != 1 { t.Fatalf("expected one replacement review command, posted=%d", len(gh.posted)) } } -func TestBotReviewedHeadToleratesBotSuffix(t *testing.T) { - // CRQ_BOT configured suffix-less, but REST reviews come back as coderabbitai[bot]. - cfg := Config{Bot: "coderabbitai", GateRepo: "o/gate", Scope: []string{"o"}} - gh := newFakeGitHub() - review := Review{CommitID: "abcdef1234567890"} - review.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("o/repo", 5)] = []Review{review} - svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) - - ok, err := svc.botReviewedHead(context.Background(), "o/repo", 5, "abcdef123") - if err != nil { - t.Fatal(err) - } - if !ok { - t.Fatal("a suffix-less CRQ_BOT must still dedupe against a coderabbitai[bot] REST review of the head") - } -} - func TestNeedsReviewToleratesBotSuffix(t *testing.T) { - // CRQ_BOT configured suffix-less, but REST reviews/comments come back as coderabbitai[bot]. cfg := Config{Bot: "coderabbitai", GateRepo: "o/gate", Scope: []string{"o"}, ReviewDoneMarker: "summarize by coderabbit.ai"} gh := newFakeGitHub() var pull Pull @@ -1996,7 +1472,7 @@ func TestNeedsReviewToleratesBotSuffix(t *testing.T) { gh.reviews[fakeKey("o/repo", 5)] = []Review{review} svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) - need, err := svc.needsReview(context.Background(), State{}, "o/repo", 5, true) + need, _, err := svc.needsReview(context.Background(), DefaultState(cfg), "o/repo", 5, true) if err != nil { t.Fatal(err) } @@ -2008,7 +1484,7 @@ func TestNeedsReviewToleratesBotSuffix(t *testing.T) { comment := IssueComment{Body: "finished; summarize by coderabbit.ai"} comment.User.Login = "coderabbitai[bot]" gh.comments[fakeKey("o/repo", 5)] = []IssueComment{comment} - need, err = svc.needsReview(context.Background(), State{}, "o/repo", 5, false) + need, _, err = svc.needsReview(context.Background(), DefaultState(cfg), "o/repo", 5, false) if err != nil { t.Fatal(err) } @@ -2017,21 +1493,56 @@ func TestNeedsReviewToleratesBotSuffix(t *testing.T) { } } -func TestPumpDropsClosedPRWithoutFiring(t *testing.T) { +func TestNeedsReviewSkipsTrackedHeadButNotNewHead(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - RateLimitMarker: "rate limited by coderabbit.ai", - PollInterval: time.Millisecond, - InflightTimeout: time.Minute, - FiredMax: 500, + cfg := Config{Bot: "coderabbitai", Host: "h"} + gh := newFakeGitHub() + head := "a0646f010" + pull := Pull{State: "open"} + pull.Head.SHA = head + "aaaaaa0" + gh.pulls[fakeKey("o/carrier", 82)] = pull + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + // A round parked awaiting retry at the current head: Pump owns re-firing it, so + // autoreview must not re-enqueue. + seedRound(t, store, cfg, "o/carrier", 82, head, PhaseFired, time.Now().UTC(), 1) + if _, err := store.Update(ctx, func(st *State) error { + r := st.Round("o/carrier", 82) + if err := r.AwaitRetry(time.Now().UTC().Add(40*time.Minute), "rate limited", time.Now().UTC()); err != nil { + return err + } + st.PutRound(*r) + st.FireSlot = nil + return nil + }); err != nil { + t.Fatal(err) + } + st, _, _ := store.Load(ctx) + + need, _, err := svc.needsReview(ctx, st, "o/carrier", 82, true) + if err != nil { + t.Fatal(err) + } + if need { + t.Fatal("needsReview must skip a head already tracked by an awaiting_retry round") + } + + // A new head is not blocked by the prior head's parked round. + pull.Head.SHA = "bbbbbbbbbccccc" + gh.pulls[fakeKey("o/carrier", 82)] = pull + need, _, err = svc.needsReview(ctx, st, "o/carrier", 82, true) + if err != nil { + t.Fatal(err) + } + if !need { + t.Fatal("a new head must not be blocked by a prior head's parked round") } +} + +func TestPumpDropsClosedPRWithoutFiring(t *testing.T) { + ctx := context.Background() + cfg := firingConfig() gh := newFakeGitHub() - // PR queued while open, then closed/merged before it reached the front. var pull Pull pull.State = "closed" pull.Merged = true @@ -2053,44 +1564,28 @@ func TestPumpDropsClosedPRWithoutFiring(t *testing.T) { if len(gh.posted) != 0 { t.Fatalf("must not post a review to a closed PR, posted %d", len(gh.posted)) } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if state.Contains("owner/repo", 12) { + if containsActiveRound(store, t, "owner/repo", 12) { t.Fatal("closed PR should have been removed from the queue") } } func TestPumpDropsClosedPRWhileReviewQuotaIsBlocked(t *testing.T) { ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - PollInterval: time.Millisecond, - InflightTimeout: time.Minute, - FiredMax: 500, - } + cfg := firingConfig() gh := newFakeGitHub() var pull Pull pull.State = "closed" pull.Merged = true - // A merged PR with a deleted head must still be removable; no head SHA is - // needed once GitHub says the PR is terminal. gh.pulls[fakeKey("owner/repo", 12)] = pull store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) - if _, err := service.Enqueue(ctx, "owner/repo", 12); err != nil { - t.Fatal(err) - } + // The round was enqueued while open; the PR is now merged with a deleted head. + seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseQueued, time.Now().UTC(), 0) blockedUntil := time.Now().UTC().Add(time.Hour) if _, err := store.Update(ctx, func(st *State) error { - st.Blocked.BlockedUntil = &blockedUntil - st.Blocked.Source = "calibrate" + st.Account.BlockedUntil = &blockedUntil + st.Account.Source = "calibrate" return nil }); err != nil { t.Fatal(err) @@ -2103,11 +1598,7 @@ func TestPumpDropsClosedPRWhileReviewQuotaIsBlocked(t *testing.T) { if pumped.Action != "skipped" || pumped.Reason != "pr closed" { t.Fatalf("expected merged PR cleanup to bypass the quota block, got %#v", pumped) } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if state.Contains("owner/repo", 12) { + if containsActiveRound(store, t, "owner/repo", 12) { t.Fatal("merged PR should be removed even while review quota is blocked") } if len(gh.posted) != 0 { @@ -2115,127 +1606,26 @@ func TestPumpDropsClosedPRWhileReviewQuotaIsBlocked(t *testing.T) { } } -func TestPumpDedupesQueuedHeadAwaitingFeedback(t *testing.T) { - ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - PollInterval: time.Millisecond, - InflightTimeout: time.Minute, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } - gh := newFakeGitHub() - var pull Pull - pull.State = "open" - pull.Head.SHA = "abcdef1234567890" - gh.pulls[fakeKey("owner/repo", 12)] = pull - store := NewMemoryStore(cfg) - started := time.Now().UTC() - if _, err := store.Update(ctx, func(st *State) error { - st.NextSeq = 1 - st.Queue = []QueueItem{{Seq: 1, Owner: "owner", Repo: "owner/repo", PR: 12, Host: "testhost", EnqueuedAt: started}} - st.AwaitingFeedback[QueueKey("owner/repo", 12)] = FeedbackWait{ - Repo: "owner/repo", - PR: 12, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(cfg.FeedbackWaitTimeout), - } - delete(st.Fired, QueueKey("owner/repo", 12)) // tolerate older/corrupt state missing the fired marker - return nil - }); err != nil { - t.Fatal(err) - } - service := NewService(cfg, gh, store, nil) - - result, err := service.Pump(ctx) - if err != nil { - t.Fatal(err) - } - if result.Action != "deduped" || result.Head != "abcdef123" { - t.Fatalf("expected pump to dedupe the queued awaiting head, got %#v", result) - } - if len(gh.posted) != 0 { - t.Fatalf("pump must not post another review for an awaiting head, posted=%d", len(gh.posted)) - } - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if len(state.Queue) != 0 { - t.Fatalf("deduped awaiting item should be removed from the queue, got %#v", state.Queue) - } - if state.Fired[QueueKey("owner/repo", 12)] != "abcdef123" { - t.Fatalf("pump should restore the fired marker from awaiting feedback: %#v", state.Fired) - } -} - -func TestPumpDedupeRevalidatesCurrentWaitMarker(t *testing.T) { - ctx := context.Background() - cfg := Config{ - GateRepo: "owner/gate", - StateRef: "crq-state", - Host: "testhost", - Bot: "coderabbitai[bot]", - ReviewCommand: "@coderabbitai review", - PollInterval: time.Millisecond, - InflightTimeout: time.Minute, - FeedbackWaitTimeout: time.Minute, - FiredMax: 500, - } - gh := newFakeGitHub() - var pull Pull - pull.State = "open" - pull.Head.SHA = "abcdef1234567890" - gh.pulls[fakeKey("owner/repo", 12)] = pull - started := time.Now().UTC() - loadState := DefaultState(cfg) - loadState.NextSeq = 1 - loadState.Queue = []QueueItem{{Seq: 1, Owner: "owner", Repo: "owner/repo", PR: 12, Host: "testhost", EnqueuedAt: started}} - loadState.AwaitingFeedback[QueueKey("owner/repo", 12)] = FeedbackWait{ - Repo: "owner/repo", - PR: 12, - Head: "abcdef123", - StartedAt: started, - Deadline: started.Add(cfg.FeedbackWaitTimeout), - } - updateState := DefaultState(cfg) - updateState.NextSeq = 1 - updateState.Queue = append([]QueueItem(nil), loadState.Queue...) - service := NewService(cfg, gh, &staleDedupeStore{cfg: cfg, loadState: loadState, updateState: updateState}, nil) - - result, err := service.Pump(ctx) +func containsActiveRound(store StateStore, t *testing.T, repo string, pr int) bool { + t.Helper() + st, _, err := store.Load(context.Background()) if err != nil { t.Fatal(err) } - if result.Action != "lost_race" { - t.Fatalf("stale wait marker must not report a successful dedupe, got %#v", result) - } - if len(gh.posted) != 0 { - t.Fatalf("stale dedupe race must not post another review command, posted=%d", len(gh.posted)) - } + return containsActive(&st, repo, pr) } -func TestEnqueueDedupesAlreadyFiredHead(t *testing.T) { +func TestEnqueueDedupesAlreadyReviewedHead(t *testing.T) { ctx := context.Background() - cfg := Config{GateRepo: "owner/gate", StateRef: "crq-state", Host: "testhost", FiredMax: 500} + cfg := firingConfig() gh := newFakeGitHub() var pull Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls["owner/repo#7"] = pull store := NewMemoryStore(cfg) - _, err := store.Update(ctx, func(st *State) error { - st.Fired[QueueKey("owner/repo", 7)] = "abcdef123" - return nil - }) - if err != nil { - t.Fatal(err) - } + // A completed round at the head is the dedup marker. + seedRound(t, store, cfg, "owner/repo", 7, "abcdef123", PhaseCompleted, time.Now().UTC(), 1) service := NewService(cfg, gh, store, nil) result, err := service.Enqueue(ctx, "owner/repo", 7) if err != nil { @@ -2246,119 +1636,11 @@ func TestEnqueueDedupesAlreadyFiredHead(t *testing.T) { } } -func TestMemoryStoreConcurrentUpdatesDoNotLoseMutations(t *testing.T) { +func TestRateLimitedRoundParksAndBlocksAccount(t *testing.T) { ctx := context.Background() - cfg := Config{GateRepo: "owner/gate", StateRef: "crq-state", FiredMax: 500} - store := NewMemoryStore(cfg) - var wg sync.WaitGroup - for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() - _, err := store.Update(ctx, func(st *State) error { - st.NextSeq++ - return nil - }) - if err != nil { - t.Errorf("update failed: %v", err) - } - }() - } - wg.Wait() - state, _, err := store.Load(ctx) - if err != nil { - t.Fatal(err) - } - if state.NextSeq != 50 { - t.Fatalf("lost updates: got %d want 50", state.NextSeq) - } -} - -// --- Runaway re-fire loop regression tests (carrier#82) --- - -func TestParseAvailableInHandlesMarkdownAndColon(t *testing.T) { - base := time.Date(2026, 7, 11, 18, 24, 0, 0, time.UTC) - // Verbatim of CodeRabbit's current rate-limit body: a colon and bold markers - // sit between "available in" and the duration. The old parser required a - // literal "available in " (trailing space) and choked on "**40", yielding no - // reset — which dropped the block to a ~2m fallback and drove the runaway. - body := "## Review limit reached\n\nYou have reached your review limit.\n\n**Next review available in:** **40 minutes**" - got := parseAvailableIn(body, base) - if got == nil { - t.Fatal("expected a parsed reset for the verbatim rate-limit body, got nil") - } - if want := base.Add(40 * time.Minute); !got.Equal(want) { - t.Fatalf("expected reset %v (base+40m), got %v", want, *got) - } -} - -func TestParseAvailableInPlainFormatStillWorks(t *testing.T) { - base := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) - got := parseAvailableIn("You are rate limited. Reviews available in 3 minutes.", base) - if got == nil || !got.Equal(base.Add(3*time.Minute)) { - t.Fatalf("expected base+3m for the plain format, got %v", got) - } - got = parseAvailableIn("available in 1 hour and 30 minutes", base) - if got == nil || !got.Equal(base.Add(90*time.Minute)) { - t.Fatalf("expected base+90m for compound duration, got %v", got) - } -} - -func TestInflightStatusAlreadyReviewedAckWithoutReviewDoesNotOverrideRateLimit(t *testing.T) { - now := time.Now().UTC() - cfg := Config{Bot: "coderabbitai", RateLimitMarker: "rate limited by coderabbit.ai", InflightTimeout: time.Hour} - gh := newFakeGitHub() - // Incident shape from ha-adjustable-bed#435: CodeRabbit rate-limited the first - // attempt, then answered the explicit command with misleading already-reviewed - // boilerplate even though GitHub had no review for the PR. - rl := IssueComment{ID: 1, Body: "\n> ## Review limit reached\n> **Next review available in:** **40 minutes**", UpdatedAt: now.Add(time.Second)} - rl.User.Login = "coderabbitai[bot]" - ack := IssueComment{ID: 2, Body: "
\n✅ Action performed\n\nReview finished.\n\n> Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.\n
", UpdatedAt: now.Add(2 * time.Second)} - ack.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/repo", 82)] = []IssueComment{rl, ack} - svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) - // Guard the premises: the two real CodeRabbit comments are cleanly separable — - // the rate-limit notice carries the RL marker but not the terminal note, and - // the ack the reverse. - if !svc.isRateLimited(rl.Body) || svc.isReviewAlreadyDone(rl.Body) { - t.Fatal("rate-limit comment must classify as rate-limited, not terminal") - } - if svc.isRateLimited(ack.Body) || !svc.isReviewAlreadyDone(ack.Body) { - t.Fatal("ack must classify as terminal already-reviewed, not rate-limited") - } - state := State{InFlight: &InFlight{Repo: "o/repo", PR: 82, Head: "a0646f010", Phase: "posted", FiredAt: &now, FiredCommentID: 99}} - status, err := svc.inflightStatus(context.Background(), state) - if err != nil { - t.Fatal(err) - } - if !status.Requeue || status.Reason != warnRateLimited { - t.Fatalf("an unproven acknowledgement must yield to the rate limit, got %#v", status) - } - if status.Done { - t.Fatal("an acknowledgement with no matching GitHub review must not complete the round") - } - - // Once GitHub actually has a review for the claimed head, the acknowledgement - // is trustworthy and wins over the stale rate-limit comment. - review := Review{CommitID: "a0646f010abcdef", SubmittedAt: now.Add(-time.Minute)} - review.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("o/repo", 82)] = []Review{review} - status, err = svc.inflightStatus(context.Background(), state) - if err != nil { - t.Fatal(err) - } - if !status.Done || status.Requeue || status.Reason != doneAlreadyReviewed { - t.Fatalf("a matching GitHub review should validate the acknowledgement, got %#v", status) - } -} - -func TestRateLimitedAlreadyReviewedAckWithoutReviewRequeues(t *testing.T) { - ctx := context.Background() - cfg := Config{ - GateRepo: "o/gate", Scope: []string{"o"}, Host: "h", - Bot: "coderabbitai", RateLimitMarker: "rate limited by coderabbit.ai", - ReviewCommand: "@coderabbitai review", InflightTimeout: time.Hour, - } + cfg := firingConfig() + cfg.Bot = "coderabbitai" + cfg.InflightTimeout = time.Hour gh := newFakeGitHub() head := "a0646f010" pull := Pull{State: "open"} @@ -2379,7 +1661,7 @@ func TestRateLimitedAlreadyReviewedAckWithoutReviewRequeues(t *testing.T) { } // CodeRabbit answers with a rate-limit comment and an already-reviewed claim, - // but no review object. The PR must remain eligible for a real later attempt. + // but no review object. The round must park (retry later), not complete. answer := time.Now().UTC().Add(time.Minute) rl := IssueComment{ID: 501, Body: "\n> ## Review limit reached\n> **Next review available in:** **40 minutes**", CreatedAt: answer, UpdatedAt: answer} rl.User.Login = "coderabbitai[bot]" @@ -2395,14 +1677,17 @@ func TestRateLimitedAlreadyReviewedAckWithoutReviewRequeues(t *testing.T) { t.Fatalf("expected rate-limited requeue, got %#v", res) } st, _, _ := store.Load(ctx) - if st.Fired[QueueKey("o/carrier", 82)] != "" { - t.Fatalf("a false completion must not retain a fired marker, got %#v", st.Fired) + if r := st.Round("o/carrier", 82); r == nil || r.Phase != PhaseAwaitingRetry { + t.Fatalf("a rate-limited round must park awaiting retry, got %#v", r) } - if st.Blocked.BlockedUntil == nil { + if firedMarker(&st, "o/carrier", 82) != "" { + t.Fatalf("a parked round must not be a dedup marker") + } + if st.Account.BlockedUntil == nil { t.Fatal("the real rate-limit response must block the next attempt") } - if !st.Contains("o/carrier", 82) { - t.Fatal("the unreviewed PR must remain queued") + if !containsActive(&st, "o/carrier", 82) { + t.Fatal("the unreviewed PR must remain active") } if _, err := svc.Pump(ctx); err != nil { @@ -2419,85 +1704,6 @@ func TestRateLimitedAlreadyReviewedAckWithoutReviewRequeues(t *testing.T) { } } -func TestNeedsReviewSkipsCooledDownHead(t *testing.T) { - ctx := context.Background() - cfg := Config{Bot: "coderabbitai", Host: "h"} - gh := newFakeGitHub() - head := "a0646f010" - pull := Pull{State: "open"} - pull.Head.SHA = head + "aaaaaa0" - gh.pulls[fakeKey("o/carrier", 82)] = pull - svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) - - st := DefaultState(cfg) - st.Cooldown[QueueKey("o/carrier", 82)] = FireCooldown{Head: head, Until: time.Now().UTC().Add(40 * time.Minute)} - - need, err := svc.needsReview(ctx, st, "o/carrier", 82, true) - if err != nil { - t.Fatal(err) - } - if need { - t.Fatal("needsReview must skip a head under an active fire cooldown") - } - - // A new head is not blocked by the prior head's cooldown. - pull.Head.SHA = "bbbbbbbbbccccc" - gh.pulls[fakeKey("o/carrier", 82)] = pull - need, err = svc.needsReview(ctx, st, "o/carrier", 82, true) - if err != nil { - t.Fatal(err) - } - if !need { - t.Fatal("a new head must not be blocked by a prior head's cooldown") - } -} - -func TestRequeueRateLimitSetsCooldownAndDoesNotExtendSameComment(t *testing.T) { - cfg := Config{GateRepo: "o/gate", Scope: []string{"o"}, RateLimitFallback: 15 * time.Minute, Host: "h"} - svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - - reset := time.Now().UTC().Add(40 * time.Minute) - st := &State{ - InFlight: &InFlight{Repo: "o/a", PR: 1, Head: "abc123def", Seq: 5}, - Fired: map[string]string{"o/a#1": "abc123def"}, - Cooldown: map[string]FireCooldown{}, - } - svc.requeueInflight(st, inflightCheck{Requeue: true, Reason: warnRateLimited, BlockedUntil: &reset, RLCommentID: 77, RLCommentUpdated: time.Now().UTC()}) - - cd, ok := st.Cooldown["o/a#1"] - if !ok || cd.Head != "abc123def" || !cd.Until.Equal(reset) { - t.Fatalf("expected cooldown until reset for the fired head, got %#v (ok=%v)", cd, ok) - } - if st.Blocked.RLCommentID != 77 { - t.Fatalf("expected rate-limit comment id tracked, got %d", st.Blocked.RLCommentID) - } - firstUntil := *st.Blocked.BlockedUntil - - // Re-observe the SAME edited rate-limit comment with an advanced UpdatedAt and a - // later parsed window: the standing block must not be pushed out again. - st.InFlight = &InFlight{Repo: "o/a", PR: 1, Head: "abc123def", Seq: 6} - st.Fired = map[string]string{"o/a#1": "abc123def"} - later := reset.Add(30 * time.Minute) - svc.requeueInflight(st, inflightCheck{Requeue: true, Reason: warnRateLimited, BlockedUntil: &later, RLCommentID: 77, RLCommentUpdated: time.Now().UTC()}) - if !st.Blocked.BlockedUntil.Equal(firstUntil) { - t.Fatalf("re-observed same rate-limit comment must reuse standing block %v, got %v", firstUntil, *st.Blocked.BlockedUntil) - } -} - -func TestRequeueRateLimitNoResetUsesFallbackNotShortTTL(t *testing.T) { - cfg := Config{GateRepo: "o/gate", Scope: []string{"o"}, CalibrationTTL: 2 * time.Minute, RateLimitFallback: 15 * time.Minute, Host: "h"} - svc := NewService(cfg, newFakeGitHub(), NewMemoryStore(cfg), nil) - now := time.Now().UTC() - st := &State{InFlight: &InFlight{Repo: "o/a", PR: 1, Head: "abc123def"}, Fired: map[string]string{}, Cooldown: map[string]FireCooldown{}} - svc.requeueInflight(st, inflightCheck{Requeue: true, Reason: warnRateLimited}) - if st.Blocked.BlockedUntil == nil { - t.Fatal("expected a block window on an unparseable rate limit") - } - if got := st.Blocked.BlockedUntil.Sub(now); got < 10*time.Minute { - t.Fatalf("unparseable rate limit should fall back to the conservative window, got %v", got) - } -} - func TestRotateCalibrationPersistsNewIssue(t *testing.T) { ctx := context.Background() cfg := Config{GateRepo: "o/gate", CalibrationPR: 1, Scope: []string{"o"}} @@ -2524,21 +1730,54 @@ func TestRotateCalibrationPersistsNewIssue(t *testing.T) { } } -func TestNormalizePrunesExpiredCooldowns(t *testing.T) { - cfg := Config{Scope: []string{"o"}} - st := DefaultState(cfg) - now := time.Now().UTC() - st.Cooldown["o/a#1"] = FireCooldown{Head: "aaa", Until: now.Add(30 * time.Minute)} // live - st.Cooldown["o/b#2"] = FireCooldown{Head: "bbb", Until: now.Add(-time.Minute)} // expired - st.Cooldown["o/c#3"] = FireCooldown{Head: "", Until: now.Add(time.Hour)} // headless - st.Normalize(cfg) - if _, ok := st.Cooldown["o/a#1"]; !ok { - t.Fatal("live cooldown must survive Normalize") - } - if _, ok := st.Cooldown["o/b#2"]; ok { - t.Fatal("expired cooldown must be pruned") - } - if _, ok := st.Cooldown["o/c#3"]; ok { - t.Fatal("headless cooldown must be pruned") +func TestMemoryStoreConcurrentUpdatesDoNotLoseMutations(t *testing.T) { + ctx := context.Background() + cfg := Config{GateRepo: "owner/gate", StateRef: "crq-state-v3"} + store := NewMemoryStore(cfg) + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := store.Update(ctx, func(st *State) error { + st.NextSeq++ + return nil + }) + if err != nil { + t.Errorf("update failed: %v", err) + } + }() + } + wg.Wait() + state, _, err := store.Load(ctx) + if err != nil { + t.Fatal(err) + } + if state.NextSeq != 50 { + t.Fatalf("lost updates: got %d want 50", state.NextSeq) + } +} + +func TestParseAvailableInHandlesMarkdownAndColon(t *testing.T) { + base := time.Date(2026, 7, 11, 18, 24, 0, 0, time.UTC) + body := "## Review limit reached\n\nYou have reached your review limit.\n\n**Next review available in:** **40 minutes**" + got := parseAvailableIn(body, base) + if got == nil { + t.Fatal("expected a parsed reset for the verbatim rate-limit body, got nil") + } + if want := base.Add(40 * time.Minute); !got.Equal(want) { + t.Fatalf("expected reset %v (base+40m), got %v", want, *got) + } +} + +func TestParseAvailableInPlainFormatStillWorks(t *testing.T) { + base := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) + got := parseAvailableIn("You are rate limited. Reviews available in 3 minutes.", base) + if got == nil || !got.Equal(base.Add(3*time.Minute)) { + t.Fatalf("expected base+3m for the plain format, got %v", got) + } + got = parseAvailableIn("available in 1 hour and 30 minutes", base) + if got == nil || !got.Equal(base.Add(90*time.Minute)) { + t.Fatalf("expected base+90m for compound duration, got %v", got) } } diff --git a/internal/crq/state.go b/internal/crq/state.go index 76dc3bc..569180c 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -1,299 +1,94 @@ package crq import ( - "crypto/sha1" - "encoding/hex" - "encoding/json" "fmt" - "sort" - "strconv" "strings" "time" -) -const ( - statePath = "state.json" - dashboardPath = "dashboard.md" - stateBegin = "" + "github.com/kristofferR/coderabbit-queue/internal/engine" + crqstate "github.com/kristofferR/coderabbit-queue/internal/state" ) -type State struct { - Version int `json:"v"` - Rev int64 `json:"rev"` - NextSeq int64 `json:"next_seq"` - Queue []QueueItem `json:"queue"` - InFlight *InFlight `json:"in_flight"` - LastFired *time.Time `json:"last_fired"` - Warn string `json:"warn,omitempty"` - Fired map[string]string `json:"fired"` - Cooldown map[string]FireCooldown `json:"cooldown,omitempty"` - AwaitingFeedback map[string]FeedbackWait `json:"awaiting_feedback,omitempty"` - History []HistoryItem `json:"history"` - Blocked Blocked `json:"blocked"` - Leader *LeaderLease `json:"leader,omitempty"` - // CalibrationIssue overrides the configured calibration PR/issue when the - // original hit GitHub's hard 2500-comment cap and crq rotated to a fresh one. - // Persisted in the shared state so the whole fleet uses the new issue. - CalibrationIssue int `json:"calibration_issue,omitempty"` - GCAt *time.Time `json:"gc_at,omitempty"` - UpdatedAt *time.Time `json:"wrote_at,omitempty"` - DashboardSHA string `json:"dashboard_sha,omitempty"` -} +// The persisted schema and its store live in internal/state (v3). These +// aliases keep the crq orchestration code referring to State/Round/… without +// the package qualifier, and without colliding with the many `state`/`st` +// variable names in this package. +type ( + State = crqstate.State + Round = crqstate.Round + Phase = crqstate.Phase + FireSlot = crqstate.FireSlot + AccountQuota = crqstate.AccountQuota + LeaderLease = crqstate.LeaderLease + Revision = crqstate.Revision + StateStore = crqstate.StateStore + StoreConfig = crqstate.StoreConfig +) -// FireCooldown records the head last fired for a queue key and the earliest time -// that head may be fired again. Unlike Fired (which is cleared on a rate-limited -// requeue so a genuinely throttled PR can retry once the window clears), a -// cooldown survives the requeue, so needsReview refuses to re-enqueue the same -// repo#pr@head until the block window passes — the guard that stops a bouncing -// rate limit from re-firing the same head over and over. Cooldowns are pruned -// once expired (see Normalize), so the map stays bounded. -type FireCooldown struct { - Head string `json:"head"` - Until time.Time `json:"until"` -} +const ( + PhaseQueued = crqstate.PhaseQueued + PhaseReserved = crqstate.PhaseReserved + PhaseFired = crqstate.PhaseFired + PhaseReviewing = crqstate.PhaseReviewing + PhaseAwaitingRetry = crqstate.PhaseAwaitingRetry + PhaseCompleted = crqstate.PhaseCompleted + PhaseAbandoned = crqstate.PhaseAbandoned +) -type QueueItem struct { - Seq int64 `json:"seq"` - Owner string `json:"owner"` - Repo string `json:"repo"` - PR int `json:"pr"` - Host string `json:"host"` - EnqueuedAt time.Time `json:"enqueued_at"` - // RequeuedAt is set when the item re-enters the queue after an abandoned - // fire (rate limit, in-flight timeout). Command comments older than it must - // not be adopted as an already-posted review. - RequeuedAt *time.Time `json:"requeued_at,omitempty"` -} +var ( + ErrCASConflict = crqstate.ErrCASConflict + ErrNoChange = crqstate.ErrNoChange + cloneState = crqstate.Clone +) -// adoptCutoff returns the earliest comment timestamp Pump may adopt as an -// existing review command for this item; zero means no lower bound. -func (q QueueItem) adoptCutoff() time.Time { - if q.RequeuedAt != nil { - return q.RequeuedAt.UTC() +func (c Config) storeConfig() StoreConfig { + return StoreConfig{ + GateRepo: c.GateRepo, + StateRef: c.StateRef, + DashboardIssue: c.DashboardIssue, + Timezone: c.Timezone, + Scope: c.Scope, } - return time.Time{} } -type InFlight struct { - Seq int64 `json:"seq"` - Repo string `json:"repo"` - PR int `json:"pr"` - Head string `json:"head,omitempty"` - Token string `json:"token"` - Phase string `json:"phase"` - ReservedAt time.Time `json:"reserved_at"` - FiredAt *time.Time `json:"fired_at,omitempty"` - FiredCommentID int64 `json:"fired_comment_id,omitempty"` - ByHost string `json:"by_host"` +// 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 *GitHub, log Logger) *crqstate.GitStateStore { + return crqstate.NewGitStateStore(cfg.storeConfig(), gh, log) } -type FeedbackWait struct { - Repo string `json:"repo"` - PR int `json:"pr"` - Head string `json:"head"` - StartedAt time.Time `json:"started_at"` - Deadline time.Time `json:"deadline"` - FiredCommentID int64 `json:"fired_comment_id,omitempty"` - ByHost string `json:"by_host,omitempty"` +func NewMemoryStore(cfg Config) *crqstate.MemoryStore { + return crqstate.NewMemoryStore(cfg.storeConfig()) } -type HistoryItem struct { - Repo string `json:"repo"` - PR int `json:"pr"` - Commit string `json:"commit,omitempty"` - At time.Time `json:"at"` - Host string `json:"host"` +// DefaultState returns a fresh v3 state seeded with the configured scope, used +// by tests and init. +func DefaultState(cfg Config) State { + st := crqstate.New() + st.Account.Scope = strings.Join(cfg.Scope, ",") + st.Account.Source = "init" + return st } -type Blocked struct { - Scope string `json:"scope"` - BlockedUntil *time.Time `json:"blocked_until"` - Remaining *int `json:"remaining"` - Source string `json:"source"` - CheckedAt *time.Time `json:"checked_at"` - CalibAskedAt *time.Time `json:"calib_asked_at"` - // RLCommentID/RLCommentUpdated identify the rate-limit comment whose "next - // review available in" window produced the current block. CodeRabbit edits a - // single rate-limit comment in place instead of posting a new one, so its - // UpdatedAt advances past every later fire; tracking it lets a re-observed - // edit reuse the standing block instead of being counted as a fresh event - // that extends the window on every bounce. - RLCommentID int64 `json:"rl_comment_id,omitempty"` - RLCommentUpdated *time.Time `json:"rl_comment_updated,omitempty"` +func renderDashboard(st State, cfg Config) string { + return crqstate.RenderDashboard(st, cfg.storeConfig()) } - -type LeaderLease struct { - Owner string `json:"owner"` - Token string `json:"token"` - ExpiresAt time.Time `json:"expires_at"` - UpdatedAt time.Time `json:"updated_at"` +func renderTitle(st State) string { return crqstate.RenderTitle(st) } +func issueBody(st State, cfg Config) (string, error) { + return crqstate.IssueBody(st, cfg.storeConfig()) } -func DefaultState(cfg Config) State { - return State{ - Version: 1, - Rev: 0, - Fired: map[string]string{}, - Cooldown: map[string]FireCooldown{}, - AwaitingFeedback: map[string]FeedbackWait{}, - Blocked: Blocked{Scope: strings.Join(cfg.Scope, ","), Source: "init"}, +// policy assembles the engine Policy from config. +func (s *Service) policy() engine.Policy { + return engine.Policy{ + Bot: s.cfg.Bot, + RequiredBots: s.cfg.RequiredBots, + MinInterval: s.cfg.MinInterval, + InflightTimeout: s.cfg.InflightTimeout, + RateLimitFallback: s.cfg.RateLimitFallback, } } -func (s *State) Normalize(cfg Config) { - if s.Version == 0 { - s.Version = 1 - } - if s.Fired == nil { - s.Fired = map[string]string{} - } - if s.Cooldown == nil { - s.Cooldown = map[string]FireCooldown{} - } - if s.AwaitingFeedback == nil { - s.AwaitingFeedback = map[string]FeedbackWait{} - } - for i := range s.Queue { - s.Queue[i].Repo = NormalizeRepo(s.Queue[i].Repo) - s.Queue[i].Owner = ownerOf(s.Queue[i].Repo) - } - if s.InFlight != nil { - s.InFlight.Repo = NormalizeRepo(s.InFlight.Repo) - } - for i := range s.History { - s.History[i].Repo = NormalizeRepo(s.History[i].Repo) - } - if s.Blocked.Scope == "" { - s.Blocked.Scope = strings.Join(cfg.Scope, ",") - } - if s.Blocked.Source == "" { - s.Blocked.Source = "init" - } - folded := map[string]string{} - for k, v := range s.Fired { - folded[strings.ToLower(k)] = v - } - s.Fired = folded - // Fold cooldown keys the same way and drop entries whose window has passed, - // so a bounded map of live per-head cooldowns is all that persists. - now := time.Now().UTC() - cooldown := map[string]FireCooldown{} - for k, cd := range s.Cooldown { - if cd.Head == "" || cd.Until.IsZero() || !cd.Until.After(now) { - continue - } - cooldown[strings.ToLower(k)] = cd - } - s.Cooldown = cooldown - awaiting := map[string]FeedbackWait{} - for k, wait := range s.AwaitingFeedback { - repo, pr := wait.Repo, wait.PR - if repo == "" || pr <= 0 { - repo, pr = splitQueueKey(k) - } - repo = NormalizeRepo(repo) - if repo == "" || pr <= 0 || wait.Head == "" { - continue - } - wait.Repo = repo - wait.PR = pr - key := QueueKey(repo, pr) - awaiting[key] = wait - if s.Fired[key] == "" { - s.Fired[key] = wait.Head - } - } - s.AwaitingFeedback = awaiting - if cfg.FiredMax > 0 && len(s.Fired) > cfg.FiredMax { - // Protect markers for recently-fired heads (those still in History) from - // eviction. Normalize runs right after a fire records st.Fired[key]=head, and - // a plain lexicographic trim could drop that just-written marker for a repo - // whose name sorts early — making crq forget the head was already requested - // and fire a duplicate review. Only the older remainder is trimmed. - type kv struct { - Key string - Val string - } - type historyMarker struct { - Key string - Commit string - At time.Time - Index int - } - recent := make([]historyMarker, 0, len(s.History)) - for i, h := range s.History { - key := strings.ToLower(QueueKey(h.Repo, h.PR)) - if fired := s.Fired[key]; fired != "" && (h.Commit == "" || h.Commit == fired) { - recent = append(recent, historyMarker{Key: key, Commit: fired, At: h.At, Index: i}) - } - } - sort.SliceStable(recent, func(i, j int) bool { - if !recent[i].At.Equal(recent[j].At) { - return recent[i].At.After(recent[j].At) - } - return recent[i].Index < recent[j].Index - }) - protected := map[string]string{} - awaitingMarkers := make([]historyMarker, 0, len(s.AwaitingFeedback)) - for _, wait := range s.AwaitingFeedback { - key := QueueKey(wait.Repo, wait.PR) - if fired := s.Fired[key]; fired != "" && fired == wait.Head { - awaitingMarkers = append(awaitingMarkers, historyMarker{Key: key, Commit: fired, At: wait.StartedAt}) - } - } - sort.SliceStable(awaitingMarkers, func(i, j int) bool { - return awaitingMarkers[i].At.After(awaitingMarkers[j].At) - }) - for _, marker := range awaitingMarkers { - if len(protected) >= cfg.FiredMax { - break - } - protected[marker.Key] = marker.Commit - } - for _, marker := range recent { - if len(protected) >= cfg.FiredMax { - break - } - if _, ok := protected[marker.Key]; ok { - continue - } - protected[marker.Key] = marker.Commit - } - items := make([]kv, 0, len(s.Fired)) - for k, v := range s.Fired { - if _, ok := protected[k]; ok { - continue - } - items = append(items, kv{k, v}) - } - budget := cfg.FiredMax - len(protected) - if budget < 0 { - budget = 0 - } - sort.Slice(items, func(i, j int) bool { return items[i].Key < items[j].Key }) - if len(items) > budget { - items = items[len(items)-budget:] - } - s.Fired = protected - for _, item := range items { - s.Fired[item.Key] = item.Val - } - } -} - -func splitQueueKey(key string) (string, int) { - repo, prText, ok := strings.Cut(strings.ToLower(key), "#") - if !ok { - return "", 0 - } - pr, err := strconv.Atoi(prText) - if err != nil { - return "", 0 - } - return NormalizeRepo(repo), pr -} - func NormalizeRepo(repo string) string { repo = strings.TrimSpace(repo) repo = strings.TrimSuffix(repo, ".git") @@ -304,189 +99,83 @@ func QueueKey(repo string, pr int) string { return fmt.Sprintf("%s#%d", NormalizeRepo(repo), pr) } -func (s State) Contains(repo string, pr int) bool { - repo = NormalizeRepo(repo) - if s.InFlight != nil && s.InFlight.Repo == repo && s.InFlight.PR == pr { - return true - } - for _, item := range s.Queue { - if item.Repo == repo && item.PR == pr { - return true - } - } - return false -} - -// CooledDown reports whether the given repo#pr@head is under an active fire -// cooldown — a rate-limited requeue that must not be re-fired (or re-enqueued) -// until its window passes. Keyed on head, so a new commit is never blocked by a -// prior head's cooldown. -func (s State) CooledDown(repo string, pr int, head string, now time.Time) bool { - cd, ok := s.Cooldown[strings.ToLower(QueueKey(repo, pr))] - return ok && cd.Head == head && cd.Until.After(now) -} +// --- v2→v3 compatibility shims (consumed by feedback.go / Wait, rewritten in 4b) --- -func (s State) SortedQueue() []QueueItem { - out := append([]QueueItem(nil), s.Queue...) - sort.Slice(out, func(i, j int) bool { return out[i].Seq < out[j].Seq }) - return out +// FeedbackWait is the v2-shaped view of a fired/reviewing round, retained so +// the feedback/loop code keeps compiling against round state until stage 4b +// rewrites it. +type FeedbackWait struct { + Repo string + PR int + Head string + StartedAt time.Time + Deadline time.Time + FiredCommentID int64 + ByHost string } -func (s State) SortedAwaitingFeedback() []FeedbackWait { - out := make([]FeedbackWait, 0, len(s.AwaitingFeedback)) - for _, wait := range s.AwaitingFeedback { - out = append(out, wait) +// waitView presents a repo#pr's current round as a FeedbackWait when it is +// fired or reviewing (the v2 "awaiting feedback" states); otherwise the zero +// value, whose empty Head reads as "no wait" at every call site. +func waitView(st *State, repo string, pr int) FeedbackWait { + r := st.Round(repo, pr) + if r == nil || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) { + return FeedbackWait{} } - sort.Slice(out, func(i, j int) bool { - if !out[i].Deadline.Equal(out[j].Deadline) { - return out[i].Deadline.Before(out[j].Deadline) - } - if out[i].Repo != out[j].Repo { - return out[i].Repo < out[j].Repo - } - return out[i].PR < out[j].PR - }) - return out -} - -const crqProjectURL = "https://github.com/kristofferR/coderabbit-queue" - -func dashboardLoc(cfg Config) *time.Location { - if cfg.Timezone != "" { - if loc, err := time.LoadLocation(cfg.Timezone); err == nil { - return loc - } + w := FeedbackWait{Repo: r.Repo, PR: r.PR, Head: r.Head, FiredCommentID: r.CommandID, ByHost: r.ByHost} + if r.FiredAt != nil { + w.StartedAt = r.FiredAt.UTC() } - return time.UTC -} - -func fmtStamp(t *time.Time, loc *time.Location) string { - if t == nil { - return "—" + if r.WaitDeadline != nil { + w.Deadline = r.WaitDeadline.UTC() } - return t.In(loc).Format("2006-01-02 15:04 MST") + return w } -func minutesUntil(t time.Time, now time.Time) int { - mins := int(t.Sub(now).Minutes()) + 1 - if mins < 1 { - mins = 1 +// roundAnchor returns the fire timestamp and command id for repo#pr's current +// round when its head matches — the completion cutoff anchor that v2 read from +// AwaitingFeedback/InFlight/History. +func roundAnchor(st *State, repo string, pr int, head string) (firedAt time.Time, commandID int64, ok bool) { + r := st.Round(repo, pr) + if r == nil || r.Head != head || r.FiredAt == nil { + return time.Time{}, 0, false } - return mins + return r.FiredAt.UTC(), r.CommandID, true } -func renderDashboard(state State, cfg Config) string { - loc := dashboardLoc(cfg) - now := time.Now().UTC() - queue := state.SortedQueue() - awaiting := state.SortedAwaitingFeedback() - blocked := state.Blocked.BlockedUntil != nil && state.Blocked.BlockedUntil.After(now) - - var b strings.Builder - fmt.Fprintf(&b, "# 🐰 crq — CodeRabbit review queue\n\n") - - switch { - case blocked: - fmt.Fprintf(&b, "### 🔴 Blocked — next review in ~%dm\n\n", minutesUntil(*state.Blocked.BlockedUntil, now)) - case state.InFlight != nil: - fmt.Fprintf(&b, "### 🟡 Reviewing %s#%d\n\n", state.InFlight.Repo, state.InFlight.PR) - case len(awaiting) > 0: - fmt.Fprintf(&b, "### 🟡 Awaiting feedback for %s#%d\n\n", awaiting[0].Repo, awaiting[0].PR) - case len(queue) > 0: - fmt.Fprintf(&b, "### 🟠 %d queued\n\n", len(queue)) - default: - fmt.Fprintf(&b, "### 🟢 Idle\n\n") - } - - via := "" - if state.Blocked.Source != "" && state.Blocked.Source != "init" { - via = fmt.Sprintf(" _(via %s)_", state.Blocked.Source) - } - remaining := "available now" - if blocked { - remaining = "0 — rate-limited" - } - - fmt.Fprintf(&b, "| | |\n|---|---|\n") - fmt.Fprintf(&b, "| **Scope** | `%s` |\n", state.Blocked.Scope) - fmt.Fprintf(&b, "| **Reviews remaining** | %s%s |\n", remaining, via) - if blocked { - fmt.Fprintf(&b, "| **Rate limit** | ⚠️ rate limited |\n") - } else { - fmt.Fprintf(&b, "| **Rate limit** | ✅ not currently limited |\n") - } - fmt.Fprintf(&b, "| **Last review fired** | %s |\n", fmtStamp(state.LastFired, loc)) - if state.InFlight != nil { - fmt.Fprintf(&b, "| **In flight** | [%s#%d](https://github.com/%s/pull/%d) · fired %s · `%s` |\n", - state.InFlight.Repo, state.InFlight.PR, state.InFlight.Repo, state.InFlight.PR, - fmtStamp(state.InFlight.FiredAt, loc), state.InFlight.ByHost) - } else { - fmt.Fprintf(&b, "| **In flight** | — |\n") - } - if len(awaiting) > 0 { - wait := awaiting[0] - fmt.Fprintf(&b, "| **Feedback wait** | [%s#%d](https://github.com/%s/pull/%d) · `%s` · deadline %s |\n", - wait.Repo, wait.PR, wait.Repo, wait.PR, wait.Head, fmtStamp(&wait.Deadline, loc)) - } else { - fmt.Fprintf(&b, "| **Feedback wait** | — |\n") - } - if state.Warn != "" { - fmt.Fprintf(&b, "\n> ⚠️ %s\n", state.Warn) - } +// containsActive reports whether repo#pr has a round still occupying its slot +// (queued through awaiting_retry) — the v2 State.Contains for the queue/inflight. +func containsActive(st *State, repo string, pr int) bool { + r := st.Round(repo, pr) + return r != nil && r.Active() +} - fmt.Fprintf(&b, "\n## ⏳ Queue — %d waiting\n\n", len(queue)) - if len(queue) == 0 { - fmt.Fprintf(&b, "_Nothing queued._\n") - } else { - fmt.Fprintf(&b, "| # | PR | enqueued | host |\n|--:|---|---|---|\n") - for i, item := range queue { - fmt.Fprintf(&b, "| %d | [%s#%d](https://github.com/%s/pull/%d) | %s | `%s` |\n", - i+1, item.Repo, item.PR, item.Repo, item.PR, fmtStamp(&item.EnqueuedAt, loc), item.Host) - } +// firedMarker returns the head for which repo#pr has already been requested and +// must not be re-fired without a new head — the v2 Fired[key] dedupe. A +// completed round, or one still fired/reviewing, is such a marker; a parked +// awaiting_retry round is not (Pump re-fires it once RetryAt passes). +func firedMarker(st *State, repo string, pr int) string { + r := st.Round(repo, pr) + if r == nil { + return "" } - - fmt.Fprintf(&b, "\n## 📨 Recently requested — last %d\n\n", len(state.History)) - if len(state.History) == 0 { - fmt.Fprintf(&b, "_None yet._\n") - } else { - fmt.Fprintf(&b, "| PR | commit | requested | host |\n|---|---|---|---|\n") - for _, item := range state.History { - fmt.Fprintf(&b, "| [%s#%d](https://github.com/%s/pull/%d) | `%s` | %s | `%s` |\n", - item.Repo, item.PR, item.Repo, item.PR, item.Commit, fmtStamp(&item.At, loc), item.Host) - } + switch r.Phase { + case PhaseFired, PhaseReviewing, PhaseCompleted: + return r.Head } - - fmt.Fprintf(&b, "\n---\n") - fmt.Fprintf(&b, "🤖 Managed by [crq](%s) · rev %d · updated %s · do not edit by hand (machine state is in the hidden block at the top).\n", - crqProjectURL, state.Rev, fmtStamp(state.UpdatedAt, loc)) - return b.String() + return "" } -func renderTitle(state State) string { - now := time.Now().UTC() - switch { - case state.Blocked.BlockedUntil != nil && state.Blocked.BlockedUntil.After(now): - return fmt.Sprintf("🐰 crq — blocked · queue %d", len(state.Queue)) - case state.InFlight != nil: - return fmt.Sprintf("🐰 crq — reviewing #%d · queue %d", state.InFlight.PR, len(state.Queue)) - case len(state.AwaitingFeedback) > 0: - return fmt.Sprintf("🐰 crq — awaiting feedback · queue %d", len(state.Queue)) - case len(state.Queue) > 0: - return fmt.Sprintf("🐰 crq — %d queued", len(state.Queue)) - default: - return "🐰 crq — idle" +// accountBlockedUntil returns the latest active block preventing repo#pr@head +// from firing: the account-wide quota block or this round's own retry window +// (the v2 feedbackBlockedUntil over Blocked + per-head Cooldown). +func accountBlockedUntil(st *State, repo string, pr int, head string, now time.Time) (time.Time, bool) { + var until time.Time + if st.Account.BlockedUntil != nil && st.Account.BlockedUntil.After(now) { + until = st.Account.BlockedUntil.UTC() } -} - -func issueBody(state State, cfg Config) (string, error) { - machine, err := json.Marshal(state) - if err != nil { - return "", err + if r := st.Round(repo, pr); r != nil && r.Phase == PhaseAwaitingRetry && r.Head == head && r.RetryAt != nil && r.RetryAt.After(now) && r.RetryAt.After(until) { + until = r.RetryAt.UTC() } - return fmt.Sprintf("%s\n%s\n%s\n\n%s", stateBegin, machine, stateEnd, renderDashboard(state, cfg)), nil -} - -func hashString(value string) string { - sum := sha1.Sum([]byte(value)) - return hex.EncodeToString(sum[:]) + return until, !until.IsZero() } diff --git a/internal/crq/state_test.go b/internal/crq/state_test.go index d2e4b36..57f006b 100644 --- a/internal/crq/state_test.go +++ b/internal/crq/state_test.go @@ -1,116 +1,21 @@ package crq import ( + "context" "strings" "testing" "time" ) -func TestNormalizeKeepsRecentlyFiredMarkers(t *testing.T) { - cfg := Config{FiredMax: 2} - now := time.Now().UTC() - st := State{ - Fired: map[string]string{ - "owner/aaa#1": "head-aaa", // sorts first; a plain lexicographic trim would drop it - "owner/mmm#1": "head-mmm", - "owner/zzz#1": "head-zzz", - }, - // History marks owner/aaa#1 as just-fired — it must survive the trim. - History: []HistoryItem{{Repo: "owner/aaa", PR: 1, Commit: "head-aaa", At: now}}, - } - st.Normalize(cfg) - - if st.Fired["owner/aaa#1"] != "head-aaa" { - t.Fatalf("recently-fired marker was evicted by the FiredMax trim: %#v", st.Fired) - } - if len(st.Fired) > cfg.FiredMax { - t.Fatalf("expected at most FiredMax markers after protection, got %d: %#v", len(st.Fired), st.Fired) - } -} - -func TestNormalizeCapsProtectedHistoryMarkers(t *testing.T) { - cfg := Config{FiredMax: 2} - now := time.Now().UTC() - st := State{ - Fired: map[string]string{ - "owner/old#1": "head-old", - "owner/newest#1": "head-newest", - "owner/middle#1": "head-middle", - "owner/extra#1": "head-extra", - }, - History: []HistoryItem{ - {Repo: "owner/old", PR: 1, Commit: "head-old", At: now.Add(-2 * time.Minute)}, - {Repo: "owner/newest", PR: 1, Commit: "head-newest", At: now}, - {Repo: "owner/middle", PR: 1, Commit: "head-middle", At: now.Add(-time.Minute)}, - }, - } - st.Normalize(cfg) - - if len(st.Fired) != cfg.FiredMax { - t.Fatalf("expected exactly FiredMax markers, got %d: %#v", len(st.Fired), st.Fired) - } - if st.Fired["owner/newest#1"] != "head-newest" || st.Fired["owner/middle#1"] != "head-middle" { - t.Fatalf("expected the most recent matching history markers to survive: %#v", st.Fired) - } - if _, ok := st.Fired["owner/old#1"]; ok { - t.Fatalf("older history marker should not exceed FiredMax protection budget: %#v", st.Fired) - } -} - -func TestNormalizeProtectsAwaitingFeedbackMarkers(t *testing.T) { - cfg := Config{FiredMax: 1, FeedbackWaitTimeout: time.Minute} - now := time.Now().UTC() - st := State{ - Fired: map[string]string{ - "owner/aaa#1": "head-aaa", - "owner/zzz#1": "head-zzz", - }, - AwaitingFeedback: map[string]FeedbackWait{ - "owner/aaa#1": { - Repo: "owner/aaa", - PR: 1, - Head: "head-aaa", - StartedAt: now, - Deadline: now.Add(time.Minute), - }, - }, - } - st.Normalize(cfg) - - if st.Fired["owner/aaa#1"] != "head-aaa" { - t.Fatalf("awaiting feedback marker must protect its fired dedupe marker: %#v", st.Fired) - } - if len(st.Fired) != cfg.FiredMax { - t.Fatalf("expected FiredMax trimming to keep exactly one marker, got %#v", st.Fired) - } -} - -func TestNormalizeRestoresFiredFromAwaitingFeedback(t *testing.T) { - now := time.Now().UTC() - st := State{ - AwaitingFeedback: map[string]FeedbackWait{ - "Owner/Repo#7": { - Head: "abcdef123", - StartedAt: now, - Deadline: now.Add(time.Minute), - }, - }, - } - st.Normalize(Config{FiredMax: 500}) - - key := QueueKey("owner/repo", 7) - if st.Fired[key] != "abcdef123" { - t.Fatalf("normalization should restore the fired marker from awaiting feedback, got %#v", st.Fired) - } - if wait := st.AwaitingFeedback[key]; wait.Repo != "owner/repo" || wait.PR != 7 { - t.Fatalf("awaiting feedback key/fields should normalize together, got %#v", st.AwaitingFeedback) - } -} - func TestDashboardLabelsFireHistoryAsRequested(t *testing.T) { - state := DefaultState(Config{}) - state.History = []HistoryItem{{Repo: "owner/repo", PR: 7, Commit: "abcdef123", At: time.Now().UTC(), Host: "host"}} - dashboard := renderDashboard(state, Config{}) + cfg := Config{FeedbackWaitTimeout: time.Hour} + store := NewMemoryStore(cfg) + seedRound(t, store, cfg, "owner/repo", 7, "abcdef123", PhaseFired, time.Now().UTC(), 1) + state, _, err := store.Load(context.Background()) + if err != nil { + t.Fatal(err) + } + dashboard := renderDashboard(state, cfg) if !strings.Contains(dashboard, "Recently requested") || !strings.Contains(dashboard, "| PR | commit | requested | host |") { t.Fatalf("fire history must be labeled as requested, got:\n%s", dashboard) diff --git a/internal/crq/store.go b/internal/crq/store.go deleted file mode 100644 index 0dde0bf..0000000 --- a/internal/crq/store.go +++ /dev/null @@ -1,211 +0,0 @@ -package crq - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "sync" - "time" -) - -var ErrCASConflict = errors.New("state changed while writing") -var ErrNoChange = errors.New("state unchanged") - -type Revision struct { - CommitSHA string - TreeSHA string -} - -type StateStore interface { - Load(context.Context) (State, Revision, error) - Update(context.Context, func(*State) error) (State, error) - SyncDashboard(context.Context, State) error -} - -type GitStateStore struct { - cfg Config - gh *GitHub -} - -func NewGitStateStore(cfg Config, gh *GitHub) *GitStateStore { - return &GitStateStore{cfg: cfg, gh: gh} -} - -func (s *GitStateStore) Load(ctx context.Context) (State, Revision, error) { - if err := s.cfg.RequireState(); err != nil { - return State{}, Revision{}, err - } - ref, err := s.gh.GetRef(ctx, s.cfg.GateRepo, s.cfg.StateRef) - if errors.Is(err, ErrNotFound) { - state := DefaultState(s.cfg) - state.Normalize(s.cfg) - return state, Revision{}, nil - } - if err != nil { - return State{}, Revision{}, err - } - commit, err := s.gh.GetCommit(ctx, s.cfg.GateRepo, ref) - if err != nil { - return State{}, Revision{}, err - } - tree, err := s.gh.GetTree(ctx, s.cfg.GateRepo, commit.Tree.SHA) - if err != nil { - return State{}, Revision{}, err - } - var stateBlob string - for _, item := range tree.Tree { - if item.Path == statePath && item.Type == "blob" { - stateBlob = item.SHA - break - } - } - if stateBlob == "" { - return State{}, Revision{}, fmt.Errorf("state ref %s has no %s", s.cfg.StateRef, statePath) - } - raw, err := s.gh.GetBlob(ctx, s.cfg.GateRepo, stateBlob) - if err != nil { - return State{}, Revision{}, err - } - var state State - if err := json.Unmarshal(raw, &state); err != nil { - return State{}, Revision{}, err - } - state.Normalize(s.cfg) - return state, Revision{CommitSHA: ref, TreeSHA: commit.Tree.SHA}, nil -} - -func (s *GitStateStore) Update(ctx context.Context, mutate func(*State) error) (State, error) { - const attempts = 12 - for i := 0; i < attempts; i++ { - state, rev, err := s.Load(ctx) - if err != nil { - return State{}, err - } - if err := mutate(&state); err != nil { - if errors.Is(err, ErrNoChange) { - return state, nil - } - return State{}, err - } - now := time.Now().UTC() - state.Rev++ - state.UpdatedAt = &now - state.Normalize(s.cfg) - if err := s.compareAndSwap(ctx, &state, rev); err != nil { - if errors.Is(err, ErrCASConflict) { - continue - } - return State{}, err - } - return state, nil - } - return State{}, ErrCASConflict -} - -func (s *GitStateStore) compareAndSwap(ctx context.Context, state *State, rev Revision) error { - dashboard := renderDashboard(*state, s.cfg) - state.DashboardSHA = hashString(dashboard) - stateJSON, err := json.MarshalIndent(*state, "", " ") - if err != nil { - return err - } - stateBlob, err := s.gh.CreateBlob(ctx, s.cfg.GateRepo, append(stateJSON, '\n')) - if err != nil { - return err - } - dashboardBlob, err := s.gh.CreateBlob(ctx, s.cfg.GateRepo, []byte(dashboard)) - if err != nil { - return err - } - treeSHA, err := s.gh.CreateTree(ctx, s.cfg.GateRepo, rev.TreeSHA, []map[string]any{ - {"path": statePath, "mode": "100644", "type": "blob", "sha": stateBlob}, - {"path": dashboardPath, "mode": "100644", "type": "blob", "sha": dashboardBlob}, - }) - if err != nil { - return err - } - parents := []string{} - if rev.CommitSHA != "" { - parents = []string{rev.CommitSHA} - } - commitSHA, err := s.gh.CreateCommit(ctx, s.cfg.GateRepo, fmt.Sprintf("crq: state rev %d", state.Rev), treeSHA, parents) - if err != nil { - return err - } - if rev.CommitSHA == "" { - err = s.gh.CreateRef(ctx, s.cfg.GateRepo, s.cfg.StateRef, commitSHA) - } else { - err = s.gh.UpdateRef(ctx, s.cfg.GateRepo, s.cfg.StateRef, commitSHA, false) - } - if err == nil { - return nil - } - var apiErr *APIError - if errors.As(err, &apiErr) && (apiErr.Status == http.StatusUnprocessableEntity || apiErr.Status == http.StatusConflict) { - return ErrCASConflict - } - return err -} - -func (s *GitStateStore) SyncDashboard(ctx context.Context, state State) error { - if err := s.cfg.RequireDashboard(); err != nil { - return err - } - body, err := issueBody(state, s.cfg) - if err != nil { - return err - } - return s.gh.PatchIssue(ctx, s.cfg.GateRepo, s.cfg.DashboardIssue, renderTitle(state), body) -} - -type MemoryStore struct { - mu sync.Mutex - cfg Config - state State - rev int64 -} - -func NewMemoryStore(cfg Config) *MemoryStore { - state := DefaultState(cfg) - state.Normalize(cfg) - return &MemoryStore{cfg: cfg, state: state} -} - -func (m *MemoryStore) Load(context.Context) (State, Revision, error) { - m.mu.Lock() - defer m.mu.Unlock() - state := cloneState(m.state) - state.Normalize(m.cfg) - return state, Revision{CommitSHA: fmt.Sprintf("%d", m.rev)}, nil -} - -func (m *MemoryStore) Update(_ context.Context, mutate func(*State) error) (State, error) { - m.mu.Lock() - defer m.mu.Unlock() - state := cloneState(m.state) - state.Normalize(m.cfg) - if err := mutate(&state); err != nil { - if errors.Is(err, ErrNoChange) { - return state, nil - } - return State{}, err - } - now := time.Now().UTC() - state.Rev++ - state.UpdatedAt = &now - state.Normalize(m.cfg) - m.rev++ - m.state = state - return state, nil -} - -func (m *MemoryStore) SyncDashboard(context.Context, State) error { return nil } - -func cloneState(state State) State { - raw, _ := json.Marshal(state) - var out State - _ = json.Unmarshal(raw, &out) - return out -} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index e80f330..4c2c57c 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -231,6 +231,59 @@ func TestDecideFireGuards(t *testing.T) { } } +// TestBareReactionReleasesSlotButKeepsRoundOpen ports v2's doneBotReacted: a +// reaction on the fired command acknowledges it, releasing the slot while the +// review keeps running. +func TestBareReactionReleasesSlotButKeepsRoundOpen(t *testing.T) { + r := firedRound(t, "abcdef123") + obs := Observation{Head: "abcdef123", Open: true, Reacted: true} + tr := Progress(r, state.AccountQuota{}, obs, t0.Add(time.Minute), policy) + if tr.Outcome != OutReviewing { + t.Fatalf("a bare reaction must release the slot and keep the round open, got %+v", tr) + } +} + +// TestReviewsPausedNoteIsNotAck ports v2: the auto-pause note is a bot comment +// but not an acknowledgement of the fired command, so the round keeps waiting. +func TestReviewsPausedNoteIsNotAck(t *testing.T) { + r := firedRound(t, "abcdef123") + paused := dialect.BotEvent{Kind: dialect.EvPaused, Bot: "coderabbitai[bot]", CommentID: 900, + CreatedAt: t0.Add(10 * time.Second), UpdatedAt: t0.Add(10 * time.Second)} + obs := Observation{Head: "abcdef123", Open: true, Events: []dialect.BotEvent{paused}} + tr := Progress(r, state.AccountQuota{}, obs, t0.Add(time.Minute), policy) + if tr.Outcome != KeepWaiting { + t.Fatalf("a reviews-paused note must not acknowledge or complete the round, got %+v", tr) + } +} + +// TestRateLimitBeatsAlreadyReviewedAck encodes the carrier#82 incident: a +// rate-limit notice plus an "already reviewed" claim, with no review object, +// must park the round (retry later), never complete it. +func TestRateLimitBeatsAlreadyReviewedAck(t *testing.T) { + r := firedRound(t, "a0646f010") + window := t0.Add(40 * time.Minute) + obs := Observation{Head: "a0646f010", Open: true, Events: []dialect.BotEvent{ + rateLimitEvent(501, t0.Add(10*time.Second), &window), + {Kind: dialect.EvAlreadyReviewed, Bot: "coderabbitai[bot]", CommentID: 502, CreatedAt: t0.Add(10 * time.Second), UpdatedAt: t0.Add(10 * time.Second)}, + }} + tr := Progress(r, state.AccountQuota{}, obs, t0.Add(time.Minute), policy) + if tr.Outcome != OutRetry || tr.Blocked == nil { + t.Fatalf("an unproven already-reviewed ack must yield to the rate limit, got %+v", tr) + } +} + +// TestPreFireReviewOfHeadCompletes ports botsReviewedHead: a required bot's +// review of the head counts even when it landed before the round was fired. +func TestPreFireReviewOfHeadCompletes(t *testing.T) { + r := firedRound(t, "abcdef123") + obs := Observation{Head: "abcdef123", Open: true, Reviews: []ReviewSeen{ + {Bot: "coderabbitai[bot]", ReviewID: 9, Commit: "abcdef1234567890", SubmittedAt: t0.Add(-10 * time.Minute)}, + }} + if got := Completion(r, obs, policy); !got.Done { + t.Fatalf("a required bot's pre-fire review of the head must complete the round: %+v", got) + } +} + // TestCodexGatesCleanSummary ports the codexInactiveOrThumbed rules. func TestCodexGatesCleanSummary(t *testing.T) { r := firedRound(t, "abcdef123") diff --git a/internal/engine/progress.go b/internal/engine/progress.go index bd0d393..57c6ee6 100644 --- a/internal/engine/progress.go +++ b/internal/engine/progress.go @@ -124,10 +124,10 @@ func Progress(r state.Round, q state.AccountQuota, obs Observation, now time.Tim return Transition{Outcome: KeepWaiting, Reason: "review in flight"} } - // Reviewing: the slot is long released; the wait deadline bounds the round. - if r.WaitDeadline != nil && now.After(*r.WaitDeadline) { - return Transition{Outcome: OutRetry, Reason: "wait deadline expired", RetryAt: now} - } + // Reviewing: the slot is long released and the review is running. The + // wall-clock wait deadline is the loop's concern (it times out its own wait), + // not the daemon's — the daemon keeps waiting for real bot evidence rather + // than re-firing a review that is still in progress. return Transition{Outcome: KeepWaiting, Reason: "reviewing"} } diff --git a/internal/state/dashboard.go b/internal/state/dashboard.go new file mode 100644 index 0000000..101a094 --- /dev/null +++ b/internal/state/dashboard.go @@ -0,0 +1,209 @@ +package state + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +const ( + stateBegin = "" + crqProjectURL = "https://github.com/kristofferR/coderabbit-queue" +) + +func joinScope(scope []string) string { + return strings.Join(scope, ",") +} + +func dashboardLoc(cfg StoreConfig) *time.Location { + if cfg.Timezone != "" { + if loc, err := time.LoadLocation(cfg.Timezone); err == nil { + return loc + } + } + return time.UTC +} + +func fmtStamp(t *time.Time, loc *time.Location) string { + if t == nil { + return "—" + } + return t.In(loc).Format("2006-01-02 15:04 MST") +} + +func minutesUntil(t time.Time, now time.Time) int { + mins := int(t.Sub(now).Minutes()) + 1 + if mins < 1 { + mins = 1 + } + return mins +} + +// reviewingRounds returns the rounds that fired and are still open (fired or +// reviewing), ordered by fire time — the v3 equivalent of the "awaiting +// feedback" set (a fired round whose slot may already be released). +func reviewingRounds(st State) []Round { + var out []Round + for _, r := range st.Rounds { + if r.Phase == PhaseFired || r.Phase == PhaseReviewing { + out = append(out, r) + } + } + sort.Slice(out, func(i, j int) bool { + return firedAtOf(out[i]).Before(firedAtOf(out[j])) + }) + return out +} + +func firedAtOf(r Round) time.Time { + if r.FiredAt != nil { + return *r.FiredAt + } + return r.EnqueuedAt +} + +// requestedRounds gathers every round that has fired (active or archived) for +// the "Recently requested" table, newest first, capped. +func requestedRounds(st State) []Round { + var out []Round + for _, r := range st.Rounds { + if r.FiredAt != nil { + out = append(out, r) + } + } + for _, r := range st.Archive { + if r.FiredAt != nil { + out = append(out, r) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].FiredAt.After(*out[j].FiredAt) }) + if len(out) > 20 { + out = out[:20] + } + return out +} + +// 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 { + loc := dashboardLoc(cfg) + now := time.Now().UTC() + queue := st.QueuedRounds(now) + reviewing := reviewingRounds(st) + slot := st.SlotRound() + blocked := st.Account.BlockedUntil != nil && st.Account.BlockedUntil.After(now) + + var b strings.Builder + fmt.Fprintf(&b, "# 🐰 crq — CodeRabbit review queue\n\n") + + switch { + case blocked: + fmt.Fprintf(&b, "### 🔴 Blocked — next review in ~%dm\n\n", minutesUntil(*st.Account.BlockedUntil, now)) + case slot != nil: + fmt.Fprintf(&b, "### 🟡 Reviewing %s#%d\n\n", slot.Repo, slot.PR) + case len(reviewing) > 0: + fmt.Fprintf(&b, "### 🟡 Awaiting feedback for %s#%d\n\n", reviewing[0].Repo, reviewing[0].PR) + case len(queue) > 0: + fmt.Fprintf(&b, "### 🟠 %d queued\n\n", len(queue)) + default: + fmt.Fprintf(&b, "### 🟢 Idle\n\n") + } + + via := "" + if st.Account.Source != "" && st.Account.Source != "init" { + via = fmt.Sprintf(" _(via %s)_", st.Account.Source) + } + remaining := "available now" + if blocked { + remaining = "0 — rate-limited" + } + + fmt.Fprintf(&b, "| | |\n|---|---|\n") + fmt.Fprintf(&b, "| **Scope** | `%s` |\n", st.Account.Scope) + fmt.Fprintf(&b, "| **Reviews remaining** | %s%s |\n", remaining, via) + if blocked { + fmt.Fprintf(&b, "| **Rate limit** | ⚠️ rate limited |\n") + } else { + fmt.Fprintf(&b, "| **Rate limit** | ✅ not currently limited |\n") + } + 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", + slot.Repo, slot.PR, slot.Repo, slot.PR, fmtStamp(slot.FiredAt, loc), slot.ByHost) + } else { + fmt.Fprintf(&b, "| **In flight** | — |\n") + } + 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)) + } else { + fmt.Fprintf(&b, "| **Feedback wait** | — |\n") + } + if st.Warn != "" { + fmt.Fprintf(&b, "\n> ⚠️ %s\n", st.Warn) + } + + fmt.Fprintf(&b, "\n## ⏳ Queue — %d waiting\n\n", len(queue)) + if len(queue) == 0 { + fmt.Fprintf(&b, "_Nothing queued._\n") + } else { + fmt.Fprintf(&b, "| # | PR | enqueued | host |\n|--:|---|---|---|\n") + for i, r := range queue { + fmt.Fprintf(&b, "| %d | [%s#%d](https://github.com/%s/pull/%d) | %s | `%s` |\n", + i+1, r.Repo, r.PR, r.Repo, r.PR, fmtStamp(&r.EnqueuedAt, loc), r.ByHost) + } + } + + requested := requestedRounds(st) + fmt.Fprintf(&b, "\n## 📨 Recently requested — last %d\n\n", len(requested)) + if len(requested) == 0 { + fmt.Fprintf(&b, "_None yet._\n") + } else { + fmt.Fprintf(&b, "| PR | commit | requested | host |\n|---|---|---|---|\n") + for _, r := range requested { + fmt.Fprintf(&b, "| [%s#%d](https://github.com/%s/pull/%d) | `%s` | %s | `%s` |\n", + r.Repo, r.PR, r.Repo, r.PR, r.Head, fmtStamp(r.FiredAt, loc), r.ByHost) + } + } + + fmt.Fprintf(&b, "\n---\n") + fmt.Fprintf(&b, "🤖 Managed by [crq](%s) · rev %d · updated %s · do not edit by hand (machine state is in the hidden block at the top).\n", + crqProjectURL, st.Rev, fmtStamp(st.UpdatedAt, loc)) + return b.String() +} + +func RenderTitle(st State) string { + now := time.Now().UTC() + queue := len(st.QueuedRounds(now)) + switch { + case st.Account.BlockedUntil != nil && st.Account.BlockedUntil.After(now): + return fmt.Sprintf("🐰 crq — blocked · queue %d", queue) + case st.SlotRound() != nil: + return fmt.Sprintf("🐰 crq — reviewing #%d · queue %d", st.SlotRound().PR, queue) + case len(reviewingRounds(st)) > 0: + return fmt.Sprintf("🐰 crq — awaiting feedback · queue %d", queue) + case queue > 0: + return fmt.Sprintf("🐰 crq — %d queued", queue) + default: + return "🐰 crq — idle" + } +} + +func IssueBody(st State, cfg StoreConfig) (string, error) { + machine, err := json.Marshal(st) + if err != nil { + return "", err + } + return fmt.Sprintf("%s\n%s\n%s\n\n%s", stateBegin, machine, stateEnd, RenderDashboard(st, cfg)), nil +} + +func hashString(value string) string { + sum := sha1.Sum([]byte(value)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/state/state.go b/internal/state/state.go index fb891c2..14a12e4 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -256,6 +256,22 @@ func (r *Round) Complete() error { return nil } +// Dedupe completes a not-yet-fired round because the configured bot already +// reviewed its head independently (an adopted review, not a fire crq made): a +// queued (or retry-eligible) round → completed. The completed round stays as +// the "this head was reviewed" dedup marker without recording a fictitious +// fire (FiredAt stays nil). +func (r *Round) Dedupe(now time.Time) error { + if r.Phase != PhaseQueued && r.Phase != PhaseReserved && !r.retryEligible(now) { + return r.illegal(PhaseCompleted) + } + r.Phase = PhaseCompleted + r.Token = "" + r.ReservedAt = nil + r.Note = "bot already reviewed head" + return nil +} + // Abandon ends the round from any phase (PR closed/merged, cancelled, or // superseded by a new head). The caller archives it via State.EndRound. func (r *Round) Abandon(reason string) { diff --git a/internal/state/state_test.go b/internal/state/state_test.go index ca12b81..e09aeee 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -89,6 +89,29 @@ func TestFiredHeadCannotRefire(t *testing.T) { } } +// TestDedupeCompletesFromQueued covers the "bot already reviewed the head" +// path: a queued round is marked complete without recording a fictitious fire, +// leaving it as the dedup marker. +func TestDedupeCompletesFromQueued(t *testing.T) { + s := New() + r, err := s.NewRound("owner/repo", 10, "abcdef123", t0) + if err != nil { + t.Fatal(err) + } + if err := r.Dedupe(t0); err != nil { + t.Fatal(err) + } + if r.Phase != PhaseCompleted || r.FiredAt != nil || r.Attempts != 0 { + t.Fatalf("dedupe must complete without a fire: %+v", r) + } + // A fired round cannot be deduped — it goes through Complete. + fired := newFired(t, &s) + var te *TransitionError + if err := fired.Dedupe(t0); !errors.As(err, &te) { + t.Fatalf("dedupe of a fired round must be illegal, got %v", err) + } +} + func TestIllegalCompletions(t *testing.T) { s := New() r, err := s.NewRound("owner/repo", 8, "cafebabe1", t0) diff --git a/internal/state/store.go b/internal/state/store.go new file mode 100644 index 0000000..38901e5 --- /dev/null +++ b/internal/state/store.go @@ -0,0 +1,300 @@ +package state + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "time" + + gh "github.com/kristofferR/coderabbit-queue/internal/gh" +) + +// ErrCASConflict is returned when the state ref moved between load and write. +var ErrCASConflict = errors.New("state changed while writing") + +// ErrNoChange lets a mutate closure report "nothing to persist", so Update +// returns the current state without writing a new revision. +var ErrNoChange = errors.New("state unchanged") + +const ( + statePath = "state.json" + dashboardPath = "dashboard.md" +) + +// Logger is the minimal logging surface the store uses (for the loud +// auto-reinit line when a stale schema payload is loaded). +type Logger interface { + Printf(string, ...any) +} + +// StoreConfig carries the fields the store and dashboard need from crq's +// Config, so internal/state stays free of an import cycle back to crq. +type StoreConfig struct { + GateRepo string + StateRef string + DashboardIssue int + Timezone string + Scope []string +} + +func (c StoreConfig) requireState() error { + if c.GateRepo == "" { + return errors.New("CRQ_REPO is not set (run 'crq init' or configure ~/.config/crq/env)") + } + return nil +} + +func (c StoreConfig) requireDashboard() error { + if err := c.requireState(); err != nil { + return err + } + if c.DashboardIssue <= 0 { + return errors.New("CRQ_ISSUE is not set (run 'crq init' or configure ~/.config/crq/env)") + } + return nil +} + +// Revision identifies the git commit/tree the loaded state came from, so the +// compare-and-swap can build the next commit on top of it. +type Revision struct { + CommitSHA string + TreeSHA string +} + +// StateStore is the persistence surface crq consumes. Load reads the current +// state, Update applies a mutate closure under compare-and-swap, and +// SyncDashboard mirrors the state to the dashboard issue. +type StateStore interface { + Load(context.Context) (State, Revision, error) + Update(context.Context, func(*State) error) (State, error) + SyncDashboard(context.Context, State) error +} + +// GitStateStore persists v3 state as state.json in a git ref, with the same +// compare-and-swap mechanism as v2 (12 retries on UpdateRef 409/422). Only the +// payload shape changed; a payload whose schema version isn't 3 (or won't +// parse) is logged loudly and auto-reinitialized — crq is pre-release, there is +// no migration. +type GitStateStore struct { + cfg StoreConfig + gh *gh.GitHub + log Logger +} + +func NewGitStateStore(cfg StoreConfig, client *gh.GitHub, log Logger) *GitStateStore { + return &GitStateStore{cfg: cfg, gh: client, log: log} +} + +func (s *GitStateStore) logf(format string, args ...any) { + if s.log != nil { + s.log.Printf(format, args...) + } +} + +func (s *GitStateStore) Load(ctx context.Context) (State, Revision, error) { + if err := s.cfg.requireState(); err != nil { + return State{}, Revision{}, err + } + ref, err := s.gh.GetRef(ctx, s.cfg.GateRepo, s.cfg.StateRef) + if errors.Is(err, gh.ErrNotFound) { + st := s.fresh() + return st, Revision{}, nil + } + if err != nil { + return State{}, Revision{}, err + } + commit, err := s.gh.GetCommit(ctx, s.cfg.GateRepo, ref) + if err != nil { + return State{}, Revision{}, err + } + tree, err := s.gh.GetTree(ctx, s.cfg.GateRepo, commit.Tree.SHA) + if err != nil { + return State{}, Revision{}, err + } + var stateBlob string + for _, item := range tree.Tree { + if item.Path == statePath && item.Type == "blob" { + stateBlob = item.SHA + break + } + } + rev := Revision{CommitSHA: ref, TreeSHA: commit.Tree.SHA} + if stateBlob == "" { + s.logf("state ref %s has no %s — reinitializing to a fresh v%d state", s.cfg.StateRef, statePath, SchemaVersion) + return s.fresh(), rev, nil + } + raw, err := s.gh.GetBlob(ctx, s.cfg.GateRepo, stateBlob) + if err != nil { + return State{}, Revision{}, err + } + // Peek at the schema version before a full decode: a v2 (or unknown) payload + // must not be coerced field-by-field into v3, it must be discarded. + var probe struct { + Version int `json:"v"` + } + if err := json.Unmarshal(raw, &probe); err != nil || probe.Version != SchemaVersion { + reason := fmt.Sprintf("schema v%d", probe.Version) + if err != nil { + reason = "an unparseable payload" + } + s.logf("state ref %s holds %s (want v%d) — reinitializing to a fresh state (no migration; crq is pre-release)", s.cfg.StateRef, reason, SchemaVersion) + return s.fresh(), rev, nil + } + var st State + if err := json.Unmarshal(raw, &st); err != nil { + s.logf("state ref %s payload failed to decode (%v) — reinitializing to a fresh state", s.cfg.StateRef, err) + return s.fresh(), rev, nil + } + st.Normalize(time.Now().UTC()) + return st, rev, nil +} + +func (s *GitStateStore) fresh() State { + st := New() + st.Account.Scope = joinScope(s.cfg.Scope) + st.Account.Source = "init" + st.Normalize(time.Now().UTC()) + return st +} + +func (s *GitStateStore) Update(ctx context.Context, mutate func(*State) error) (State, error) { + const attempts = 12 + for i := 0; i < attempts; i++ { + st, rev, err := s.Load(ctx) + if err != nil { + return State{}, err + } + if err := mutate(&st); err != nil { + if errors.Is(err, ErrNoChange) { + return st, nil + } + return State{}, err + } + now := time.Now().UTC() + st.Rev++ + st.UpdatedAt = &now + st.Normalize(now) + if err := s.compareAndSwap(ctx, &st, rev); err != nil { + if errors.Is(err, ErrCASConflict) { + continue + } + return State{}, err + } + return st, nil + } + return State{}, ErrCASConflict +} + +func (s *GitStateStore) compareAndSwap(ctx context.Context, st *State, rev Revision) error { + dashboard := RenderDashboard(*st, s.cfg) + st.DashboardSHA = hashString(dashboard) + stateJSON, err := json.MarshalIndent(*st, "", " ") + if err != nil { + return err + } + stateBlob, err := s.gh.CreateBlob(ctx, s.cfg.GateRepo, append(stateJSON, '\n')) + if err != nil { + return err + } + dashboardBlob, err := s.gh.CreateBlob(ctx, s.cfg.GateRepo, []byte(dashboard)) + if err != nil { + return err + } + treeSHA, err := s.gh.CreateTree(ctx, s.cfg.GateRepo, rev.TreeSHA, []map[string]any{ + {"path": statePath, "mode": "100644", "type": "blob", "sha": stateBlob}, + {"path": dashboardPath, "mode": "100644", "type": "blob", "sha": dashboardBlob}, + }) + if err != nil { + return err + } + parents := []string{} + if rev.CommitSHA != "" { + parents = []string{rev.CommitSHA} + } + commitSHA, err := s.gh.CreateCommit(ctx, s.cfg.GateRepo, fmt.Sprintf("crq: state rev %d", st.Rev), treeSHA, parents) + if err != nil { + return err + } + if rev.CommitSHA == "" { + err = s.gh.CreateRef(ctx, s.cfg.GateRepo, s.cfg.StateRef, commitSHA) + } else { + err = s.gh.UpdateRef(ctx, s.cfg.GateRepo, s.cfg.StateRef, commitSHA, false) + } + if err == nil { + return nil + } + var apiErr *gh.APIError + if errors.As(err, &apiErr) && (apiErr.Status == http.StatusUnprocessableEntity || apiErr.Status == http.StatusConflict) { + return ErrCASConflict + } + return err +} + +func (s *GitStateStore) SyncDashboard(ctx context.Context, st State) error { + if err := s.cfg.requireDashboard(); err != nil { + return err + } + body, err := IssueBody(st, s.cfg) + if err != nil { + return err + } + return s.gh.PatchIssue(ctx, s.cfg.GateRepo, s.cfg.DashboardIssue, RenderTitle(st), body) +} + +// MemoryStore is the in-memory store used by tests and the fake-GitHub harness. +type MemoryStore struct { + mu sync.Mutex + cfg StoreConfig + state State + rev int64 +} + +func NewMemoryStore(cfg StoreConfig) *MemoryStore { + st := New() + st.Account.Scope = joinScope(cfg.Scope) + st.Account.Source = "init" + st.Normalize(time.Now().UTC()) + return &MemoryStore{cfg: cfg, state: st} +} + +func (m *MemoryStore) Load(context.Context) (State, Revision, error) { + m.mu.Lock() + defer m.mu.Unlock() + st := Clone(m.state) + st.Normalize(time.Now().UTC()) + return st, Revision{CommitSHA: fmt.Sprintf("%d", m.rev)}, nil +} + +func (m *MemoryStore) Update(_ context.Context, mutate func(*State) error) (State, error) { + m.mu.Lock() + defer m.mu.Unlock() + st := Clone(m.state) + st.Normalize(time.Now().UTC()) + if err := mutate(&st); err != nil { + if errors.Is(err, ErrNoChange) { + return st, nil + } + return State{}, err + } + now := time.Now().UTC() + st.Rev++ + st.UpdatedAt = &now + st.Normalize(now) + m.rev++ + m.state = st + return st, nil +} + +func (m *MemoryStore) SyncDashboard(context.Context, State) error { return nil } + +// Clone deep-copies a State via its JSON representation, so a mutate closure +// can never scribble on the store's retained copy. +func Clone(st State) State { + raw, _ := json.Marshal(st) + var out State + _ = json.Unmarshal(raw, &out) + return out +} From 1d507477ba32a1f2b0f3315d15cf0e3ab6e6afc4 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 16:56:34 +0200 Subject: [PATCH 05/20] Rewrite Loop, Wait, and Feedback natively on round state Completes the flag-day cutover: Loop, Wait, and Feedback now run on the v3 round state machine and the pure engine, and the transitional v2 shim and alias layers are gone. - Feedback keeps its findings-extraction pipeline (review bodies, threads, review comments, issue comments) but derives convergence from engine.Completion driven off observe(). observe() now carries the raw reviews/comments Feedback parses, so one fetch serves both the daemon (DecideFire/Progress) and the loop (Completion + findings) with no second path. ReviewedBy comes from CompletionStatus. - Loop/waitToFire and Wait run on rounds directly: the wait IS the round (fired/reviewing with a WaitDeadline), drain-first uses engine.BlockingFindings, Wait uses engine.FindingsOnHead. Exit codes 0/10/2 and the internal 3 dedupe are unchanged; throttle riding, the >=60s pump cadence, and account-block deadline extension are preserved. - Deleted the completion-fallback chain and its Service classifier forwarders (feedbackCompletionContext, applyCompletionReplyFallback, completionReplyForFiredCommand, reviewCommandReplies, codexInactiveOrThumbed, codexThumbedUp, hasNonterminal/hasFailedReviewState, is{RateLimited, ReviewsPaused,ReviewAlreadyDone,CompletionReply,AutoReply}) plus the FeedbackWait shim struct and waitView/roundAnchor/ensureFeedbackWait/ extendFeedbackWaitDeadline/clearFeedbackWait. The adoption completion-reply guard moved to engine.CommandHasCompletionReply. - Deleted the dialect and gh alias layers; call sites use dialect.X/ghapi.X directly. The crq-local Finding/FeedbackReport JSON output is unchanged. - Vocabulary: "rate limit" as literal text now lives only in gh (Throttle) and dialect; engine/state/crq use Throttle (GitHub) or AccountQuota/ "account blocked" (CodeRabbit). The requeue reason="rate limited" log string is preserved via dialect.ReasonRateLimited. - Migrated the Loop/Feedback/Wait tests onto the native round API; ported the markReviewed suffix and completion-reply-adoption edges to engine_test.go. Added an AGENTS.md architecture map (CLAUDE.md symlink). --- AGENTS.md | 96 +++++ CLAUDE.md | 1 + cmd/crq/main.go | 3 +- internal/crq/auto.go | 53 +-- internal/crq/config.go | 15 +- internal/crq/dialect_aliases.go | 48 --- internal/crq/feedback.go | 694 ++++++++------------------------ internal/crq/feedback_test.go | 350 ++++++++-------- internal/crq/gh_aliases.go | 38 -- internal/crq/init.go | 6 +- internal/crq/observe.go | 81 ++-- internal/crq/preflight.go | 6 +- internal/crq/service.go | 87 ++-- internal/crq/service_test.go | 338 ++++++++-------- internal/crq/state.go | 50 +-- internal/dialect/coderabbit.go | 17 + internal/dialect/event.go | 22 +- internal/engine/completion.go | 21 + internal/engine/engine_test.go | 45 +++ internal/engine/progress.go | 19 +- internal/state/dashboard.go | 6 +- 21 files changed, 852 insertions(+), 1144 deletions(-) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md delete mode 100644 internal/crq/dialect_aliases.go delete mode 100644 internal/crq/gh_aliases.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2fa3f20 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,96 @@ +# crq architecture + +crq coordinates CodeRabbit/Codex PR reviews through one account-wide queue so +the fleet never double-fires a review or races the shared rate limit. This is a +map of how the code is laid out and the invariants that keep it correct. For the +CLI contract read `README.md` and `llms.txt`; for usage read `crq help`. + +## Package layout + +Dependency rule (Go-enforced, no cycles): `dialect ← engine ← crq`, `state ← crq`, +`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; 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". +- `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. +- `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. +- `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. + +Vocabulary: two distinct concepts, never mixed. GitHub REST quota = **Throttle** +(gh). CodeRabbit account quota = **AccountQuota** / "account blocked" (state, +engine, crq). "rate limit" as literal text lives only in `gh` and `dialect`. + +## State: one Round per PR, never deleted + +``` +queued → reserved → fired → reviewing → completed + ↑ │ │ │ + └─────────┘ ├─────────┴→ awaiting_retry ─→ (fire-eligible once RetryAt passes) + (post failed) └→ completed (review lands while slot held) + any phase → abandoned (PR closed, cancelled, or superseded by a new head) +``` + +Transitions are methods on `Round`; illegal edges error. A round is **never +deleted** — only transitioned, or archived when a new head supersedes it. That +is the invariant that makes the spam bug unrepresentable: "we already requested a +review at this head" is a fact you'd have to destroy a record to forget, and no +transition does that. `needsReview` collapses to `r, ok := Rounds[key]; ok && +r.Head == head → skip`. A completed round stays as the "this head was reviewed" +dedup marker. A rate-limited requeue parks the round in `awaiting_retry` (keeping +its head/attempts/history), it does not delete a fired marker. + +The global `FireSlot` allows ≤1 concurrent fire fleet-wide (CAS). A bot ack +releases the slot while the review keeps running (the round moves to +`reviewing`); the round itself stays open until `Completion` is done. + +## observe → decide → apply + +One flow drives both the daemon and the loop: + +1. **observe** (`crq/observe.go`) — the single place that asks GitHub "what + happened on this PR" and reduces it to an `engine.Observation` (head, open, + reviews, classified `BotEvent`s, adoptable commands, reactions). It also + 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 + 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". + +Daemon `Pump` = Progress on the slot round + DecideFire on the next eligible. +`crq loop` (Wait + Feedback) = the same DecideFire to fire, then `Completion` + +findings filters to converge. The wait IS the round: a fired/reviewing round with +a `WaitDeadline` is the in-flight wait. Loop exit codes are frozen: 0 converged/ +skipped, 10 findings, 2 timeout. + +## Adding a new bot-message format + +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. + +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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/cmd/crq/main.go b/cmd/crq/main.go index 6eef355..15a6662 100644 --- a/cmd/crq/main.go +++ b/cmd/crq/main.go @@ -13,6 +13,7 @@ import ( "time" "github.com/kristofferR/coderabbit-queue/internal/crq" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) type stderrLogger struct{} @@ -62,7 +63,7 @@ func run(ctx context.Context, args []string) int { fatal(err) return 1 } - gh, err := crq.NewGitHub(ctx) + gh, err := ghapi.NewGitHub(ctx) if err != nil { fatal(err) return 1 diff --git a/internal/crq/auto.go b/internal/crq/auto.go index f8a2fb4..cd69e12 100644 --- a/internal/crq/auto.go +++ b/internal/crq/auto.go @@ -7,6 +7,9 @@ import ( "os" "strings" "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) type AutoOptions struct { @@ -23,8 +26,8 @@ func (s *Service) AutoReview(ctx context.Context, opts AutoOptions) error { for { held, err := s.acquireLeader(ctx, owner, token) if err != nil { - if _, ok := rateLimitWait(err); ok { - if cont, serr := s.sleepRateLimit(ctx, opts, "leader", err); serr != nil || !cont { + if _, ok := ghapi.ThrottleWait(err); ok { + if cont, serr := s.sleepThrottle(ctx, opts, "leader", err); serr != nil || !cont { return serr } continue @@ -49,11 +52,11 @@ func (s *Service) AutoReview(ctx context.Context, opts AutoOptions) error { s.log.Printf("autoreview: lost leadership mid-pass; standing by") } } else { - // A rate-limited pass means the following API calls will keep failing — + // A throttled pass means the following API calls will keep failing — // sleep out the window instead of immediately pumping and re-scanning, // which would just hammer the quota. Skip Pump entirely in that case. - if _, ok := rateLimitWait(passErr); ok { - if cont, serr := s.sleepRateLimit(ctx, opts, "pass", passErr); serr != nil || !cont { + if _, ok := ghapi.ThrottleWait(passErr); ok { + if cont, serr := s.sleepThrottle(ctx, opts, "pass", passErr); serr != nil || !cont { if opts.Once { return s.finishAutoReviewOnce(ctx, token, serr) } @@ -66,8 +69,8 @@ func (s *Service) AutoReview(ctx context.Context, opts AutoOptions) error { } passFailure = passErr if _, err := s.Pump(ctx); err != nil { - if _, ok := rateLimitWait(err); ok { - if cont, serr := s.sleepRateLimit(ctx, opts, "pump", err); serr != nil || !cont { + if _, ok := ghapi.ThrottleWait(err); ok { + if cont, serr := s.sleepThrottle(ctx, opts, "pump", err); serr != nil || !cont { if opts.Once { return s.finishAutoReviewOnce(ctx, token, serr) } @@ -84,7 +87,7 @@ func (s *Service) AutoReview(ctx context.Context, opts AutoOptions) error { } } if opts.Once { - // A one-shot run must surface a real (non-rate-limit) scan/pump failure — + // A one-shot run must surface a real (non-throttle) scan/pump failure — // e.g. a permission or owner-lookup error — so cron/CI doesn't see success // when nothing was scanned or enqueued. The daemon keeps going (logged). return s.finishAutoReviewOnce(ctx, token, passFailure) @@ -106,10 +109,10 @@ func (s *Service) finishAutoReviewOnce(ctx context.Context, token string, err er return err } -// rateLimitBackoff bounds how long the autoreview daemon sleeps when GitHub -// rate-limits it: at least one poll interval, plus a small buffer past the +// throttleBackoff bounds how long the autoreview daemon sleeps when GitHub +// throttles it: at least one poll interval, plus a small buffer past the // reset, capped at an hour so a bogus reset header can't wedge the daemon. -func (s *Service) rateLimitBackoff(wait time.Duration) time.Duration { +func (s *Service) throttleBackoff(wait time.Duration) time.Duration { if wait <= 0 { wait = s.cfg.AutoReviewPoll } @@ -123,17 +126,17 @@ func (s *Service) rateLimitBackoff(wait time.Duration) time.Duration { return wait } -// sleepRateLimit waits out a GitHub rate-limit window that an autoreview +// sleepThrottle waits out a GitHub throttle window that an autoreview // leader/pass/pump step hit, using the same bounded backoff as the leader path so // a throttle pauses the daemon instead of spinning failing API calls. cause must -// be a rate-limit error (the caller checks rateLimitWait first). It returns +// be a throttle error (the caller checks ghapi.ThrottleWait first). It returns // cont=false (nil error) when opts.Once means we should stop after the wait, and a // non-nil error only when the context is cancelled mid-wait. -func (s *Service) sleepRateLimit(ctx context.Context, opts AutoOptions, stage string, cause error) (cont bool, err error) { - wait, _ := rateLimitWait(cause) - wait = s.rateLimitBackoff(wait) +func (s *Service) sleepThrottle(ctx context.Context, opts AutoOptions, stage string, cause error) (cont bool, err error) { + wait, _ := ghapi.ThrottleWait(cause) + wait = s.throttleBackoff(wait) if s.log != nil { - s.log.Printf("autoreview: %s rate-limited (%v); sleeping %s before next pass", stage, cause, wait.Round(time.Second)) + s.log.Printf("autoreview: %s throttled (%v); sleeping %s before next pass", stage, cause, wait.Round(time.Second)) } select { case <-ctx.Done(): @@ -226,7 +229,7 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t // Stream results and stop once the post-filter scan budget is spent, so // excluded/gate-repo results can't crowd out in-scope PRs (a fixed pre-filter // limit would never reach them) while we still don't over-fetch pages. - err := s.gh.EachOpenPR(ctx, target, byRepo, func(pr SearchPR) (bool, error) { + err := s.gh.EachOpenPR(ctx, target, byRepo, func(pr ghapi.SearchPR) (bool, error) { if scanned >= s.cfg.AutoReviewMaxScan { return true, nil } @@ -237,7 +240,7 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t if len(s.cfg.AllowRepos) > 0 && !s.cfg.AllowRepos[repo] { return false, nil } - if s.cfg.SkipAuthors[normalizeBotName(strings.ToLower(pr.Author))] { + if s.cfg.SkipAuthors[dialect.NormalizeBotName(strings.ToLower(pr.Author))] { return false, nil } if s.cfg.SkipMarker != "" && strings.Contains(pr.Body, s.cfg.SkipMarker) { @@ -259,10 +262,10 @@ func (s *Service) autoReviewPass(ctx context.Context, opts AutoOptions, owner, t } need, head, nerr := s.needsReview(ctx, state, repo, pr.Number, opts.Incremental) if nerr != nil { - // A rate limit must abort the pass so AutoReview's outer backoff kicks + // A throttle must abort the pass so AutoReview's outer backoff kicks // in, instead of scanning the rest of the candidates under the same // throttle (and skipping them until a later poll). - if isThrottled(nerr) { + if ghapi.IsThrottled(nerr) { return false, nerr } if s.log != nil { @@ -302,11 +305,11 @@ func (s *Service) needsReview(ctx context.Context, state State, repo string, pr if err != nil { return false, "", err } - bot := normalizeBotName(s.cfg.Bot) + bot := dialect.NormalizeBotName(s.cfg.Bot) lastBotReview := "" for _, review := range reviews { - if normalizeBotName(review.User.Login) == bot && review.CommitID != "" { - lastBotReview = shortOID(review.CommitID) + if dialect.NormalizeBotName(review.User.Login) == bot && review.CommitID != "" { + lastBotReview = dialect.ShortOID(review.CommitID) } } if incremental { @@ -324,7 +327,7 @@ func (s *Service) needsReview(ctx context.Context, state State, repo string, pr return false, "", err } for _, comment := range comments { - if normalizeBotName(comment.User.Login) == bot && strings.Contains(comment.Body, s.cfg.ReviewDoneMarker) { + if dialect.NormalizeBotName(comment.User.Login) == bot && strings.Contains(comment.Body, s.cfg.ReviewDoneMarker) { return false, head, nil } } diff --git a/internal/crq/config.go b/internal/crq/config.go index 778e084..8909be8 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -8,10 +8,17 @@ import ( "strconv" "strings" "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) const Version = "2.0.0-dev" +// The GitHub transport tags its User-Agent with the crq version. Version lives +// in this package, so the wiring stays here now that the gh alias layer is gone. +func init() { ghapi.UserAgent = "crq/" + Version } + type Config struct { GateRepo string DashboardIssue int @@ -98,8 +105,8 @@ func LoadConfig() (Config, error) { RequiredBots: requiredBots, FeedbackBots: listEnv(env, "CRQ_FEEDBACK_BOTS", strings.Join(unionBots(requiredBots, extraFeedbackBots), ",")), ReviewCommand: stringEnv(env, "CRQ_REVIEW_CMD", "@coderabbitai review"), - RateLimitCommand: stringEnv(env, "CRQ_RATELIMIT_CMD", "@coderabbitai rate limit"), - RateLimitMarker: stringEnv(env, "CRQ_RL_MARKER", "rate limited by coderabbit.ai"), + 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"), ReviewDoneMarker: stringEnv(env, "CRQ_REVIEW_DONE_MARKER", "summarize by coderabbit.ai"), CompletionMarker: stringEnvAllowEmpty(env, "CRQ_COMPLETION_MARKER", "Review finished"), @@ -251,7 +258,7 @@ func unionBots(lists ...[]string) []string { if item == "" { continue } - key := normalizeBotName(item) + key := dialect.NormalizeBotName(item) if seen[key] { continue } @@ -279,7 +286,7 @@ func repoSet(value string) map[string]bool { func authorSet(value string) map[string]bool { set := map[string]bool{} for _, item := range strings.Split(value, ",") { - item = normalizeBotName(strings.ToLower(strings.TrimSpace(item))) + item = dialect.NormalizeBotName(strings.ToLower(strings.TrimSpace(item))) if item != "" { set[item] = true } diff --git a/internal/crq/dialect_aliases.go b/internal/crq/dialect_aliases.go deleted file mode 100644 index b744d56..0000000 --- a/internal/crq/dialect_aliases.go +++ /dev/null @@ -1,48 +0,0 @@ -package crq - -import ( - "github.com/kristofferR/coderabbit-queue/internal/dialect" -) - -// Aliases for the bot-text helpers that moved to internal/dialect. They keep -// existing call sites and tests stable while the refactor lands package by -// package; new code should call dialect directly. Deleted once the engine -// extraction rewrites the callers. - -type Finding = dialect.Finding - -var ( - severityOf = dialect.SeverityOf - rankSeverity = dialect.RankSeverity - titleOf = dialect.TitleOf - stripMarkdownQuote = dialect.StripMarkdownQuote - compactReviewBody = dialect.CompactReviewBody - normalizeReviewText = dialect.NormalizeReviewText - looksLikePath = dialect.LooksLikePath - normalizeBotName = dialect.NormalizeBotName - inBots = dialect.InBots - botSet = dialect.BotSet - isCodexBot = dialect.IsCodexBot - hasCodexBot = dialect.HasCodexBot - isNonActionableText = dialect.IsNonActionableText - isActionableFinding = dialect.IsActionableFinding - isNoActionReviewCompletion = dialect.IsNoActionReviewCompletion - isCodexNoActionReviewCompletion = dialect.IsCodexNoActionReviewCompletion - codexReviewedCommitSHA = dialect.CodexReviewedCommitSHA - shaPrefixMatch = dialect.SHAPrefixMatch - shortOID = dialect.ShortOID - parseAvailableIn = dialect.ParseAvailableIn - parseQuota = dialect.ParseQuota - parseRemainingReviews = dialect.ParseRemainingReviews -) - -// parseReviewBodyFindings adapts a GitHub review to the dialect parsers, which -// take only the metadata they attach to findings. -func parseReviewBodyFindings(review Review, bot string) []Finding { - return dialect.ParseReviewBodyFindings(review.Body, dialect.ReviewMeta{ - ID: review.ID, - CommitID: review.CommitID, - HTMLURL: review.HTMLURL, - SubmittedAt: review.SubmittedAt, - }, bot) -} diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index e47573b..0d8a44f 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -10,26 +10,43 @@ import ( "strconv" "strings" "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) type FeedbackReport struct { - Status string `json:"status"` - Repo string `json:"repo"` - PR int `json:"pr"` - Head string `json:"head"` - Reason string `json:"reason,omitempty"` - Converged bool `json:"converged"` - ReviewedBy map[string]bool `json:"reviewed_by"` - Findings []Finding `json:"findings"` - CheckedAt time.Time `json:"checked_at"` + Status string `json:"status"` + Repo string `json:"repo"` + PR int `json:"pr"` + Head string `json:"head"` + Reason string `json:"reason,omitempty"` + Converged bool `json:"converged"` + ReviewedBy map[string]bool `json:"reviewed_by"` + Findings []dialect.Finding `json:"findings"` + CheckedAt time.Time `json:"checked_at"` } func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackReport, error) { repo = NormalizeRepo(repo) - pull, err := s.gh.GetPull(ctx, repo, pr) + now := time.Now().UTC() + st, _, err := s.store.Load(ctx) if err != nil { return FeedbackReport{}, err } + round := st.Round(repo, pr) + + // One fetch drives both halves: observe() reads the pull, reviews and issue + // comments (plus reactions when the round has fired). Feedback parses its + // findings from the raw reviews/comments and derives convergence from + // engine.Completion over the same snapshot — no second fetch path, and the + // "is head reviewed?" rules live only in the engine. + obs, err := s.observe(ctx, repo, pr, round, now) + if err != nil { + return FeedbackReport{}, err + } + pull := obs.pull head := "" if len(pull.Head.SHA) >= 9 { head = pull.Head.SHA[:9] @@ -40,27 +57,33 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe PR: pr, Head: head, ReviewedBy: map[string]bool{}, - Findings: []Finding{}, - CheckedAt: time.Now().UTC(), - } - // Two bot sets with different jobs. requiredBots gates convergence: crq isn't - // "done" until every one has reviewed the head, so only these seed ReviewedBy. - // extractBots is the broader set whose findings we surface — a superset that - // includes Codex — so a bot that reviews without being required (and would hang - // convergence if it were) still has its findings reported instead of dropped. - requiredBots := botSet(s.cfg.RequiredBots) - // Always extract from the required bots too: a bot crq waits for whose findings - // it didn't surface would hang the loop forever. FeedbackBots only widens this. - extractBots := botSet(unionBots(s.cfg.FeedbackBots, s.cfg.RequiredBots)) - for bot := range requiredBots { - report.ReviewedBy[bot] = false + Findings: []dialect.Finding{}, + CheckedAt: now, } - completion := s.feedbackCompletionContext(ctx, repo, pr, head) - reviews, err := s.gh.ListReviews(ctx, repo, pr) - if err != nil { - return report, err + // The completion anchor is the current round only when it still tracks this + // head. A stale or missing round yields a headless anchor (FiredAt nil), so + // only head-matching reviews and SHA-bound Codex summaries can count — the + // engine's rule set reproduces v2's "no wait context" behavior. + completionRound := Round{Repo: repo, PR: pr, Head: head} + if round != nil && round.Head == head { + completionRound = *round } + anchorOK := completionRound.FiredAt != nil + anchorCutoff := time.Time{} + if anchorOK { + anchorCutoff = completionRound.FiredAt.UTC() + } + completion := engine.Completion(completionRound, obs.eng, s.policy()) + report.ReviewedBy = completion.ReviewedBy + + // extractBots is the broader set whose findings we surface — a superset that + // includes Codex — so a bot that reviews without being required (and would + // hang convergence if it were) still has its findings reported instead of + // dropped. It always includes the required bots: a bot crq waits for whose + // findings it didn't surface would hang the loop forever. + extractBots := dialect.BotSet(unionBots(s.cfg.FeedbackBots, s.cfg.RequiredBots)) + // Review-body findings — CodeRabbit's detailed and "Prompt for AI agents" // blocks — carry no per-finding resolution state, only the review's commit. // When inline comments fail to post (GitHub 5xx / code-review limits) that @@ -69,33 +92,28 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe // (a rebase, a squash-merge). Extract instead from each bot's LATEST review // regardless of its commit: a newer review from the same bot supersedes it, // and resolved/outdated inline threads still suppress individual prompt - // duplicates below. Convergence (markReviewed) stays gated to a review whose - // commit matches the head, so the loop still waits for a real head review. - latestReview := map[string]Review{} - for _, review := range reviews { + // duplicates below. Convergence (engine.Completion) stays gated to a review + // whose commit matches the head, so the loop still waits for a real review. + latestReview := map[string]ghapi.Review{} + for _, review := range obs.reviews { login := review.User.Login - if !inBots(extractBots, login) { + if !dialect.InBots(extractBots, login) { continue } // Once a fresh review round has started for this head, a body submitted // before that round belongs to the previous head. Unresolved threads are // still surfaced below across commits, while thread-less body findings // must be re-reported by the current round instead of trapping the loop. - if completion.OK && s.isConfiguredBot(login) && + if anchorOK && s.isConfiguredBot(login) && (head == "" || !strings.HasPrefix(review.CommitID, head)) && - !notBefore(review.SubmittedAt, completion.Cutoff) { + !notBefore(review.SubmittedAt, anchorCutoff) { continue } - if head != "" && strings.HasPrefix(review.CommitID, head) { - // markReviewed only flips existing ReviewedBy keys (required bots), so a - // non-required extract bot reviewing here is a harmless no-op. - markReviewed(report.ReviewedBy, login) - } if cur, ok := latestReview[login]; !ok || reviewNewer(review, cur) { latestReview[login] = review } } - for _, review := range reviews { + for _, review := range obs.reviews { if lr, ok := latestReview[review.User.Login]; !ok || lr.ID != review.ID { continue } @@ -116,8 +134,8 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe } } } - } else if isThrottled(err) { - // A transient GraphQL rate limit must not silently degrade to the REST + } else if ghapi.IsThrottled(err) { + // A transient GraphQL throttle must not silently degrade to the REST // fallback, which loses thread resolution/outdated state and the cross-commit // unresolved findings this command promises. Surface it so Loop rides it out // instead of reporting converged from incomplete data. @@ -128,19 +146,19 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe return report, cerr } for _, comment := range comments { - if !inBots(extractBots, comment.User.Login) { + if !dialect.InBots(extractBots, comment.User.Login) { continue } - commit := shortOID(firstNonEmpty(comment.CommitID, comment.OriginalCommitID)) + commit := dialect.ShortOID(firstNonEmpty(comment.CommitID, comment.OriginalCommitID)) if head != "" && commit != "" && commit != head { continue } - report.Findings = append(report.Findings, Finding{ + report.Findings = append(report.Findings, dialect.Finding{ Bot: comment.User.Login, - Severity: severityOf(comment.Body), + Severity: dialect.SeverityOf(comment.Body), Path: comment.Path, Line: firstPositive(comment.Line, comment.OriginalLine), - Title: titleOf(comment.Body), + Title: dialect.TitleOf(comment.Body), Body: strings.TrimSpace(comment.Body), CommentID: comment.ID, ReviewID: comment.PullRequestReviewID, @@ -152,13 +170,6 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe } } - issueComments, err := s.gh.ListIssueComments(ctx, repo, pr) - if err != nil { - // Don't silently drop the issue-comment source: a rate limit or API error - // here would otherwise let crq report clean/converged while Codex issue - // comments (or completion signals) were simply never fetched. - return report, err - } // Top-level issue comments carry no commit SHA, so bound them to the current // head: a bot finding posted before this head was committed belongs to an earlier // round and must not trap crq loop on stale, already-addressed feedback. The @@ -176,61 +187,32 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe } return headCutoff } - for _, comment := range issueComments { - if !inBots(extractBots, comment.User.Login) { + for _, comment := range obs.comments { + if !dialect.InBots(extractBots, comment.User.Login) { continue } - if s.isRateLimited(comment.Body) { - continue + if s.cr.IsRateLimited(comment.Body) { + continue // an account-quota notice is never a finding } - // Codex reports a clean review as a top-level issue comment rather than a - // submitted GitHub review. Treat that message as a completion signal when - // Codex gates this round, and never surface it as an actionable finding when - // Codex is extraction-only. The persisted wait is the only safe way to bind - // an issue comment (which has no commit SHA) to the current head. - if isCodexBot(comment.User.Login) && isCodexNoActionReviewCompletion(comment.Body) { - // The newer summary 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 persisted wait was - // lost (e.g. after a crash or an interrupted loop). - if sha := codexReviewedCommitSHA(comment.Body); sha != "" { - if head != "" && shaPrefixMatch(sha, head) { - markReviewed(report.ReviewedBy, comment.User.Login) - } - continue - } - if completion.OK && notBefore(issueCommentTime(comment), completion.Cutoff) { - markReviewed(report.ReviewedBy, comment.User.Login) - } + // 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) { continue } - // Issue comments carry no commit SHA, so a stale completion summary from an - // earlier commit must not be treated as a review of the current head — rely on - // the persisted current-round feedback wait before treating a configured-bot - // no-action summary as the review completion signal. if s.isConfiguredBot(comment.User.Login) { - if completion.OK && isNoActionReviewCompletion(comment.Body) && notBefore(issueCommentTime(comment), completion.Cutoff) { - codexOK, err := s.codexInactiveOrThumbed(ctx, repo, pr, head, completion, issueComments, reviews, report.ReviewedBy) - if err != nil { - return report, err - } - if !codexOK { - continue - } - markReviewed(report.ReviewedBy, comment.User.Login) - } continue } - if isNonActionableText(comment.Body) { + if dialect.IsNonActionableText(comment.Body) { continue // notices/acks (e.g. usage-limit messages) aren't findings } if cutoff := headCutoffOf(); !cutoff.IsZero() && comment.CreatedAt.Before(cutoff) { continue // posted before the current head was committed — a stale round } - report.Findings = append(report.Findings, Finding{ + report.Findings = append(report.Findings, dialect.Finding{ Bot: comment.User.Login, - Severity: severityOf(comment.Body), - Title: titleOf(comment.Body), + Severity: dialect.SeverityOf(comment.Body), + Title: dialect.TitleOf(comment.Body), Body: strings.TrimSpace(comment.Body), CommentID: comment.ID, URL: comment.URL, @@ -239,22 +221,17 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe }) } - s.applyCompletionReplyFallback(ctx, repo, pr, head, &report, issueComments, reviews) - report.Findings = dedupeFindings(report.Findings, suppressPromptAt) sort.Slice(report.Findings, func(i, j int) bool { - if rankSeverity(report.Findings[i].Severity) != rankSeverity(report.Findings[j].Severity) { - return rankSeverity(report.Findings[i].Severity) > rankSeverity(report.Findings[j].Severity) + 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) } if report.Findings[i].Path != report.Findings[j].Path { return report.Findings[i].Path < report.Findings[j].Path } return report.Findings[i].Line < report.Findings[j].Line }) - report.Converged = len(report.Findings) == 0 - for _, reviewed := range report.ReviewedBy { - report.Converged = report.Converged && reviewed - } + report.Converged = engine.Converged(report.Findings, completion) if report.Converged { report.Status = "converged" } else if len(report.Findings) == 0 { @@ -263,118 +240,6 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe return report, nil } -type feedbackCompletionContext struct { - Cutoff time.Time - FiredCommentID int64 - OK bool -} - -func (s *Service) feedbackCompletionContext(ctx context.Context, repo string, pr int, head string) feedbackCompletionContext { - state, _, err := s.store.Load(ctx) - if err != nil { - return feedbackCompletionContext{} - } - if firedAt, commandID, ok := roundAnchor(&state, repo, pr, head); ok { - return feedbackCompletionContext{Cutoff: firedAt, FiredCommentID: commandID, OK: true} - } - return feedbackCompletionContext{} -} - -func (s *Service) codexInactiveOrThumbed(ctx context.Context, repo string, pr int, head string, completion feedbackCompletionContext, issueComments []IssueComment, reviews []Review, reviewedBy map[string]bool) (bool, error) { - codexActive := hasCodexBot(s.cfg.RequiredBots) - codexReviewed := reviewedByBot(reviewedBy, "chatgpt-codex-connector[bot]") - for _, review := range reviews { - if !isCodexBot(review.User.Login) { - continue - } - if head != "" && review.CommitID != "" && strings.HasPrefix(review.CommitID, head) { - codexActive = true - codexReviewed = true - break - } - if review.CommitID == "" && !review.SubmittedAt.IsZero() && notBefore(review.SubmittedAt, completion.Cutoff) { - codexActive = true - codexReviewed = true - break - } - } - if codexReviewed { - return true, nil - } - if !codexActive { - for _, comment := range issueComments { - if isCodexBot(comment.User.Login) && !isNonActionableText(comment.Body) && notBefore(issueCommentTime(comment), completion.Cutoff) { - codexActive = true - break - } - } - } - if !codexActive { - return true, nil - } - if ok, err := s.codexThumbedUp(ctx, repo, pr, completion); err != nil { - return false, err - } else if ok { - markReviewed(reviewedBy, "chatgpt-codex-connector[bot]") - return true, nil - } - return false, nil -} - -func (s *Service) codexThumbedUp(ctx context.Context, repo string, pr int, completion feedbackCompletionContext) (bool, error) { - reactions, err := s.gh.ListIssueReactions(ctx, repo, pr) - if err != nil { - return false, err - } - for _, reaction := range reactions { - if isCurrentCodexThumbsUp(reaction, completion.Cutoff) { - return true, nil - } - } - if completion.FiredCommentID == 0 { - return false, nil - } - reactions, err = s.gh.ListCommentReactions(ctx, repo, completion.FiredCommentID) - if err != nil { - return false, err - } - for _, reaction := range reactions { - if isCurrentCodexThumbsUp(reaction, completion.Cutoff) { - return true, nil - } - } - return false, nil -} - -// findingsBlockingFreshReview identifies feedback that can still be acted on or -// resolved before requesting a review of head. Unresolved threads remain -// actionable across commits, while thread-less review-body/prompt findings from -// an older commit cannot be resolved on GitHub. Those older summaries are -// superseded by the next current-head review and must not deadlock the loop after -// the caller has pushed its fixes. -func findingsBlockingFreshReview(findings []Finding, head string) []Finding { - blocking := make([]Finding, 0, len(findings)) - for _, finding := range findings { - if finding.ThreadID != "" || finding.Commit == "" || head == "" || strings.HasPrefix(finding.Commit, head) { - blocking = append(blocking, finding) - } - } - return blocking -} - -// findingsReportedOnHead excludes carried review artifacts from older commits. -// Wait uses this narrower filter because its job is still to request a review -// when the only visible feedback predates the queued head. -func findingsReportedOnHead(findings []Finding, head string) []Finding { - current := make([]Finding, 0, len(findings)) - for _, finding := range findings { - if finding.Commit == "" || head == "" || strings.HasPrefix(finding.Commit, head) { - current = append(current, finding) - } - } - return current -} - func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport, int, error) { repo = NormalizeRepo(repo) // Do not spend a new review slot while actionable feedback from an earlier @@ -394,22 +259,22 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport if loadErr != nil { return FeedbackReport{}, 1, loadErr } - if waitView(&state, repo, pr).Head != head { + if waitingHead(&state, repo, pr) != head { for { report, feedbackErr := s.Feedback(ctx, repo, pr) if feedbackErr != nil { - if wait, ok := rateLimitWait(feedbackErr); ok { + if wait, ok := ghapi.ThrottleWait(feedbackErr); ok { if wait <= 0 { wait = s.cfg.PollInterval } - if serr := sleepCtx(ctx, wait); serr != nil { + if serr := ghapi.SleepCtx(ctx, wait); serr != nil { return report, 1, serr } continue } return report, 1, feedbackErr } - blocking := findingsBlockingFreshReview(report.Findings, head) + blocking := engine.BlockingFindings(report.Findings, head) if len(blocking) > 0 { report.Findings = blocking report.Status = "feedback" @@ -437,9 +302,9 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport // timeout so the caller retries later instead. A skipped wait result is // terminal, not retryable, so preserve it as a skipped report. if waitResult.Action == "skipped" { - s.clearFeedbackWait(ctx, repo, pr, "") + s.completeWaitRound(ctx, repo, pr, "") } - return FeedbackReport{Status: status, Repo: NormalizeRepo(repo), PR: pr, Head: waitResult.Head, Reason: waitResult.Reason, ReviewedBy: map[string]bool{}, Findings: []Finding{}}, code, nil + return FeedbackReport{Status: status, Repo: NormalizeRepo(repo), PR: pr, Head: waitResult.Head, Reason: waitResult.Reason, ReviewedBy: map[string]bool{}, Findings: []dialect.Finding{}}, code, nil } head = waitResult.Head if head == "" { @@ -449,11 +314,10 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport return FeedbackReport{}, 1, herr } } - wait, err := s.ensureFeedbackWait(ctx, repo, pr, head) + deadline, err := s.ensureWaitDeadline(ctx, repo, pr, head) if err != nil { return FeedbackReport{}, 1, err } - deadline := wait.Deadline var lastLog time.Time // Pump keeps the queue moving while we wait, but once a minute is plenty (the // autoreview daemon pumps too); pumping on every tick just burns REST quota. @@ -462,20 +326,20 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport for { report, err := s.Feedback(ctx, repo, pr) if err != nil { - // A GitHub REST rate limit (the shared 5000/hr quota) is transient — ride + // A GitHub REST throttle (the shared 5000/hr quota) is transient — ride // it out like a network outage rather than failing the agent. Wait for the // reset and push the review deadline past it: GitHub throttling isn't the // bot taking long to review. - if wait, ok := rateLimitWait(err); ok { + if wait, ok := ghapi.ThrottleWait(err); ok { if wait <= 0 { wait = s.cfg.PollInterval } deadline = deadline.Add(wait) if s.log != nil { - s.log.Printf("%s#%d GitHub API rate-limited; waiting %s for the reset, then resuming", repo, pr, wait.Round(time.Second)) + s.log.Printf("%s#%d GitHub API throttled; waiting %s for the reset, then resuming", repo, pr, wait.Round(time.Second)) } - s.extendFeedbackWaitDeadline(ctx, repo, pr, head, deadline) - if serr := sleepCtx(ctx, wait); serr != nil { + s.pushWaitDeadline(ctx, repo, pr, head, deadline) + if serr := ghapi.SleepCtx(ctx, wait); serr != nil { return report, 1, serr } continue @@ -493,14 +357,14 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport } else { report.Reason = "hold current head: fix locally, but do not commit or push until every required reviewer finishes" } - s.clearFeedbackWait(ctx, repo, pr, head) + s.completeWaitRound(ctx, repo, pr, head) return report, 10, nil } if report.Converged { - s.clearFeedbackWait(ctx, repo, pr, head) + s.completeWaitRound(ctx, repo, pr, head) return report, 0, nil } - // Keep the queue moving (re-fire once a rate-limit window clears) and pick up + // Keep the queue moving (re-fire once an account-block window clears) and pick up // the Blocked state it leaves behind. Pumping every poll tick is redundant — // with several loops waiting concurrently it multiplies into real REST-quota // cost — so each waiter pumps at most once per pumpEvery. @@ -510,16 +374,16 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport } lastPump = time.Now() } - // While the account is rate-limited the PR can't be reviewed yet — it just + // While the account is blocked the PR can't be reviewed yet — it just // stays queued — so that wait must not count against the feedback timeout, and // there's nothing to fetch until the window clears. Push the deadline past the // block and poll slowly, so a long queue wait doesn't drain the shared GitHub - // REST quota (and re-hit its rate limit) every PollInterval. + // REST quota (and re-hit its throttle) every PollInterval. poll := s.cfg.PollInterval var blockedUntil *time.Time now := time.Now().UTC() if st, _, lerr := s.store.Load(ctx); lerr == nil { - if until, ok := feedbackBlockedUntil(st, repo, pr, head, now); ok { + if until, ok := accountBlockedUntil(&st, repo, pr, head, now); ok { blockedUntil = &until } } @@ -527,12 +391,12 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport extended := extendDeadlineForBlock(deadline, blockedUntil, now, s.cfg.FeedbackWaitTimeout) if extended.After(deadline) { deadline = extended - s.extendFeedbackWaitDeadline(ctx, repo, pr, head, deadline) + s.pushWaitDeadline(ctx, repo, pr, head, deadline) } poll = blockedPollInterval(*blockedUntil, now, s.cfg.PollInterval) } if now.After(deadline) { - s.clearFeedbackWait(ctx, repo, pr, head) + s.completeWaitRound(ctx, repo, pr, head) if len(report.Findings) > 0 { report.Status = "feedback" report.Reason = "review wait timed out; actionable findings must be addressed before retrying" @@ -544,7 +408,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport if s.log != nil && time.Since(lastLog) >= 30*time.Second { activeElapsed := feedbackWaitElapsed(deadline, s.cfg.FeedbackWaitTimeout, now) if blockedUntil != nil { - s.log.Printf("%s#%d queued — account rate-limited until %s; waiting, not counting it against the %s review wait (%s active)", repo, pr, blockedUntil.UTC().Format(time.RFC3339), s.cfg.FeedbackWaitTimeout, activeElapsed.Round(time.Second)) + s.log.Printf("%s#%d queued — account blocked until %s; waiting, not counting it against the %s review wait (%s active)", repo, pr, blockedUntil.UTC().Format(time.RFC3339), s.cfg.FeedbackWaitTimeout, activeElapsed.Round(time.Second)) } else { s.log.Printf("%s#%d waiting for review feedback on %s — reviewed %s (%s / %s)", repo, pr, report.Head, reviewedSummary(report.ReviewedBy), activeElapsed.Round(time.Second), s.cfg.FeedbackWaitTimeout) } @@ -558,17 +422,20 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport } } -func (s *Service) ensureFeedbackWait(ctx context.Context, repo string, pr int, head string) (FeedbackWait, error) { +// ensureWaitDeadline returns the wall-clock deadline the loop bounds its poll +// by. The wait IS the round: when the fired/reviewing round has no WaitDeadline +// yet (Pump normally sets it at fire time), this sets one budget past the fire. +// If the round is no longer waiting (completed/none), it returns a transient +// deadline so the loop still terminates. +func (s *Service) ensureWaitDeadline(ctx context.Context, repo string, pr int, head string) (time.Time, error) { repo = NormalizeRepo(repo) st, _, err := s.store.Load(ctx) if err != nil { - return FeedbackWait{}, err + return time.Time{}, err } - if w := waitView(&st, repo, pr); w.Head == head && !w.Deadline.IsZero() { - return w, nil + if dl, ok := roundWaitDeadline(&st, repo, pr, head); ok { + return dl, nil } - // Set the wait deadline on the fired/reviewing round if the fire path hasn't - // yet (Pump normally sets it at fire time). changed := false updated, err := s.store.Update(ctx, func(st *State) error { changed = false @@ -587,21 +454,22 @@ func (s *Service) ensureFeedbackWait(ctx context.Context, repo string, pr int, h return nil }) if err != nil { - return FeedbackWait{}, err + return time.Time{}, err } if changed { s.sync(ctx, updated) } - if w := waitView(&updated, repo, pr); w.Head == head && !w.Deadline.IsZero() { - return w, nil + if dl, ok := roundWaitDeadline(&updated, repo, pr, head); ok { + return dl, nil } // The round is no longer a wait (completed/none): synthesize a transient // deadline so the loop still bounds its poll. - now := time.Now().UTC() - return FeedbackWait{Repo: repo, PR: pr, Head: head, StartedAt: now, Deadline: now.Add(s.cfg.FeedbackWaitTimeout), ByHost: s.cfg.Host}, nil + return time.Now().UTC().Add(s.cfg.FeedbackWaitTimeout), nil } -func (s *Service) extendFeedbackWaitDeadline(ctx context.Context, repo string, pr int, head string, deadline time.Time) { +// pushWaitDeadline moves the fired/reviewing round's wait deadline later (never +// earlier), persisting the extension an account block or GitHub throttle bought. +func (s *Service) pushWaitDeadline(ctx context.Context, repo string, pr int, head string, deadline time.Time) { repo = NormalizeRepo(repo) changed := false state, err := s.store.Update(ctx, func(st *State) error { @@ -630,10 +498,10 @@ func (s *Service) extendFeedbackWaitDeadline(ctx context.Context, repo string, p } } -// clearFeedbackWait ends the wait by completing the fired/reviewing round. The -// completed round remains as the dedup marker (v2 kept Fired[key] while deleting -// the wait), so a subsequent enqueue/needsReview at the same head is deduped. -func (s *Service) clearFeedbackWait(ctx context.Context, repo string, pr int, head string) { +// completeWaitRound ends the wait by completing the fired/reviewing round. The +// completed round remains as the "this head was reviewed" dedup marker, so a +// subsequent enqueue/needsReview at the same head is deduped rather than re-fired. +func (s *Service) completeWaitRound(ctx context.Context, repo string, pr int, head string) { repo = NormalizeRepo(repo) changed := false state, err := s.store.Update(ctx, func(st *State) error { @@ -674,7 +542,7 @@ func (s *Service) waitToFire(ctx context.Context, repo string, pr int) (PumpResu if err == nil { return result, code, nil } - wait, ok := rateLimitWait(err) + wait, ok := ghapi.ThrottleWait(err) if !ok { return result, code, err } @@ -682,15 +550,15 @@ func (s *Service) waitToFire(ctx context.Context, repo string, pr int) (PumpResu wait = s.cfg.PollInterval } if s.log != nil { - s.log.Printf("%s#%d GitHub API rate-limited before firing; waiting %s for the reset, then retrying", repo, pr, wait.Round(time.Second)) + s.log.Printf("%s#%d GitHub API throttled before firing; waiting %s for the reset, then retrying", repo, pr, wait.Round(time.Second)) } - if serr := sleepCtx(ctx, wait); serr != nil { + if serr := ghapi.SleepCtx(ctx, wait); serr != nil { return result, code, serr } } } -// blockedPollInterval slows the feedback poll while the account is rate-limited: +// blockedPollInterval slows the feedback poll while the account is blocked: // nothing can be fetched until the window clears, so wait until just past the // reset instead of every PollInterval — capped so the loop still re-checks // periodically. Keeps a long queue wait from draining the shared GitHub REST quota. @@ -718,7 +586,7 @@ func blockedPollInterval(blockedUntil, now time.Time, base time.Duration) time.D } // extendDeadlineForBlock keeps the feedback-wait deadline from elapsing while the -// CodeRabbit account is rate-limited. A blocked PR can't be reviewed — it just +// CodeRabbit account is blocked. A blocked PR can't be reviewed — it just // stays queued until the window clears and crq re-fires — so that time shouldn't // burn the review-wait budget. When blocked, the deadline is pushed to a full // budget past the block; it is never moved earlier. @@ -732,15 +600,7 @@ func extendDeadlineForBlock(deadline time.Time, blockedUntil *time.Time, now tim return deadline } -// feedbackBlockedUntil returns the latest active block that prevents this exact -// PR head from firing: the account-wide quota block, or this round's own -// awaiting_retry window. Feedback waiters must honor both or they can claim to -// be waiting on a review that crq is still forbidden to request. -func feedbackBlockedUntil(st State, repo string, pr int, head string, now time.Time) (time.Time, bool) { - return accountBlockedUntil(&st, repo, pr, head, now) -} - -// feedbackWaitElapsed reports only reviewable time. A rate-limit block extends +// feedbackWaitElapsed reports only reviewable time. An account block extends // deadline, so deriving progress from the remaining budget excludes the blocked // interval and can never produce impossible output such as "35m / 20m". func feedbackWaitElapsed(deadline time.Time, budget time.Duration, now time.Time) time.Duration { @@ -943,9 +803,20 @@ func (s *Service) reviewThreads(ctx context.Context, repo string, pr int) ([]rev return all, nil } +// parseReviewBodyFindings adapts a GitHub review to the dialect parsers, which +// take only the metadata they attach to findings. +func parseReviewBodyFindings(review ghapi.Review, bot string) []dialect.Finding { + return dialect.ParseReviewBodyFindings(review.Body, dialect.ReviewMeta{ + ID: review.ID, + CommitID: review.CommitID, + HTMLURL: review.HTMLURL, + SubmittedAt: review.SubmittedAt, + }, bot) +} + // reviewNewer reports whether review a supersedes b: later submission wins, and // a higher ID breaks ties (equal/zero timestamps) so selection is deterministic. -func reviewNewer(a, b Review) bool { +func reviewNewer(a, b ghapi.Review) bool { if !a.SubmittedAt.Equal(b.SubmittedAt) { return a.SubmittedAt.After(b.SubmittedAt) } @@ -958,25 +829,25 @@ func reviewNewer(a, b Review) bool { // so a real finding from an earlier commit is surfaced instead of silently // dropped when HEAD moves. (This is why callers do not need a manual // cross-review audit.) Resolved or outdated threads are skipped. -func threadFindings(thread reviewThread, bots map[string]struct{}) []Finding { +func threadFindings(thread reviewThread, bots map[string]struct{}) []dialect.Finding { if thread.IsResolved || thread.IsOutdated { return nil } - var out []Finding + var out []dialect.Finding for _, comment := range thread.Comments.Nodes { - if !inBots(bots, comment.Author.Login) { + if !dialect.InBots(bots, comment.Author.Login) { continue } - commit := shortOID(comment.Commit.OID) + commit := dialect.ShortOID(comment.Commit.OID) if commit == "" { - commit = shortOID(comment.OriginalCommit.OID) + commit = dialect.ShortOID(comment.OriginalCommit.OID) } - out = append(out, Finding{ + out = append(out, dialect.Finding{ Bot: comment.Author.Login, - Severity: severityOf(comment.Body), + Severity: dialect.SeverityOf(comment.Body), Path: firstNonEmpty(thread.Path, comment.Path), Line: firstPositive(thread.Line, comment.Line, comment.OriginalLine), - Title: titleOf(comment.Body), + Title: dialect.TitleOf(comment.Body), Body: strings.TrimSpace(comment.Body), ThreadID: thread.ID, CommentID: comment.DatabaseID, @@ -995,7 +866,7 @@ func threadFindings(thread reviewThread, bots map[string]struct{}) []Finding { func promptSuppressKeys(thread reviewThread, bots map[string]struct{}) []string { var keys []string for _, comment := range thread.Comments.Nodes { - if !inBots(bots, comment.Author.Login) { + if !dialect.InBots(bots, comment.Author.Login) { continue } path := firstNonEmpty(thread.Path, comment.Path) @@ -1003,33 +874,33 @@ func promptSuppressKeys(thread reviewThread, bots map[string]struct{}) []string if path == "" || line <= 0 { continue } - keys = append(keys, normalizeBotName(comment.Author.Login)+"|"+path+"|"+strconv.Itoa(line)) + keys = append(keys, dialect.NormalizeBotName(comment.Author.Login)+"|"+path+"|"+strconv.Itoa(line)) } return keys } -func dedupeFindings(in []Finding, suppressPromptAt map[string]bool) []Finding { +func dedupeFindings(in []dialect.Finding, suppressPromptAt map[string]bool) []dialect.Finding { seen := map[string]bool{} structuredAtLocation := map[string]bool{} for _, finding := range in { if finding.Source != "review_prompt" && finding.Path != "" && finding.Line > 0 { - structuredAtLocation[normalizeBotName(finding.Bot)+"|"+finding.Path+"|"+strconv.Itoa(finding.Line)] = true + structuredAtLocation[dialect.NormalizeBotName(finding.Bot)+"|"+finding.Path+"|"+strconv.Itoa(finding.Line)] = true } } - out := []Finding{} + out := []dialect.Finding{} for _, finding := range in { finding.Body = strings.TrimSpace(finding.Body) finding.Title = strings.TrimSpace(finding.Title) - if !isActionableFinding(finding) { + if !dialect.IsActionableFinding(finding) { continue } if finding.Source == "review_prompt" { - key := normalizeBotName(finding.Bot) + "|" + finding.Path + "|" + strconv.Itoa(finding.Line) + key := dialect.NormalizeBotName(finding.Bot) + "|" + finding.Path + "|" + strconv.Itoa(finding.Line) if structuredAtLocation[key] || suppressPromptAt[key] { continue } } - key := normalizeBotName(finding.Bot) + "|" + finding.Path + "|" + strconv.Itoa(finding.Line) + "|" + finding.Title + "|" + finding.Body + "|" + finding.ThreadID + key := dialect.NormalizeBotName(finding.Bot) + "|" + finding.Path + "|" + strconv.Itoa(finding.Line) + "|" + finding.Title + "|" + finding.Body + "|" + finding.ThreadID sum := sha256.Sum256([]byte(key)) finding.ID = hex.EncodeToString(sum[:]) if seen[finding.ID] { @@ -1041,262 +912,13 @@ func dedupeFindings(in []Finding, suppressPromptAt map[string]bool) []Finding { return out } -func (s *Service) isCompletionReply(body string) bool { return s.cr.IsCompletionReply(body) } - -// hasNonterminalReviewState reports whether CodeRabbit currently exposes a -// post-command state that contradicts a terminal completion reply. The top -// summary is edited in place, so its UpdatedAt, not its original CreatedAt, -// determines which command round the current body belongs to. -func (s *Service) hasNonterminalReviewState(comments []IssueComment, since time.Time) bool { - for _, comment := range comments { - if !s.isConfiguredBot(comment.User.Login) || !notBefore(issueCommentTime(comment), since) { - continue - } - if s.cr.IsReviewInProgress(comment.Body) || s.isRateLimited(comment.Body) || s.isReviewsPaused(comment.Body) { - return true - } - } - return false -} - -func (s *Service) hasFailedReviewState(comments []IssueComment, since time.Time) bool { - for _, comment := range comments { - if !s.isConfiguredBot(comment.User.Login) || !notBefore(issueCommentTime(comment), since) { - continue - } - if s.cr.IsReviewFailure(comment.Body) { - return true - } - } - return false -} - -func (s *Service) isAutoReply(body string) bool { return s.cr.IsAutoReply(body) } - -// applyCompletionReplyFallback marks the configured bot reviewed when a -// no-findings re-review completed by issue-comment reply rather than a review -// object. The state anchor is mandatory because issue comments carry no commit, -// and a prior submitted review by the bot is mandatory because only a -// re-review can complete without posting a review object (see -// completionReplyForFiredCommand). -func (s *Service) applyCompletionReplyFallback(ctx context.Context, repo string, pr int, head string, report *FeedbackReport, issueComments []IssueComment, reviews []Review) { - if !needsConfiguredBotReview(report.ReviewedBy, s.cfg.Bot) { - return - } - firedAt, ok := s.completionFallbackFiredAt(ctx, repo, pr, head) - if !ok { - return - } - if s.completionReplyForFiredCommand(issueComments, reviews, firedAt) { - markReviewed(report.ReviewedBy, s.cfg.Bot) - } -} - -func (s *Service) completionFallbackFiredAt(ctx context.Context, repo string, pr int, head string) (time.Time, bool) { - st, _, err := s.store.Load(ctx) - if err != nil { - return time.Time{}, false - } - firedAt, _, ok := roundAnchor(&st, repo, pr, head) - if !ok { - return time.Time{}, false - } - return firedAt, true -} - -type reviewCommandReply struct { - commandID int64 - commandAt time.Time - completion bool -} - -// completionReplyForFiredCommand reports whether the command fired at/after -// firedAt received a completion reply. A raw timestamp check is not enough: an -// earlier round still finishing can post its "Review finished" after the new -// head's command was fired, which would converge the new round before its -// review ran. Replies are paired chronologically with the earliest unanswered -// command, while submitted reviews consume the command they answered, so older -// completed commands cannot steal a later completion reply. -// -// The completion reply only stands in for a review when the bot has already -// submitted at least one review on the PR: it models a no-findings re-review -// ("nothing new since my last review"), which presupposes a prior review. -// CodeRabbit can answer the first-ever command on a PR with an instant -// "Review finished" while the real review is still queued on its side -// (observed: ack 5s after the trigger, review with 11 findings landing -// minutes later) — counting that ack converged the round with zero findings -// and cleared the feedback wait, which also let autoreview fire a duplicate -// command for the same head. -func (s *Service) completionReplyForFiredCommand(comments []IssueComment, reviews []Review, firedAt time.Time) bool { - if !botHasAnyReview(reviews, s.cfg.Bot) { - return false - } - for _, reply := range s.reviewCommandReplies(comments, reviews) { - if reply.completion && notBefore(reply.commandAt, firedAt) && - !s.hasNonterminalReviewState(comments, reply.commandAt) && - !s.hasFailedReviewState(comments, reply.commandAt) { - return true - } - } - return false -} - -// botHasAnyReview reports whether login has a submitted review on the PR, on -// any commit. A CodeRabbit review that actually ran always submits a review -// object ("Actionable comments posted: N"), so its absence means the PR was -// never reviewed and a completion reply cannot mean "nothing new to re-review". -func botHasAnyReview(reviews []Review, login string) bool { - bots := botSet([]string{login}) - for _, review := range reviews { - if inBots(bots, review.User.Login) { - return true - } - } - return false -} - -func (s *Service) reviewCommandHasCompletionReply(comments []IssueComment, reviews []Review, commandID int64) bool { - if commandID == 0 { - return false - } - for _, reply := range s.reviewCommandReplies(comments, reviews) { - if reply.commandID == commandID && reply.completion && !s.hasNonterminalReviewState(comments, reply.commandAt) { - return true - } - } - return false -} - -func (s *Service) reviewCommandReplies(comments []IssueComment, reviews []Review) []reviewCommandReply { - command := strings.TrimSpace(s.cfg.ReviewCommand) - if command == "" { - return nil - } - - type eventKind int - const ( - eventCommand eventKind = iota - eventAutoReply - eventReview - ) - type event struct { - kind eventKind - at time.Time - id int64 - comment IssueComment - } - - var events []event - for _, c := range comments { - body := strings.TrimSpace(c.Body) - switch { - case body == command && !s.isConfiguredBot(c.User.Login): - events = append(events, event{kind: eventCommand, at: commentTime(c), id: c.ID, comment: c}) - case s.isConfiguredBot(c.User.Login) && s.isAutoReply(c.Body): - events = append(events, event{kind: eventAutoReply, at: commentTime(c), id: c.ID, comment: c}) - } - } - for _, review := range reviews { - if !s.isConfiguredBot(review.User.Login) || review.SubmittedAt.IsZero() { - continue - } - events = append(events, event{kind: eventReview, at: review.SubmittedAt, id: review.ID}) - } - sort.SliceStable(events, func(i, j int) bool { - if !events[i].at.Equal(events[j].at) { - return events[i].at.Before(events[j].at) - } - if events[i].kind != events[j].kind { - return events[i].kind < events[j].kind - } - return events[i].id < events[j].id - }) - - var out []reviewCommandReply - var pending []IssueComment - for _, ev := range events { - switch ev.kind { - case eventCommand: - pending = append(pending, ev.comment) - case eventReview: - if len(pending) > 0 { - pending = pending[1:] - } - case eventAutoReply: - if len(pending) == 0 { - continue - } - cmd := pending[0] - pending = pending[1:] - out = append(out, reviewCommandReply{ - commandID: cmd.ID, - commandAt: commentTime(cmd), - completion: s.isCompletionReply(ev.comment.Body) && !s.isRateLimited(ev.comment.Body), - }) - } - } - return out -} - -func commentTime(c IssueComment) time.Time { - if !c.CreatedAt.IsZero() { - return c.CreatedAt - } - return c.UpdatedAt -} - -// needsConfiguredBotReview reports whether login gates convergence (has a -// ReviewedBy key) and its review for the head hasn't been seen yet — the only -// case where the completion-reply fallback needs to run. -func needsConfiguredBotReview(reviewedBy map[string]bool, login string) bool { - norm := normalizeBotName(login) - for bot, reviewed := range reviewedBy { - if bot == login || normalizeBotName(bot) == norm { - return !reviewed - } - } - return false -} - -func isCurrentCodexThumbsUp(reaction Reaction, since time.Time) bool { - if !isCodexBot(reaction.User.Login) || reaction.Content != "+1" { +func isCurrentCodexThumbsUp(reaction ghapi.Reaction, since time.Time) bool { + if !dialect.IsCodexBot(reaction.User.Login) || reaction.Content != "+1" { return false } return reaction.CreatedAt.IsZero() || notBefore(reaction.CreatedAt, since) } -// markReviewed flips the configured required-bot key that login matches to true, -// tolerating the "[bot]" suffix difference between REST ("coderabbitai[bot]") and -// GraphQL ("coderabbitai") logins. It updates the existing key rather than -// inserting the raw login, so convergence (which ANDs every key) can't be broken -// by a duplicate key that never flips true. -func markReviewed(reviewedBy map[string]bool, login string) { - norm := normalizeBotName(login) - for bot := range reviewedBy { - if bot == login || normalizeBotName(bot) == norm { - reviewedBy[bot] = true - return - } - } -} - -func reviewedByBot(reviewedBy map[string]bool, login string) bool { - norm := normalizeBotName(login) - for bot, reviewed := range reviewedBy { - if reviewed && (bot == login || normalizeBotName(bot) == norm) { - return true - } - } - return false -} - -func issueCommentTime(comment IssueComment) time.Time { - if comment.UpdatedAt.After(comment.CreatedAt) { - return comment.UpdatedAt.UTC() - } - return comment.CreatedAt.UTC() -} - func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index 2681c10..ff3076e 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -5,23 +5,26 @@ import ( "strings" "testing" "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) func TestLooksLikePathAcceptsRootFiles(t *testing.T) { for _, p := range []string{"Dockerfile", "Makefile", "LICENSE", "go.mod", "src/app.go", "a/b/c"} { - if !looksLikePath(p) { + if !dialect.LooksLikePath(p) { t.Fatalf("expected %q to be treated as a path", p) } } for _, p := range []string{"", "Additional comments", "🧹 Nitpick comments", "two words"} { - if looksLikePath(p) { + if dialect.LooksLikePath(p) { t.Fatalf("expected %q NOT to be a path", p) } } } func TestDedupeSuppressesResolvedThreadPromptDuplicate(t *testing.T) { - findings := []Finding{ + findings := []dialect.Finding{ {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} @@ -42,19 +45,19 @@ func TestFeedbackBoundsIssueCommentsToHead(t *testing.T) { gh := newFakeGitHub() headTime := time.Now().UTC() sha := "abcdef1234567890" - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = sha gh.pulls[fakeKey("o/repo", 3)] = pull - gc := gitCommit{SHA: sha} + gc := ghapi.Commit{SHA: sha} gc.Committer.Date = headTime gh.commits[sha] = gc - mkc := func(id int64, body string, at time.Time) IssueComment { - ic := IssueComment{ID: id, Body: body, CreatedAt: at, UpdatedAt: at} + mkc := func(id int64, body string, at time.Time) ghapi.IssueComment { + ic := ghapi.IssueComment{ID: id, Body: body, CreatedAt: at, UpdatedAt: at} ic.User.Login = "chatgpt-codex-connector[bot]" return ic } - gh.comments[fakeKey("o/repo", 3)] = []IssueComment{ + gh.comments[fakeKey("o/repo", 3)] = []ghapi.IssueComment{ mkc(1, "Stale finding from the previous head", headTime.Add(-time.Hour)), mkc(2, "Current finding for this head", headTime.Add(time.Minute)), } @@ -91,22 +94,22 @@ func TestFeedbackCountsCompletionReplyForFiredHead(t *testing.T) { completion := "\n✅ Action performed\n\nReview finished." setup := func(replyAt time.Time, replyBody string, seedHistory, priorReview bool) *Service { gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = sha gh.pulls[fakeKey("o/repo", 3)] = pull - trigger := IssueComment{ID: 1, Body: "@coderabbitai review", CreatedAt: firedAt, UpdatedAt: firedAt} + trigger := ghapi.IssueComment{ID: 1, Body: "@coderabbitai review", CreatedAt: firedAt, UpdatedAt: firedAt} trigger.User.Login = "kristofferR" - reply := IssueComment{ID: 2, Body: replyBody, CreatedAt: replyAt, UpdatedAt: replyAt} + reply := ghapi.IssueComment{ID: 2, Body: replyBody, CreatedAt: replyAt, UpdatedAt: replyAt} reply.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/repo", 3)] = []IssueComment{trigger, reply} + gh.comments[fakeKey("o/repo", 3)] = []ghapi.IssueComment{trigger, reply} if priorReview { // A no-findings re-review presupposes an earlier review of the PR // (on some older commit); without one the completion reply must // not stand in for a review (covered by the dedicated case below). - prior := Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} + prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} prior.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("o/repo", 3)] = []Review{prior} + gh.reviews[fakeKey("o/repo", 3)] = []ghapi.Review{prior} } store := NewMemoryStore(cfg) if seedHistory { @@ -194,24 +197,24 @@ func TestFeedbackRejectsCompletionReplyWhileTopSummaryIsProcessing(t *testing.T) sha := "abcdef1234567890" head := sha[:9] firedAt := time.Now().UTC().Add(-10 * time.Minute) - mk := func(id int64, login, body string, createdAt, updatedAt time.Time) IssueComment { - comment := IssueComment{ID: id, Body: body, CreatedAt: createdAt, UpdatedAt: updatedAt} + mk := func(id int64, login, body string, createdAt, updatedAt time.Time) ghapi.IssueComment { + comment := ghapi.IssueComment{ID: id, Body: body, CreatedAt: createdAt, UpdatedAt: updatedAt} comment.User.Login = login return comment } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = sha gh.pulls[fakeKey("o/repo", 3)] = pull command := mk(1, "kristofferR", cfg.ReviewCommand, firedAt, firedAt) completion := mk(2, cfg.Bot, "\nReview finished.", firedAt.Add(time.Minute), firedAt.Add(time.Minute)) summary := mk(3, cfg.Bot, "\nCurrently processing new changes in this PR. This may take a few minutes, please wait...", firedAt.Add(-time.Hour), firedAt.Add(2*time.Minute)) - gh.comments[fakeKey("o/repo", 3)] = []IssueComment{summary, command, completion} - prior := Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} + gh.comments[fakeKey("o/repo", 3)] = []ghapi.IssueComment{summary, command, completion} + prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} prior.User.Login = cfg.Bot - gh.reviews[fakeKey("o/repo", 3)] = []Review{prior} + gh.reviews[fakeKey("o/repo", 3)] = []ghapi.Review{prior} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "o/repo", 3, head, PhaseReviewing, firedAt, 1) service := NewService(cfg, gh, store, nil) @@ -226,7 +229,7 @@ func TestFeedbackRejectsCompletionReplyWhileTopSummaryIsProcessing(t *testing.T) summary.Body = "No actionable comments were generated in the recent review." summary.UpdatedAt = firedAt.Add(3 * time.Minute) - gh.comments[fakeKey("o/repo", 3)] = []IssueComment{summary, command, completion} + gh.comments[fakeKey("o/repo", 3)] = []ghapi.IssueComment{summary, command, completion} report, err = service.Feedback(context.Background(), "o/repo", 3) if err != nil { t.Fatal(err) @@ -248,24 +251,24 @@ func TestFeedbackRejectsCompletionReplyWhenTopSummaryFailed(t *testing.T) { sha := "abcdef1234567890" head := sha[:9] firedAt := time.Now().UTC().Add(-10 * time.Minute) - mk := func(id int64, login, body string, createdAt, updatedAt time.Time) IssueComment { - comment := IssueComment{ID: id, Body: body, CreatedAt: createdAt, UpdatedAt: updatedAt} + mk := func(id int64, login, body string, createdAt, updatedAt time.Time) ghapi.IssueComment { + comment := ghapi.IssueComment{ID: id, Body: body, CreatedAt: createdAt, UpdatedAt: updatedAt} comment.User.Login = login return comment } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = sha gh.pulls[fakeKey("o/repo", 3)] = pull command := mk(1, "kristofferR", cfg.ReviewCommand, firedAt, firedAt) completion := mk(2, cfg.Bot, "\nReview finished.", firedAt.Add(time.Minute), firedAt.Add(3*time.Minute)) failure := mk(3, cfg.Bot, "\n## Review failed\n\nAn error occurred during the review process.", firedAt.Add(-time.Hour), firedAt.Add(2*time.Minute)) - gh.comments[fakeKey("o/repo", 3)] = []IssueComment{failure, command, completion} - prior := Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} + gh.comments[fakeKey("o/repo", 3)] = []ghapi.IssueComment{failure, command, completion} + prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} prior.User.Login = cfg.Bot - gh.reviews[fakeKey("o/repo", 3)] = []Review{prior} + gh.reviews[fakeKey("o/repo", 3)] = []ghapi.Review{prior} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "o/repo", 3, head, PhaseReviewing, firedAt, 1) service := NewService(cfg, gh, store, nil) @@ -300,23 +303,23 @@ func TestFeedbackRejectsCompletionReplyFromEarlierRound(t *testing.T) { head := sha[:9] firedAt := time.Now().UTC().Add(-10 * time.Minute) completion := "\n✅ Action performed\n\nReview finished." - mk := func(id int64, login, body string, at time.Time) IssueComment { - ic := IssueComment{ID: id, Body: body, CreatedAt: at, UpdatedAt: at} + mk := func(id int64, login, body string, at time.Time) ghapi.IssueComment { + ic := ghapi.IssueComment{ID: id, Body: body, CreatedAt: at, UpdatedAt: at} ic.User.Login = login return ic } - setup := func(comments []IssueComment) *Service { + setup := func(comments []ghapi.IssueComment) *Service { gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = sha gh.pulls[fakeKey("o/repo", 3)] = pull gh.comments[fakeKey("o/repo", 3)] = comments // The completion fallback requires a prior review by the bot (the old // round's review of the previous head). - prior := Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} + prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} prior.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("o/repo", 3)] = []Review{prior} + gh.reviews[fakeKey("o/repo", 3)] = []ghapi.Review{prior} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "o/repo", 3, head, PhaseReviewing, firedAt, 1) return NewService(cfg, gh, store, nil) @@ -326,7 +329,7 @@ func TestFeedbackRejectsCompletionReplyFromEarlierRound(t *testing.T) { newCmd := mk(2, "kristofferR", "@coderabbitai review", firedAt) oldDone := mk(3, "coderabbitai[bot]", completion, firedAt.Add(30*time.Second)) - rep, err := setup([]IssueComment{oldCmd, newCmd, oldDone}).Feedback(context.Background(), "o/repo", 3) + rep, err := setup([]ghapi.IssueComment{oldCmd, newCmd, oldDone}).Feedback(context.Background(), "o/repo", 3) if err != nil { t.Fatal(err) } @@ -335,7 +338,7 @@ func TestFeedbackRejectsCompletionReplyFromEarlierRound(t *testing.T) { } newDone := mk(4, "coderabbitai[bot]", completion, firedAt.Add(2*time.Minute)) - rep, err = setup([]IssueComment{oldCmd, newCmd, oldDone, newDone}).Feedback(context.Background(), "o/repo", 3) + rep, err = setup([]ghapi.IssueComment{oldCmd, newCmd, oldDone, newDone}).Feedback(context.Background(), "o/repo", 3) if err != nil { t.Fatal(err) } @@ -357,24 +360,24 @@ func TestFeedbackSkipsReviewAnsweredCommandsWhenPairingCompletionReplies(t *test head := sha[:9] firedAt := time.Now().UTC().Add(-10 * time.Minute) completion := "\n✅ Action performed\n\nReview finished." - mk := func(id int64, login, body string, at time.Time) IssueComment { - ic := IssueComment{ID: id, Body: body, CreatedAt: at, UpdatedAt: at} + mk := func(id int64, login, body string, at time.Time) ghapi.IssueComment { + ic := ghapi.IssueComment{ID: id, Body: body, CreatedAt: at, UpdatedAt: at} ic.User.Login = login return ic } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = sha gh.pulls[fakeKey("o/repo", 3)] = pull - gh.comments[fakeKey("o/repo", 3)] = []IssueComment{ + gh.comments[fakeKey("o/repo", 3)] = []ghapi.IssueComment{ mk(1, "kristofferR", "@coderabbitai review", firedAt.Add(-5*time.Minute)), mk(2, "kristofferR", "@coderabbitai review", firedAt), mk(3, "coderabbitai[bot]", completion, firedAt.Add(30*time.Second)), } - oldReview := Review{ID: 44, CommitID: "1111111111111111", SubmittedAt: firedAt.Add(-4 * time.Minute)} + oldReview := ghapi.Review{ID: 44, CommitID: "1111111111111111", SubmittedAt: firedAt.Add(-4 * time.Minute)} oldReview.User.Login = cfg.Bot - gh.reviews[fakeKey("o/repo", 3)] = []Review{oldReview} + gh.reviews[fakeKey("o/repo", 3)] = []ghapi.Review{oldReview} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "o/repo", 3, head, PhaseReviewing, firedAt, 1) svc := NewService(cfg, gh, store, nil) @@ -400,21 +403,21 @@ func TestFeedbackSurfacesCodexEvenWhenNotRequired(t *testing.T) { } gh := newFakeGitHub() sha := "abcdef1234567890" - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = sha gh.pulls[fakeKey("o/repo", 7)] = pull // CodeRabbit reviewed this head and found nothing (empty body → no findings). - crReview := Review{ID: 1, Body: "", CommitID: sha, SubmittedAt: time.Now().UTC()} + crReview := ghapi.Review{ID: 1, Body: "", CommitID: sha, SubmittedAt: time.Now().UTC()} crReview.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("o/repo", 7)] = []Review{crReview} + gh.reviews[fakeKey("o/repo", 7)] = []ghapi.Review{crReview} // Codex left an inline finding on the same head (REST review-comment path, since // the fake GraphQL is unavailable and Feedback falls back to it). - cx := ReviewComment{ID: 22, Body: "**Fix the off-by-one.** This clips the last row.", Path: "app/x.go", Line: 10, CommitID: sha} + cx := ghapi.ReviewComment{ID: 22, Body: "**Fix the off-by-one.** This clips the last row.", Path: "app/x.go", Line: 10, CommitID: sha} cx.User.Login = "chatgpt-codex-connector[bot]" - gh.reviewComments[fakeKey("o/repo", 7)] = []ReviewComment{cx} + gh.reviewComments[fakeKey("o/repo", 7)] = []ghapi.ReviewComment{cx} svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) rep, err := svc.Feedback(context.Background(), "o/repo", 7) @@ -424,7 +427,7 @@ func TestFeedbackSurfacesCodexEvenWhenNotRequired(t *testing.T) { if len(rep.Findings) != 1 || !strings.Contains(rep.Findings[0].Body, "off-by-one") { t.Fatalf("expected the Codex finding to be surfaced, got %#v", rep.Findings) } - if normalizeBotName(rep.Findings[0].Bot) != "chatgpt-codex-connector" { + if dialect.NormalizeBotName(rep.Findings[0].Bot) != "chatgpt-codex-connector" { t.Fatalf("expected the finding attributed to Codex, got %q", rep.Findings[0].Bot) } if rep.Converged { @@ -441,7 +444,7 @@ func TestFeedbackSurfacesCodexEvenWhenNotRequired(t *testing.T) { } func TestParseReviewBodyFindingsExtractsOutsideDiffItems(t *testing.T) { - review := Review{ + review := ghapi.Review{ ID: 99, Body: `> [!CAUTION] > Some comments are outside the diff and can't be posted inline. @@ -482,7 +485,7 @@ func TestParseReviewBodyFindingsExtractsNestedQuoteSections(t *testing.T) { // two-plus blockquote levels deep. A single-level quote strip leaves // "> " prefixes that break the anchored line-range header match, so // every finding in those sections used to be silently dropped. - review := Review{ + review := ghapi.Review{ ID: 100, Body: "> [!WARNING]\n" + "> Review had issues posting inline.\n" + @@ -529,7 +532,7 @@ func TestParseReviewBodyFindingsExtractsNestedQuoteSections(t *testing.T) { func TestParseReviewBodyFindingsExtractsCommentsFailedToPost(t *testing.T) { // CodeRabbit's "Comments failed to post" section uses un-backticked line // headers (561-573:) unlike the backticked "Outside diff range" form. - review := Review{ + review := ghapi.Review{ ID: 7, Body: "
\n🛑 Comments failed to post (1)
\n\n" + "
\nsrc-tauri/inject/messenger.js (1)
\n\n" + @@ -554,7 +557,7 @@ func TestParseReviewBodyFindingsExtractsCommentsFailedToPost(t *testing.T) { } func TestParseReviewBodyFindingsExtractsPromptBlock(t *testing.T) { - review := Review{ + review := ghapi.Review{ ID: 100, Body: `
🤖 Prompt for all review comments with AI agents @@ -588,7 +591,7 @@ In @README.md: } func TestParseReviewBodyFindingsExtractsCodexOutsideDiffItem(t *testing.T) { - review := Review{ + review := ghapi.Review{ ID: 101, Body: ` ### 💡 Codex Review @@ -638,12 +641,12 @@ func TestFeedbackSurfacesBodyFindingsFromSupersededCommit(t *testing.T) { } gh := newFakeGitHub() head := "9999999999999999" - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = head gh.pulls[fakeKey("o/repo", 5)] = pull - review := Review{ + review := ghapi.Review{ ID: 7, Body: `**Actionable comments posted: 2**
@@ -663,7 +666,7 @@ In ` + "`@src/app.ts`" + `: SubmittedAt: time.Now().UTC().Add(-time.Hour), } review.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("o/repo", 5)] = []Review{review} + gh.reviews[fakeKey("o/repo", 5)] = []ghapi.Review{review} svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) rep, err := svc.Feedback(context.Background(), "o/repo", 5) @@ -693,12 +696,12 @@ func TestFeedbackNewerHeadReviewSupersedesOldBodyFindings(t *testing.T) { } gh := newFakeGitHub() head := "9999999999999999" - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = head gh.pulls[fakeKey("o/repo", 5)] = pull - old := Review{ + old := ghapi.Review{ ID: 7, Body: "**Actionable comments posted: 1**\n
\n🤖 Prompt for all review comments with AI agents\n\n" + "```\nIn `@src/app.ts`:\n- Around line 12-14: Stale state.\n```\n
", @@ -706,9 +709,9 @@ func TestFeedbackNewerHeadReviewSupersedesOldBodyFindings(t *testing.T) { SubmittedAt: time.Now().UTC().Add(-time.Hour), } old.User.Login = "coderabbitai[bot]" - fresh := Review{ID: 9, Body: "", CommitID: head, SubmittedAt: time.Now().UTC()} + fresh := ghapi.Review{ID: 9, Body: "", CommitID: head, SubmittedAt: time.Now().UTC()} fresh.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("o/repo", 5)] = []Review{old, fresh} + gh.reviews[fakeKey("o/repo", 5)] = []ghapi.Review{old, fresh} svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) rep, err := svc.Feedback(context.Background(), "o/repo", 5) @@ -733,12 +736,12 @@ func TestFeedbackCurrentRoundDoesNotResurfacePreRoundBodyFindings(t *testing.T) } gh := newFakeGitHub() head := "9999999999999999" - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = head gh.pulls[fakeKey("o/repo", 5)] = pull - old := Review{ + old := ghapi.Review{ ID: 7, Body: "**Actionable comments posted: 1**\n
\n🤖 Prompt for all review comments with AI agents\n\n" + "```\nIn `@src/app.ts`:\n- Around line 12-14: Stale state.\n```\n
", @@ -746,15 +749,15 @@ func TestFeedbackCurrentRoundDoesNotResurfacePreRoundBodyFindings(t *testing.T) SubmittedAt: started.Add(-time.Hour), } old.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("o/repo", 5)] = []Review{old} - completion := IssueComment{ + gh.reviews[fakeKey("o/repo", 5)] = []ghapi.Review{old} + completion := ghapi.IssueComment{ ID: 10, Body: "No actionable comments were generated in the recent review. 🎉", CreatedAt: started.Add(time.Minute), UpdatedAt: started.Add(time.Minute), } completion.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/repo", 5)] = []IssueComment{completion} + gh.comments[fakeKey("o/repo", 5)] = []ghapi.IssueComment{completion} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "o/repo", 5, head[:9], PhaseReviewing, started, 0) @@ -781,14 +784,14 @@ func TestFeedbackCurrentCodeRabbitRoundKeepsLatestCodexBodyFinding(t *testing.T) } gh := newFakeGitHub() head := "850772b68de27efabc7ec5eeda30bb5ea138eb29" - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = head gh.pulls[fakeKey("o/repo", 5)] = pull - codeRabbit := Review{ID: 8, CommitID: head, SubmittedAt: started.Add(time.Minute)} + codeRabbit := ghapi.Review{ID: 8, CommitID: head, SubmittedAt: started.Add(time.Minute)} codeRabbit.User.Login = "coderabbitai[bot]" - codex := Review{ + codex := ghapi.Review{ ID: 7, Body: `https://github.com/o/repo/blob/347388ffda8ae3eb7060a6b960ea437a78780045/convex/sections/aiCommands.ts#L2170-L2174 **![P2 Badge](https://img.shields.io/badge/P2-yellow?style=flat) Query learning history by topic before taking** @@ -798,7 +801,7 @@ Fetch by topic before applying the result limit.`, SubmittedAt: started.Add(-30 * time.Minute), } codex.User.Login = "chatgpt-codex-connector[bot]" - gh.reviews[fakeKey("o/repo", 5)] = []Review{codex, codeRabbit} + gh.reviews[fakeKey("o/repo", 5)] = []ghapi.Review{codex, codeRabbit} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "o/repo", 5, head[:9], PhaseReviewing, started, 0) @@ -813,7 +816,7 @@ Fetch by topic before applying the result limit.`, } func TestThreadFindingsSurfacesUnresolvedAcrossCommits(t *testing.T) { - bots := botSet([]string{"coderabbitai[bot]"}) + bots := dialect.BotSet([]string{"coderabbitai[bot]"}) mk := func(resolved, outdated bool, oid string) reviewThread { var th reviewThread th.ID = "PRRT_x" @@ -864,55 +867,28 @@ func TestThreadFindingsSurfacesUnresolvedAcrossCommits(t *testing.T) { } func TestInBotsToleratesBotSuffix(t *testing.T) { - bots := botSet([]string{"coderabbitai[bot]", "chatgpt-codex"}) + bots := dialect.BotSet([]string{"coderabbitai[bot]", "chatgpt-codex"}) // REST reports "coderabbitai[bot]"; GraphQL review threads report "coderabbitai". for _, login := range []string{"coderabbitai[bot]", "coderabbitai", "chatgpt-codex", "chatgpt-codex[bot]"} { - if !inBots(bots, login) { + if !dialect.InBots(bots, login) { t.Fatalf("expected %q to match a configured bot", login) } } - if inBots(bots, "some-human") { + if dialect.InBots(bots, "some-human") { t.Fatal("unexpected match for a non-bot login") } } -func TestMarkReviewedFlipsConfiguredKeyAcrossSuffix(t *testing.T) { - // A GraphQL login without the [bot] suffix must flip the configured suffixed - // key in place, not insert a divergent key that would leave convergence (which - // ANDs every key) permanently false. - reviewedBy := map[string]bool{"coderabbitai[bot]": false, "chatgpt-codex": false} - markReviewed(reviewedBy, "coderabbitai") - if !reviewedBy["coderabbitai[bot]"] || len(reviewedBy) != 2 { - t.Fatalf("expected the configured key flipped without inserting a new one: %#v", reviewedBy) - } - markReviewed(reviewedBy, "chatgpt-codex") // exact match - if !reviewedBy["chatgpt-codex"] { - t.Fatalf("exact match failed: %#v", reviewedBy) - } - // Configured key without suffix, REST login with suffix — the inverse case. - rb := map[string]bool{"coderabbitai": false} - markReviewed(rb, "coderabbitai[bot]") - if !rb["coderabbitai"] || len(rb) != 1 { - t.Fatalf("a suffixed REST login should flip the suffix-less key: %#v", rb) - } - // An unknown login is a no-op: no panic, no spurious insert. - rb2 := map[string]bool{"coderabbitai[bot]": false} - markReviewed(rb2, "some-human") - if rb2["coderabbitai[bot]"] || len(rb2) != 1 { - t.Fatalf("unknown login must be a no-op: %#v", rb2) - } -} - func TestFeedbackSkipsConfiguredBotIssueCommentsAcrossSuffix(t *testing.T) { cfg := Config{Bot: "coderabbitai", RequiredBots: []string{"coderabbitai"}} gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/repo", 1)] = pull - comment := IssueComment{Body: "CodeRabbit summary text", UpdatedAt: time.Now().UTC()} + comment := ghapi.IssueComment{Body: "CodeRabbit summary text", UpdatedAt: time.Now().UTC()} comment.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} + gh.comments[fakeKey("o/repo", 1)] = []ghapi.IssueComment{comment} svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) report, err := svc.Feedback(context.Background(), "o/repo", 1) @@ -932,18 +908,18 @@ func TestFeedbackMarksCurrentNoActionCompletionCommentReviewed(t *testing.T) { RequiredBots: []string{"coderabbitai[bot]"}, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/repo", 1)] = pull - comment := IssueComment{ + comment := ghapi.IssueComment{ ID: 1, Body: "No actionable comments were generated in the recent review. 🎉", CreatedAt: started.Add(time.Minute), UpdatedAt: started.Add(time.Minute), } comment.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} + gh.comments[fakeKey("o/repo", 1)] = []ghapi.IssueComment{comment} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "o/repo", 1, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) @@ -971,18 +947,18 @@ func TestFeedbackIgnoresStaleNoActionCompletionComment(t *testing.T) { RequiredBots: []string{"coderabbitai[bot]"}, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/repo", 1)] = pull - comment := IssueComment{ + comment := ghapi.IssueComment{ ID: 1, Body: "No actionable comments were generated in the recent review. 🎉", CreatedAt: started.Add(-time.Minute), UpdatedAt: started.Add(-time.Minute), } comment.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} + gh.comments[fakeKey("o/repo", 1)] = []ghapi.IssueComment{comment} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "o/repo", 1, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) @@ -1004,18 +980,18 @@ func TestFeedbackDoesNotUseNoActionCompletionWhileCodexRequiredWithoutThumbsUp(t RequiredBots: []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"}, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/repo", 1)] = pull - comment := IssueComment{ + comment := ghapi.IssueComment{ ID: 1, Body: "No actionable comments were generated in the recent review. 🎉", CreatedAt: started.Add(time.Minute), UpdatedAt: started.Add(time.Minute), } comment.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} + gh.comments[fakeKey("o/repo", 1)] = []ghapi.IssueComment{comment} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "o/repo", 1, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) @@ -1040,21 +1016,21 @@ func TestFeedbackUsesNoActionCompletionAfterCodexThumbsUp(t *testing.T) { RequiredBots: []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"}, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/repo", 1)] = pull - comment := IssueComment{ + comment := ghapi.IssueComment{ ID: 1, Body: "No actionable comments were generated in the recent review. 🎉", CreatedAt: started.Add(time.Minute), UpdatedAt: started.Add(time.Minute), } comment.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} - thumb := Reaction{Content: "+1", CreatedAt: started.Add(2 * time.Minute)} + gh.comments[fakeKey("o/repo", 1)] = []ghapi.IssueComment{comment} + thumb := ghapi.Reaction{Content: "+1", CreatedAt: started.Add(2 * time.Minute)} thumb.User.Login = "chatgpt-codex-connector[bot]" - gh.reactions[99] = []Reaction{thumb} + gh.reactions[99] = []ghapi.Reaction{thumb} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "o/repo", 1, "abcdef123", PhaseReviewing, started, 99) svc := NewService(cfg, gh, store, nil) @@ -1078,21 +1054,21 @@ func TestFeedbackIgnoresOptionalCodexCleanReviewSummary(t *testing.T) { FeedbackBots: []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"}, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/repo", 1)] = pull - review := Review{ID: 9, CommitID: pull.Head.SHA, SubmittedAt: time.Now().UTC()} + review := ghapi.Review{ID: 9, CommitID: pull.Head.SHA, SubmittedAt: time.Now().UTC()} review.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("o/repo", 1)] = []Review{review} - comment := IssueComment{ + gh.reviews[fakeKey("o/repo", 1)] = []ghapi.Review{review} + comment := ghapi.IssueComment{ ID: 10, Body: "## Codex Review\n\nDidn't find any major issues. Keep them coming!", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } comment.User.Login = "chatgpt-codex-connector[bot]" - gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} + gh.comments[fakeKey("o/repo", 1)] = []ghapi.IssueComment{comment} svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) report, err := svc.Feedback(context.Background(), "o/repo", 1) @@ -1118,21 +1094,21 @@ func TestFeedbackMarksRequiredCodexCleanReviewSummaryReviewed(t *testing.T) { RequiredBots: []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"}, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/repo", 1)] = pull - review := Review{ID: 9, CommitID: pull.Head.SHA, SubmittedAt: started.Add(time.Minute)} + review := ghapi.Review{ID: 9, CommitID: pull.Head.SHA, SubmittedAt: started.Add(time.Minute)} review.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("o/repo", 1)] = []Review{review} - comment := IssueComment{ + gh.reviews[fakeKey("o/repo", 1)] = []ghapi.Review{review} + comment := ghapi.IssueComment{ ID: 10, Body: "## Codex Review\n\nDidn't find any major issues. Keep them coming!", CreatedAt: started.Add(2 * time.Minute), UpdatedAt: started.Add(2 * time.Minute), } comment.User.Login = "chatgpt-codex-connector[bot]" - gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} + gh.comments[fakeKey("o/repo", 1)] = []ghapi.IssueComment{comment} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "o/repo", 1, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) @@ -1160,21 +1136,21 @@ func TestFeedbackDoesNotUseStaleCodexCleanReviewSummary(t *testing.T) { RequiredBots: []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"}, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/repo", 1)] = pull - review := Review{ID: 9, CommitID: pull.Head.SHA, SubmittedAt: started.Add(time.Minute)} + review := ghapi.Review{ID: 9, CommitID: pull.Head.SHA, SubmittedAt: started.Add(time.Minute)} review.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("o/repo", 1)] = []Review{review} - comment := IssueComment{ + gh.reviews[fakeKey("o/repo", 1)] = []ghapi.Review{review} + comment := ghapi.IssueComment{ ID: 10, Body: "Codex Review: Didn’t find any major issues. Keep them coming!", CreatedAt: started.Add(-time.Minute), UpdatedAt: started.Add(-time.Minute), } comment.User.Login = "chatgpt-codex-connector[bot]" - gh.comments[fakeKey("o/repo", 1)] = []IssueComment{comment} + gh.comments[fakeKey("o/repo", 1)] = []ghapi.IssueComment{comment} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "o/repo", 1, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) @@ -1208,18 +1184,18 @@ func TestLoopConvergesOnCurrentNoActionCompletionComment(t *testing.T) { FiredMax: 500, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - comment := IssueComment{ + comment := ghapi.IssueComment{ ID: 1, Body: "No actionable comments were generated in the recent review. 🎉", CreatedAt: started.Add(500 * time.Microsecond), UpdatedAt: started.Add(500 * time.Microsecond), } comment.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{comment} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) @@ -1234,7 +1210,7 @@ func TestLoopConvergesOnCurrentNoActionCompletionComment(t *testing.T) { } func TestDedupeFindingsDropsNonActionableBotArtifacts(t *testing.T) { - findings := []Finding{ + findings := []dialect.Finding{ {Bot: "coderabbitai", Title: "> Skipped: comment is from another GitHub bot.", Body: "> Skipped: comment is from another GitHub bot.", Source: "review_thread"}, {Bot: "chatgpt-codex-connector[bot]", Title: "You have reached your Codex usage limits for code reviews.", Body: "You have reached your Codex usage limits for code reviews.", Source: "issue_comment"}, {Bot: "coderabbitai", Title: "", Body: "---\n", Source: "review_body"}, @@ -1247,7 +1223,7 @@ func TestDedupeFindingsDropsNonActionableBotArtifacts(t *testing.T) { } func TestThreadFindingsMatchesGraphQLBotLogin(t *testing.T) { - bots := botSet([]string{"coderabbitai[bot]"}) + bots := dialect.BotSet([]string{"coderabbitai[bot]"}) var th reviewThread th.ID = "PRRT_z" th.Path = "internal/crq/foo.go" @@ -1290,25 +1266,25 @@ func TestRateLimitDetectionCoversFairUsageFormat(t *testing.T) { "Your next review will be available in 48 minutes." oldMsg := "You are rate limited by coderabbit.ai. Reviews available in 3 minutes." - if !svc.isRateLimited(newMsg) { + if !svc.cr.IsRateLimited(newMsg) { t.Fatal("must detect CodeRabbit's Fair Usage rate-limit message") } - if !svc.isRateLimited(oldMsg) { + if !svc.cr.IsRateLimited(oldMsg) { t.Fatal("must still detect the legacy marker") } - if svc.isRateLimited("LGTM — nice fix, nothing about limits here") { + if svc.cr.IsRateLimited("LGTM — nice fix, nothing about limits here") { t.Fatal("must not flag a normal review comment") } base := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC) - if reset := parseAvailableIn(newMsg, base); reset == nil || !reset.Equal(base.Add(48*time.Minute)) { + if reset := dialect.ParseAvailableIn(newMsg, base); reset == nil || !reset.Equal(base.Add(48*time.Minute)) { t.Fatalf("expected reset base+48m from the new message, got %v", reset) } } func TestParseAvailableIn(t *testing.T) { base := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC) - reset := parseAvailableIn("Review limit reached. Reviews available in 1 hour 2 minutes 3 seconds.", base) + reset := dialect.ParseAvailableIn("Review limit reached. Reviews available in 1 hour 2 minutes 3 seconds.", base) if reset == nil { t.Fatal("expected reset") } @@ -1320,7 +1296,7 @@ func TestParseAvailableIn(t *testing.T) { func TestParseQuota(t *testing.T) { base := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC) - remaining, reset := parseQuota("0 reviews remaining. Reviews available in 3 minutes.", base) + remaining, reset := dialect.ParseQuota("0 reviews remaining. Reviews available in 3 minutes.", base) if remaining == nil || *remaining != 0 { t.Fatalf("remaining mismatch: %#v", remaining) } @@ -1379,28 +1355,28 @@ func parkedRound(t *testing.T, repo string, pr int, head string, retryAt, now ti return st } -func TestFeedbackBlockedUntilHonorsPerHeadCooldownAfterGlobalBlockClears(t *testing.T) { +func TestAccountBlockedUntilHonorsPerHeadCooldownAfterGlobalBlockClears(t *testing.T) { now := time.Date(2026, 7, 13, 14, 4, 0, 0, time.UTC) cooldownUntil := now.Add(15 * time.Minute) st := parkedRound(t, "owner/repo", 947, "168df6ae6", cooldownUntil, now) - got, ok := feedbackBlockedUntil(st, "owner/repo", 947, "168df6ae6", now) + got, ok := accountBlockedUntil(&st, "owner/repo", 947, "168df6ae6", now) if !ok || !got.Equal(cooldownUntil) { - t.Fatalf("matching head retry window must keep feedback wait blocked: got %v, ok=%v", got, ok) + t.Fatalf("matching head retry window must keep the feedback wait blocked: got %v, ok=%v", got, ok) } - if _, ok := feedbackBlockedUntil(st, "owner/repo", 947, "different", now); ok { + if _, ok := accountBlockedUntil(&st, "owner/repo", 947, "different", now); ok { t.Fatal("a retry window for an older head must not block the current head") } } -func TestFeedbackBlockedUntilUsesLatestAccountOrHeadWindow(t *testing.T) { +func TestAccountBlockedUntilUsesLatestAccountOrHeadWindow(t *testing.T) { now := time.Date(2026, 7, 13, 14, 4, 0, 0, time.UTC) accountUntil := now.Add(20 * time.Minute) cooldownUntil := now.Add(15 * time.Minute) st := parkedRound(t, "owner/repo", 947, "168df6ae6", cooldownUntil, now) st.Account.BlockedUntil = &accountUntil - got, ok := feedbackBlockedUntil(st, "owner/repo", 947, "168df6ae6", now) + got, ok := accountBlockedUntil(&st, "owner/repo", 947, "168df6ae6", now) if !ok || !got.Equal(accountUntil) { t.Fatalf("latest blocking window must win: got %v, ok=%v", got, ok) } @@ -1454,7 +1430,7 @@ func TestLoopReportsClosedPRSkip(t *testing.T) { FiredMax: 500, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "closed" pull.Merged = true pull.Head.SHA = "abcdef1234567890" @@ -1483,13 +1459,13 @@ func TestLoopRequiresAllRequiredBotsAfterDedupe(t *testing.T) { FiredMax: 500, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - review := Review{CommitID: "abcdef1234567890"} + review := ghapi.Review{CommitID: "abcdef1234567890"} review.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{review} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseCompleted, time.Now().UTC(), 1) svc := NewService(cfg, gh, store, nil) @@ -1521,16 +1497,16 @@ func TestLoopResumesAwaitingFeedbackWithoutRefiring(t *testing.T) { FiredMax: 500, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - comment := IssueComment{ID: 91, Body: "Actionable finding on the current head", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + comment := ghapi.IssueComment{ID: 91, Body: "Actionable finding on the current head", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} comment.User.Login = "chatgpt-codex-connector[bot]" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} - review := Review{ID: 9, CommitID: pull.Head.SHA, SubmittedAt: time.Now().UTC()} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{comment} + review := ghapi.Review{ID: 9, CommitID: pull.Head.SHA, SubmittedAt: time.Now().UTC()} review.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{review} store := NewMemoryStore(cfg) started := time.Now().UTC().Add(-time.Minute) seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, started, 0) @@ -1550,7 +1526,7 @@ func TestLoopResumesAwaitingFeedbackWithoutRefiring(t *testing.T) { if err != nil { t.Fatal(err) } - if waitView(&state, "owner/repo", 12).Head != "" { + if waitingHead(&state, "owner/repo", 12) != "" { t.Fatalf("feedback wait should clear after findings are collected") } if firedMarker(&state, "owner/repo", 12) != "abcdef123" { @@ -1573,25 +1549,25 @@ func TestLoopWaitsForReplacementReviewInsteadOfReturningCarriedPrompt(t *testing FiredMax: 500, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - stale := Review{ + stale := ghapi.Review{ ID: 7, Body: "
Prompt for AI agents\n\n```\nIn `@a.go`:\n- Around line 1: Carried-over finding.\n```\n
", CommitID: "fedcba9876543210", SubmittedAt: time.Now().UTC().Add(-time.Hour), } stale.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("owner/repo", 12)] = []Review{stale} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{stale} store := NewMemoryStore(cfg) started := time.Now().UTC() seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, started, 0) go func() { time.Sleep(5 * time.Millisecond) - fresh := Review{ID: 9, CommitID: pull.Head.SHA, SubmittedAt: time.Now().UTC()} + fresh := ghapi.Review{ID: 9, CommitID: pull.Head.SHA, SubmittedAt: time.Now().UTC()} fresh.User.Login = "coderabbitai[bot]" gh.mu.Lock() gh.reviews[fakeKey("owner/repo", 12)] = append(gh.reviews[fakeKey("owner/repo", 12)], fresh) @@ -1628,21 +1604,21 @@ func TestLoopReturnsExistingCodexFeedbackBeforeWaitingForReviewSlot(t *testing.T } gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc - comment := IssueComment{ + comment := ghapi.IssueComment{ ID: 91, Body: "Actionable finding on the current head", CreatedAt: headTime.Add(time.Second), UpdatedAt: headTime.Add(time.Second), } comment.User.Login = "chatgpt-codex-connector[bot]" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{comment} store := NewMemoryStore(cfg) blockedUntil := time.Now().UTC().Add(time.Hour) if _, err := store.Update(ctx, func(st *State) error { @@ -1687,27 +1663,27 @@ func TestLoopDoesNotBlockOnThreadlessReviewBodyFromPreviousHead(t *testing.T) { } gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc - stale := Review{ + stale := ghapi.Review{ ID: 7, Body: "**Actionable comments posted: 1**\n
Prompt for AI agents\n\n```\nIn `@a.go`:\n- Around line 1: Already fixed on the new head.\n```\n
", CommitID: "fedcba9876543210", SubmittedAt: headTime.Add(-time.Hour), } stale.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("owner/repo", 12)] = []Review{stale} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{stale} store := NewMemoryStore(cfg) svc := NewService(cfg, gh, store, nil) go func() { time.Sleep(5 * time.Millisecond) - fresh := Review{ID: 8, CommitID: pull.Head.SHA, SubmittedAt: time.Now().UTC()} + fresh := ghapi.Review{ID: 8, CommitID: pull.Head.SHA, SubmittedAt: time.Now().UTC()} fresh.User.Login = "coderabbitai[bot]" gh.mu.Lock() gh.reviews[fakeKey("owner/repo", 12)] = append(gh.reviews[fakeKey("owner/repo", 12)], fresh) @@ -1742,18 +1718,18 @@ func TestLoopReturnsFindingsBeforeRequiredReviewerTimeout(t *testing.T) { FiredMax: 500, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - comment := IssueComment{ + comment := ghapi.IssueComment{ ID: 91, Body: "Actionable finding on the current head", CreatedAt: started.Add(time.Second), UpdatedAt: started.Add(time.Second), } comment.User.Login = "chatgpt-codex-connector[bot]" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{comment} store := NewMemoryStore(cfg) seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, started, 0) svc := NewService(cfg, gh, store, nil) @@ -1786,21 +1762,21 @@ func TestLoopReturnsFasterCodexFeedbackBeforeCodeRabbitReviews(t *testing.T) { } gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc - codexFinding := IssueComment{ + codexFinding := ghapi.IssueComment{ ID: 91, Body: "Actionable Codex finding on the current head", CreatedAt: headTime.Add(time.Second), UpdatedAt: headTime.Add(time.Second), } codexFinding.User.Login = "chatgpt-codex-connector[bot]" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{codexFinding} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{codexFinding} store := NewMemoryStore(cfg) started := time.Now().UTC() seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, started, 0) @@ -1832,7 +1808,7 @@ func TestLoopUsesPersistedFeedbackDeadline(t *testing.T) { FiredMax: 500, } gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull @@ -1859,7 +1835,7 @@ func TestLoopUsesPersistedFeedbackDeadline(t *testing.T) { if err != nil { t.Fatal(err) } - if waitView(&state, "owner/repo", 12).Head != "" { + if waitingHead(&state, "owner/repo", 12) != "" { t.Fatalf("expired feedback wait should clear after timeout") } } @@ -1877,27 +1853,27 @@ func TestCodexCleanSummaryFormats(t *testing.T) { legacy := "Codex Review: Didn't find any major issues. Keep them coming!" tada := "Codex Review: Didn't find any major issues. :tada:\n\n**Reviewed commit:** `4d9e8bca82`" - if !isCodexNoActionReviewCompletion(legacy) { + if !dialect.IsCodexNoActionReviewCompletion(legacy) { t.Fatal("legacy clean summary must count as a completion") } - if !isCodexNoActionReviewCompletion(tada) { + if !dialect.IsCodexNoActionReviewCompletion(tada) { t.Fatal("reviewed-commit clean summary must count as a completion") } - if isCodexNoActionReviewCompletion("Codex Review: 2 issues need attention") { + if dialect.IsCodexNoActionReviewCompletion("Codex Review: 2 issues need attention") { t.Fatal("a summary with findings must not count as a completion") } - if got := codexReviewedCommitSHA(tada); got != "4d9e8bca82" { + if got := dialect.CodexReviewedCommitSHA(tada); got != "4d9e8bca82" { t.Fatalf("expected the reviewed-commit sha, got %q", got) } - if got := codexReviewedCommitSHA(legacy); got != "" { + if got := dialect.CodexReviewedCommitSHA(legacy); got != "" { t.Fatalf("legacy summary has no sha, got %q", got) } // crq truncates heads to 9 chars while Codex abbreviates to 10 — the // prefix match must work in both directions. - if !shaPrefixMatch("4d9e8bca82", "4d9e8bca8") || !shaPrefixMatch("4d9e8bca8", "4d9e8bca82") { + if !dialect.SHAPrefixMatch("4d9e8bca82", "4d9e8bca8") || !dialect.SHAPrefixMatch("4d9e8bca8", "4d9e8bca82") { t.Fatal("mutual sha prefixes must match") } - if shaPrefixMatch("4d9e8bca82", "deadbeef1") { + if dialect.SHAPrefixMatch("4d9e8bca82", "deadbeef1") { t.Fatal("different shas must not match") } } diff --git a/internal/crq/gh_aliases.go b/internal/crq/gh_aliases.go deleted file mode 100644 index 60ef4e2..0000000 --- a/internal/crq/gh_aliases.go +++ /dev/null @@ -1,38 +0,0 @@ -package crq - -import ( - ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" -) - -// Aliases for the GitHub transport that moved to internal/gh. They keep -// existing call sites and tests stable while the refactor lands package by -// package; new code should import internal/gh directly. Deleted once the -// engine extraction rewrites the callers. (The import is named ghapi because -// crq code conventionally uses gh as a variable name for the client.) - -type ( - GitHub = ghapi.GitHub - APIError = ghapi.APIError - RateLimitError = ghapi.RateLimitError - Issue = ghapi.Issue - Pull = ghapi.Pull - RepoInfo = ghapi.RepoInfo - IssueComment = ghapi.IssueComment - Review = ghapi.Review - ReviewComment = ghapi.ReviewComment - Reaction = ghapi.Reaction - SearchPR = ghapi.SearchPR - gitCommit = ghapi.Commit -) - -var ( - ErrNotFound = ghapi.ErrNotFound - NewGitHub = ghapi.NewGitHub - isThrottled = ghapi.IsThrottled - rateLimitWait = ghapi.ThrottleWait - sleepCtx = ghapi.SleepCtx -) - -func init() { - ghapi.UserAgent = "crq/" + Version -} diff --git a/internal/crq/init.go b/internal/crq/init.go index 894bd31..99420c7 100644 --- a/internal/crq/init.go +++ b/internal/crq/init.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "net/url" + + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) type InitResult struct { @@ -14,7 +16,7 @@ type InitResult struct { StateRef string `json:"state_ref"` } -func Init(ctx context.Context, cfg Config, gh *GitHub, store StateStore) (InitResult, error) { +func Init(ctx context.Context, cfg Config, gh *ghapi.GitHub, store StateStore) (InitResult, error) { if cfg.GateRepo == "" { return InitResult{}, errors.New("CRQ_REPO is required for init") } @@ -57,7 +59,7 @@ func Init(ctx context.Context, cfg Config, gh *GitHub, store StateStore) (InitRe }, nil } -func ensureCalibrationPR(ctx context.Context, cfg Config, gh *GitHub) (int, error) { +func ensureCalibrationPR(ctx context.Context, cfg Config, gh *ghapi.GitHub) (int, error) { owner := ownerOf(cfg.GateRepo) branch := "crq/calibration" query := url.Values{} diff --git a/internal/crq/observe.go b/internal/crq/observe.go index 578b15b..1ce04c9 100644 --- a/internal/crq/observe.go +++ b/internal/crq/observe.go @@ -7,50 +7,66 @@ import ( "github.com/kristofferR/coderabbit-queue/internal/dialect" "github.com/kristofferR/coderabbit-queue/internal/engine" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) +// observation bundles the pure engine.Observation the decision functions +// consume with the raw GitHub payloads Feedback's findings extraction needs, so +// a single fetch serves both the daemon (Pump: DecideFire/Progress) and the +// loop (Feedback: engine.Completion + finding parsing) without a second path. +type observation struct { + eng engine.Observation + pull ghapi.Pull + reviews []ghapi.Review + comments []ghapi.IssueComment +} + // observe is the single place that asks GitHub "what happened on this PR" and -// reduces it to an engine.Observation. The daemon's Pump builds it once for the -// slot round (Progress) and once for the next-eligible round (DecideFire), so -// the "is head reviewed?" duplication of v2 collapses to one implementation. +// reduces it to an engine.Observation (plus the raw reviews/comments Feedback +// parses into findings). The daemon's Pump builds it for the slot round +// (Progress) and the next-eligible round (DecideFire); Feedback builds it for +// the current round — so the "is head reviewed?" duplication of v2 collapses to +// one implementation. // // round anchors the round-relative facts: reactions target its fired command, // the adoption cutoff is its LastAttemptAt, and reactions/thumbs-up are fetched // only for a round that has fired. -func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round, now time.Time) (engine.Observation, error) { +func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round, now time.Time) (observation, error) { pull, err := s.gh.GetPull(ctx, repo, pr) if err != nil { - return engine.Observation{}, err - } - obs := engine.Observation{Open: pull.State == "open" && !pull.Merged} - if obs.Open && len(pull.Head.SHA) >= 9 { - obs.Head = pull.Head.SHA[:9] + return observation{}, err } - if !obs.Open { - // A closed PR needs no further facts; the engine drops/abandons the round. - return obs, nil + o := observation{pull: pull} + o.eng.Open = pull.State == "open" && !pull.Merged + if o.eng.Open && len(pull.Head.SHA) >= 9 { + o.eng.Head = pull.Head.SHA[:9] } + // Reviews and issue comments are fetched even for a closed PR: the daemon's + // Progress/DecideFire abandon it regardless, but Feedback still surfaces its + // findings, and the extra two reads on a to-be-dropped round are negligible. reviews, err := s.gh.ListReviews(ctx, repo, pr) if err != nil { - return engine.Observation{}, err + return observation{}, err } + o.reviews = reviews for _, review := range reviews { - obs.Reviews = append(obs.Reviews, engine.ReviewSeen{ + o.eng.Reviews = append(o.eng.Reviews, engine.ReviewSeen{ Bot: review.User.Login, ReviewID: review.ID, - Commit: shortOID(review.CommitID), + Commit: dialect.ShortOID(review.CommitID), SubmittedAt: review.SubmittedAt, }) } comments, err := s.gh.ListIssueComments(ctx, repo, pr) if err != nil { - return engine.Observation{}, err + return observation{}, err } + o.comments = comments classifier := dialect.Classifier{CodeRabbit: s.cr, Bot: s.cfg.Bot, ReviewCommand: s.cfg.ReviewCommand} for _, c := range comments { - obs.Events = append(obs.Events, classifier.Classify(c.User.Login, c.Body, c.ID, c.CreatedAt, c.UpdatedAt)) + o.eng.Events = append(o.eng.Events, classifier.Classify(c.User.Login, c.Body, c.ID, c.CreatedAt, c.UpdatedAt)) } // Reactions and Codex thumbs-up only matter for a round that has fired. @@ -59,25 +75,25 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round if round.CommandID != 0 { reactions, err := s.gh.ListCommentReactions(ctx, repo, round.CommandID) if err != nil { - return engine.Observation{}, err + return observation{}, err } for _, reaction := range reactions { if s.isConfiguredBot(reaction.User.Login) { - obs.Reacted = true + o.eng.Reacted = true } if isCurrentCodexThumbsUp(reaction, cutoff) { - obs.CodexThumbsUp = true + o.eng.CodexThumbsUp = true } } } - if !obs.CodexThumbsUp && s.codexRelevant(obs) { + if !o.eng.CodexThumbsUp && s.codexRelevant(o.eng) { reactions, err := s.gh.ListIssueReactions(ctx, repo, pr) if err != nil { - return engine.Observation{}, err + return observation{}, err } for _, reaction := range reactions { if isCurrentCodexThumbsUp(reaction, cutoff) { - obs.CodexThumbsUp = true + o.eng.CodexThumbsUp = true break } } @@ -86,13 +102,13 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round // Adoptable commands are only consulted for a fire-eligible round. if round != nil && round.FireEligible(now) { - cmds, err := s.adoptableCommands(ctx, repo, pr, obs.Head, adoptCutoff(*round), pull, comments, reviews) + cmds, err := s.adoptableCommands(ctx, repo, pr, o.eng, adoptCutoff(*round), pull, comments, reviews) if err != nil { - return engine.Observation{}, err + return observation{}, err } - obs.Commands = cmds + o.eng.Commands = cmds } - return obs, nil + return o, nil } // adoptCutoff is the earliest command timestamp a round may adopt: the most @@ -128,7 +144,8 @@ func (s *Service) codexRelevant(obs engine.Observation) bool { // review-command comment safe to adopt as an already-posted fire, or none. The // cutoffs (LastAttemptAt floor, head-commit date, force-push, already-answered) // are applied here so the engine only picks the newest survivor. -func (s *Service) adoptableCommands(ctx context.Context, repo string, pr int, head string, notBeforeCutoff time.Time, pull Pull, comments []IssueComment, reviews []Review) ([]engine.CommandSeen, error) { +func (s *Service) adoptableCommands(ctx context.Context, repo string, pr int, obs engine.Observation, notBeforeCutoff time.Time, pull ghapi.Pull, comments []ghapi.IssueComment, reviews []ghapi.Review) ([]engine.CommandSeen, error) { + head := obs.Head command := strings.TrimSpace(s.cfg.ReviewCommand) if command == "" { return nil, nil @@ -147,12 +164,12 @@ func (s *Service) adoptableCommands(ctx context.Context, repo string, pr int, he } cutoff := notBeforeCutoff if pull.Head.SHA != "" { - if shortOID(pull.Head.SHA) != head { + if dialect.ShortOID(pull.Head.SHA) != head { return nil, nil } commit, err := s.gh.GetCommit(ctx, repo, pull.Head.SHA) if err != nil { - if _, ok := rateLimitWait(err); ok { + if _, ok := ghapi.ThrottleWait(err); ok { return nil, err } // No head-commit cutoff available (unreadable/404 head): skip adoption @@ -170,7 +187,7 @@ func (s *Service) adoptableCommands(ctx context.Context, repo string, pr int, he if fp := s.headForcePushCutoff(ctx, repo, pr); fp.After(cutoff) { cutoff = fp } - var best IssueComment + var best ghapi.IssueComment var bestAt time.Time ok := false for _, comment := range comments { @@ -201,7 +218,7 @@ func (s *Service) adoptableCommands(ctx context.Context, repo string, pr int, he return nil, nil } } - if s.reviewCommandHasCompletionReply(comments, reviews, best.ID) { + if engine.CommandHasCompletionReply(obs, s.policy(), best.ID) { return nil, nil } return []engine.CommandSeen{{ID: best.ID, CreatedAt: best.CreatedAt, UpdatedAt: best.UpdatedAt}}, nil diff --git a/internal/crq/preflight.go b/internal/crq/preflight.go index 6690796..cbbb3f7 100644 --- a/internal/crq/preflight.go +++ b/internal/crq/preflight.go @@ -15,6 +15,8 @@ import ( "strconv" "strings" "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" ) type PreflightOptions struct { @@ -240,8 +242,8 @@ func preflightFinding(event map[string]any) PreflightFinding { codegen := stringField(event, "codegenInstructions") comment := stringField(event, "comment") body := firstNonEmpty(codegen, comment) - title := firstNonEmpty(stringField(event, "title"), titleOf(body)) - severity := strings.ToLower(firstNonEmpty(stringField(event, "severity"), severityOf(title+"\n"+body))) + title := firstNonEmpty(stringField(event, "title"), dialect.TitleOf(body)) + severity := strings.ToLower(firstNonEmpty(stringField(event, "severity"), dialect.SeverityOf(title+"\n"+body))) finding := PreflightFinding{ ID: firstNonEmpty(stringField(event, "id"), stringField(event, "fingerprint")), Bot: "coderabbit-cli", diff --git a/internal/crq/service.go b/internal/crq/service.go index 8bb1559..f37528a 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -13,6 +13,7 @@ import ( "github.com/kristofferR/coderabbit-queue/internal/dialect" "github.com/kristofferR/coderabbit-queue/internal/engine" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) type Logger interface { @@ -20,19 +21,19 @@ type Logger interface { } type GitHubAPI interface { - GetPull(context.Context, string, int) (Pull, error) - GetCommit(context.Context, string, string) (gitCommit, error) - ListReviews(context.Context, string, int) ([]Review, error) - ListIssueComments(context.Context, string, int) ([]IssueComment, error) - ListIssueCommentsPage(context.Context, string, int, int, int) ([]IssueComment, error) - ListReviewComments(context.Context, string, int) ([]ReviewComment, error) - ListIssueReactions(context.Context, string, int) ([]Reaction, error) - ListCommentReactions(context.Context, string, int64) ([]Reaction, error) - PostIssueComment(context.Context, string, int, string) (IssueComment, error) + GetPull(context.Context, string, int) (ghapi.Pull, error) + GetCommit(context.Context, string, string) (ghapi.Commit, error) + ListReviews(context.Context, string, int) ([]ghapi.Review, error) + ListIssueComments(context.Context, string, int) ([]ghapi.IssueComment, error) + ListIssueCommentsPage(context.Context, string, int, int, int) ([]ghapi.IssueComment, error) + ListReviewComments(context.Context, string, int) ([]ghapi.ReviewComment, error) + ListIssueReactions(context.Context, string, int) ([]ghapi.Reaction, error) + ListCommentReactions(context.Context, string, int64) ([]ghapi.Reaction, error) + PostIssueComment(context.Context, string, int, string) (ghapi.IssueComment, error) DeleteIssueComment(context.Context, string, int64) error - CreateIssue(context.Context, string, string, string) (Issue, error) - SearchOpenPRs(context.Context, string, bool, int) ([]SearchPR, error) - EachOpenPR(context.Context, string, bool, func(SearchPR) (bool, error)) error + CreateIssue(context.Context, string, string, string) (ghapi.Issue, error) + SearchOpenPRs(context.Context, string, bool, int) ([]ghapi.SearchPR, error) + EachOpenPR(context.Context, string, bool, func(ghapi.SearchPR) (bool, error)) error GraphQL(context.Context, string, map[string]any, any) error } @@ -53,10 +54,10 @@ func NewService(cfg Config, gh GitHubAPI, store StateStore, log Logger) *Service return &Service{cfg: cfg, cr: cr, gh: gh, store: store, log: log} } -// warnRateLimited is the requeue reason for a rate-limited fire. It matches the -// engine's rate-limit Transition.Reason and is surfaced via AccountQuota, not -// the sticky Warn field. -const warnRateLimited = "rate limited" +// warnRateLimited is the requeue reason for a fire that came back account +// blocked. It matches the engine's Transition.Reason (both reference the one +// dialect constant) and is surfaced via AccountQuota, not the sticky Warn field. +const warnRateLimited = dialect.ReasonRateLimited type EnqueueResult struct { Repo string `json:"repo"` @@ -225,8 +226,8 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { if err != nil { return PumpResult{}, err } - decision := engine.DecideFire(s.global(st, now), *next, obs, now, s.policy()) - return s.applyFire(ctx, *next, obs, decision, now) + decision := engine.DecideFire(s.global(st, now), *next, obs.eng, now, s.policy()) + return s.applyFire(ctx, *next, obs.eng, decision, now) } func (s *Service) global(st State, now time.Time) engine.Global { @@ -248,7 +249,7 @@ func (s *Service) progressSlotRound(ctx context.Context, slot Round) (PumpResult if err != nil { return PumpResult{}, err } - tr := engine.Progress(slot, st.Account, obs, now, s.policy()) + 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 } @@ -319,7 +320,7 @@ func releaseSlot(st *State, key string) { } } -// applyAccountBlock ports requeueInflight's rate-limit bookkeeping. The window +// applyAccountBlock ports requeueInflight's account-quota bookkeeping. The window // (including same-comment reuse) was resolved by the engine, so only the store // write happens here. func applyAccountBlock(st *State, blk *engine.AccountBlock, now time.Time) { @@ -380,7 +381,7 @@ func (s *Service) sweepReviewing(ctx context.Context, st State, now time.Time) ( } return st, nil } - tr := engine.Progress(*target, st.Account, obs, now, s.policy()) + tr := engine.Progress(*target, st.Account, obs.eng, now, s.policy()) if tr.Outcome == engine.KeepWaiting { return st, nil } @@ -715,7 +716,7 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { now := time.Now().UTC() // Honor the freshness shortcut only when the last reading was conclusive. If a // probe is still pending (CalibAskedAt set, no reply yet), keep re-checking so a - // late "rate-limited" reply isn't ignored for the full TTL. + // late "account blocked" reply isn't ignored for the full TTL. if state.Account.CalibAskedAt == nil && state.Account.CheckedAt != nil && now.Sub(*state.Account.CheckedAt) < s.cfg.CalibrationTTL { return state, nil } @@ -727,7 +728,7 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { if st.Account.CalibAskedAt == nil && st.Account.CheckedAt != nil && time.Since(*st.Account.CheckedAt) < s.cfg.CalibrationTTL { return ErrNoChange } - // A fresh reading replaces the whole quota; carry the rate-limit comment + // A fresh reading replaces the whole quota; carry the account-quota comment // identity over so the engine can still recognise an edited comment it // already accounted for. rlID, rlUpdated := st.Account.RLCommentID, st.Account.RLCommentUpdated @@ -757,13 +758,13 @@ func (s *Service) calibrationIssue(state State) int { return s.cfg.CalibrationPR } -const calibrationIssueBody = "crq probes CodeRabbit's account-wide rate-limit state here with `@coderabbitai rate limit` so it never spends a real review to calibrate. Auto-created after a prior calibration thread hit GitHub's 2500-comment cap. Managed by crq — safe to leave alone." +const calibrationIssueBody = "crq probes CodeRabbit's account-wide review quota here with `" + dialect.DefaultRateLimitCommand + "` so it never spends a real review to calibrate. Auto-created after a prior calibration thread hit GitHub's 2500-comment cap. Managed by crq — safe to leave alone." // rotateCalibration creates a fresh calibration issue and records its number in // the shared state so the whole fleet abandons a thread that hit GitHub's hard // 2500-comment cap. func (s *Service) rotateCalibration(ctx context.Context, oldIssue int) (int, error) { - issue, err := s.gh.CreateIssue(ctx, s.cfg.GateRepo, "crq rate-limit calibration", calibrationIssueBody) + issue, err := s.gh.CreateIssue(ctx, s.cfg.GateRepo, "crq account-quota calibration", calibrationIssueBody) if err != nil { return 0, err } @@ -792,7 +793,7 @@ func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendi if reply, ok, err := s.latestCalibrationReply(ctx, issue, cutoff); err != nil { return quota, err } else if ok { - remaining, reset := parseQuota(reply.Body, reply.UpdatedAt) + remaining, reset := dialect.ParseQuota(reply.Body, reply.UpdatedAt) quota.Remaining = remaining quota.BlockedUntil = reset s.pruneCalibration(ctx, issue, keepAfter, 80) @@ -842,7 +843,7 @@ func (s *Service) readQuota(ctx context.Context, issue int, now time.Time, pendi return quota, err } if ok { - remaining, reset := parseQuota(reply.Body, reply.UpdatedAt) + remaining, reset := dialect.ParseQuota(reply.Body, reply.UpdatedAt) quota.Remaining = remaining quota.BlockedUntil = reset quota.CalibAskedAt = nil @@ -892,20 +893,20 @@ func (s *Service) pruneCalibration(ctx context.Context, issue int, keepAfter tim } // isCalibrationNoise reports whether a comment is a spent calibration artifact: -// one of crq's "@coderabbitai rate limit" probes or a CodeRabbit auto-reply. -func (s *Service) isCalibrationNoise(c IssueComment) bool { +// one of crq's account-quota probes or a CodeRabbit auto-reply. +func (s *Service) isCalibrationNoise(c ghapi.IssueComment) bool { if strings.TrimSpace(c.Body) == strings.TrimSpace(s.cfg.RateLimitCommand) { return true } return s.isConfiguredBot(c.User.Login) && strings.Contains(c.Body, s.cfg.CalibrationMarker) } -func (s *Service) latestCalibrationReply(ctx context.Context, issue int, after time.Time) (IssueComment, bool, error) { +func (s *Service) latestCalibrationReply(ctx context.Context, issue int, after time.Time) (ghapi.IssueComment, bool, error) { comments, err := s.gh.ListIssueComments(ctx, s.cfg.GateRepo, issue) if err != nil { - return IssueComment{}, false, err + return ghapi.IssueComment{}, false, err } - var best IssueComment + var best ghapi.IssueComment ok := false for _, comment := range comments { if !s.isConfiguredBot(comment.User.Login) || !comment.UpdatedAt.After(after) { @@ -923,7 +924,7 @@ func (s *Service) latestCalibrationReply(ctx context.Context, issue int, after t } func (s *Service) isConfiguredBot(login string) bool { - return normalizeBotName(login) == normalizeBotName(s.cfg.Bot) + return dialect.NormalizeBotName(login) == dialect.NormalizeBotName(s.cfg.Bot) } // notBefore reports whether t is at or after baseline. GitHub timestamps are @@ -986,16 +987,10 @@ func randomToken() string { return hex.EncodeToString(buf[:]) } -// The CodeRabbit comment classifiers live in internal/dialect; these forwarders -// keep call sites and tests stable during the refactor. -func (s *Service) isRateLimited(body string) bool { return s.cr.IsRateLimited(body) } -func (s *Service) isReviewsPaused(body string) bool { return s.cr.IsReviewsPaused(body) } -func (s *Service) isReviewAlreadyDone(body string) bool { return s.cr.IsReviewAlreadyDone(body) } - // isCommentCapError reports whether err is GitHub's hard cap of 2500 comments per // issue ("Commenting is disabled on issues with more than 2500 comments"). func isCommentCapError(err error) bool { - var api *APIError + var api *ghapi.APIError if !errors.As(err, &api) { return false } @@ -1005,8 +1000,10 @@ func isCommentCapError(err error) bool { // Wait enqueues repo#pr and pumps until a review fires for its head (code 0), // current-head feedback is already available (code 3), the wait times out (code -// 2), or the PR is closed (code 2). It is kept compiling on round state via the -// v2→v3 shims; stage 4b rewrites it. +// 2), or the PR is closed (code 2). The wait IS the round: a fired/reviewing +// round for the head is the in-flight wait, a completed round is the "already +// reviewed" dedup marker, and firedMarker/waitingHead read those states off the +// round rather than a separate wait record. func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, int, error) { repo = NormalizeRepo(repo) start := time.Now() @@ -1029,14 +1026,14 @@ func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, in if err != nil { return PumpResult{}, 1, err } - if waitView(&state, repo, pr).Head == result.Head { + if waitingHead(&state, repo, pr) == result.Head { return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: result.Head}, 3, nil } report, err := s.Feedback(ctx, repo, pr) if err != nil { return PumpResult{}, 1, err } - if len(findingsReportedOnHead(report.Findings, report.Head)) > 0 || allReviewed(report.ReviewedBy) { + if len(engine.FindingsOnHead(report.Findings, report.Head)) > 0 || allReviewed(report.ReviewedBy) { return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: result.Head}, 3, nil } // A completed round at this head with no real head review is a poisoned @@ -1067,7 +1064,7 @@ func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, in // Return current-head findings immediately so the caller can fix locally. // The round stays active: policy holds this head until the slot and every // required reviewer finish. - if len(findingsReportedOnHead(report.Findings, report.Head)) > 0 { + if len(engine.FindingsOnHead(report.Findings, report.Head)) > 0 { if s.log != nil { s.log.Printf("%s#%d feedback already available on %s; leaving review slot wait", repo, pr, report.Head) } diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index fd75c0b..524f399 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -9,18 +9,22 @@ import ( "sync" "testing" "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/engine" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) type fakeGitHub struct { mu sync.Mutex - pulls map[string]Pull - commits map[string]gitCommit + pulls map[string]ghapi.Pull + commits map[string]ghapi.Commit commitErrs map[string]error - reviews map[string][]Review - comments map[string][]IssueComment - reviewComments map[string][]ReviewComment - issueReactions map[string][]Reaction - reactions map[int64][]Reaction + reviews map[string][]ghapi.Review + comments map[string][]ghapi.IssueComment + reviewComments map[string][]ghapi.ReviewComment + issueReactions map[string][]ghapi.Reaction + reactions map[int64][]ghapi.Reaction posted []string deleted []int64 commentID int64 @@ -28,87 +32,87 @@ type fakeGitHub struct { nextIssueNumber int postErrs map[string]error graphQL func(query string, vars map[string]any, out any) error - searchPRs []SearchPR + searchPRs []ghapi.SearchPR } func newFakeGitHub() *fakeGitHub { return &fakeGitHub{ - pulls: map[string]Pull{}, - commits: map[string]gitCommit{}, + pulls: map[string]ghapi.Pull{}, + commits: map[string]ghapi.Commit{}, commitErrs: map[string]error{}, - reviews: map[string][]Review{}, - comments: map[string][]IssueComment{}, - reviewComments: map[string][]ReviewComment{}, - issueReactions: map[string][]Reaction{}, - reactions: map[int64][]Reaction{}, + reviews: map[string][]ghapi.Review{}, + comments: map[string][]ghapi.IssueComment{}, + reviewComments: map[string][]ghapi.ReviewComment{}, + issueReactions: map[string][]ghapi.Reaction{}, + reactions: map[int64][]ghapi.Reaction{}, } } func fakeKey(repo string, pr int) string { return QueueKey(repo, pr) } -func (f *fakeGitHub) GetPull(_ context.Context, repo string, pr int) (Pull, error) { +func (f *fakeGitHub) GetPull(_ context.Context, repo string, pr int) (ghapi.Pull, error) { f.mu.Lock() defer f.mu.Unlock() pull, ok := f.pulls[fakeKey(repo, pr)] if !ok { - return Pull{}, errors.New("missing pull") + return ghapi.Pull{}, errors.New("missing pull") } return pull, nil } -func (f *fakeGitHub) GetCommit(_ context.Context, repo, sha string) (gitCommit, error) { +func (f *fakeGitHub) GetCommit(_ context.Context, repo, sha string) (ghapi.Commit, error) { f.mu.Lock() defer f.mu.Unlock() if err := f.commitErrs[sha]; err != nil { - return gitCommit{}, err + return ghapi.Commit{}, err } return f.commits[sha], nil } -func (f *fakeGitHub) ListReviews(_ context.Context, repo string, pr int) ([]Review, error) { +func (f *fakeGitHub) ListReviews(_ context.Context, repo string, pr int) ([]ghapi.Review, error) { f.mu.Lock() defer f.mu.Unlock() - return append([]Review(nil), f.reviews[fakeKey(repo, pr)]...), nil + return append([]ghapi.Review(nil), f.reviews[fakeKey(repo, pr)]...), nil } -func (f *fakeGitHub) ListIssueComments(_ context.Context, repo string, pr int) ([]IssueComment, error) { +func (f *fakeGitHub) ListIssueComments(_ context.Context, repo string, pr int) ([]ghapi.IssueComment, error) { f.mu.Lock() defer f.mu.Unlock() - return append([]IssueComment(nil), f.comments[fakeKey(repo, pr)]...), nil + return append([]ghapi.IssueComment(nil), f.comments[fakeKey(repo, pr)]...), nil } -func (f *fakeGitHub) ListReviewComments(_ context.Context, repo string, pr int) ([]ReviewComment, error) { +func (f *fakeGitHub) ListReviewComments(_ context.Context, repo string, pr int) ([]ghapi.ReviewComment, error) { f.mu.Lock() defer f.mu.Unlock() - return append([]ReviewComment(nil), f.reviewComments[fakeKey(repo, pr)]...), nil + return append([]ghapi.ReviewComment(nil), f.reviewComments[fakeKey(repo, pr)]...), nil } -func (f *fakeGitHub) ListIssueReactions(_ context.Context, repo string, pr int) ([]Reaction, error) { +func (f *fakeGitHub) ListIssueReactions(_ context.Context, repo string, pr int) ([]ghapi.Reaction, error) { f.mu.Lock() defer f.mu.Unlock() - return append([]Reaction(nil), f.issueReactions[fakeKey(repo, pr)]...), nil + return append([]ghapi.Reaction(nil), f.issueReactions[fakeKey(repo, pr)]...), nil } -func (f *fakeGitHub) ListCommentReactions(_ context.Context, _ string, id int64) ([]Reaction, error) { +func (f *fakeGitHub) ListCommentReactions(_ context.Context, _ string, id int64) ([]ghapi.Reaction, error) { f.mu.Lock() defer f.mu.Unlock() - return append([]Reaction(nil), f.reactions[id]...), nil + return append([]ghapi.Reaction(nil), f.reactions[id]...), nil } -func (f *fakeGitHub) PostIssueComment(_ context.Context, repo string, pr int, body string) (IssueComment, error) { +func (f *fakeGitHub) PostIssueComment(_ context.Context, repo string, pr int, body string) (ghapi.IssueComment, error) { f.mu.Lock() defer f.mu.Unlock() if err := f.postErrs[fakeKey(repo, pr)]; err != nil { - return IssueComment{}, err + return ghapi.IssueComment{}, err } f.commentID++ f.posted = append(f.posted, repo+"#"+strconv.Itoa(pr)+":"+body) - comment := IssueComment{ID: f.commentID, Body: body, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + comment := ghapi.IssueComment{ID: f.commentID, Body: body, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} comment.User.Login = "kristofferR" return comment, nil } -func (f *fakeGitHub) CreateIssue(_ context.Context, _ string, _ string, _ string) (Issue, error) { +func (f *fakeGitHub) CreateIssue(_ context.Context, _ string, _ string, _ string) (ghapi.Issue, error) { f.mu.Lock() defer f.mu.Unlock() if f.nextIssueNumber == 0 { @@ -116,10 +120,10 @@ func (f *fakeGitHub) CreateIssue(_ context.Context, _ string, _ string, _ string } f.nextIssueNumber++ f.createdIssues = append(f.createdIssues, f.nextIssueNumber) - return Issue{Number: f.nextIssueNumber, State: "open"}, nil + return ghapi.Issue{Number: f.nextIssueNumber, State: "open"}, nil } -func (f *fakeGitHub) ListIssueCommentsPage(_ context.Context, repo string, pr, page, perPage int) ([]IssueComment, error) { +func (f *fakeGitHub) ListIssueCommentsPage(_ context.Context, repo string, pr, page, perPage int) ([]ghapi.IssueComment, error) { f.mu.Lock() defer f.mu.Unlock() all := f.comments[fakeKey(repo, pr)] @@ -131,7 +135,7 @@ func (f *fakeGitHub) ListIssueCommentsPage(_ context.Context, repo string, pr, p if end > len(all) { end = len(all) } - return append([]IssueComment(nil), all[start:end]...), nil + return append([]ghapi.IssueComment(nil), all[start:end]...), nil } func (f *fakeGitHub) DeleteIssueComment(_ context.Context, repo string, id int64) error { @@ -149,13 +153,13 @@ func (f *fakeGitHub) DeleteIssueComment(_ context.Context, repo string, id int64 return nil } -func (f *fakeGitHub) SearchOpenPRs(context.Context, string, bool, int) ([]SearchPR, error) { +func (f *fakeGitHub) SearchOpenPRs(context.Context, string, bool, int) ([]ghapi.SearchPR, error) { return nil, nil } -func (f *fakeGitHub) EachOpenPR(_ context.Context, _ string, _ bool, fn func(SearchPR) (bool, error)) error { +func (f *fakeGitHub) EachOpenPR(_ context.Context, _ string, _ bool, fn func(ghapi.SearchPR) (bool, error)) error { f.mu.Lock() - prs := append([]SearchPR(nil), f.searchPRs...) + prs := append([]ghapi.SearchPR(nil), f.searchPRs...) f.mu.Unlock() for _, pr := range prs { stop, err := fn(pr) @@ -331,13 +335,13 @@ func TestPruneCalibrationDeletesOldNoiseKeepsRecent(t *testing.T) { svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) now := time.Now().UTC() old := now.Add(-time.Hour) - mkc := func(id int64, login, body string, at time.Time) IssueComment { - c := IssueComment{ID: id, Body: body, CreatedAt: at, UpdatedAt: at} + mkc := func(id int64, login, body string, at time.Time) ghapi.IssueComment { + c := ghapi.IssueComment{ID: id, Body: body, CreatedAt: at, UpdatedAt: at} c.User.Login = login return c } key := fakeKey("o/gate", 1) - gh.comments[key] = []IssueComment{ + gh.comments[key] = []ghapi.IssueComment{ mkc(1, "kristofferR", "@coderabbitai rate limit", old), mkc(2, "coderabbitai[bot]", "0 reviews remaining. auto-generated reply by CodeRabbit", old), mkc(3, "someone", "unrelated human comment", old), @@ -391,13 +395,13 @@ func TestAutoReviewScanSkipsConfiguredAuthors(t *testing.T) { SkipAuthors: authorSet("dependabot[bot]"), } gh := newFakeGitHub() - gh.searchPRs = []SearchPR{ + gh.searchPRs = []ghapi.SearchPR{ {Repo: "o/app", Number: 1, Author: "dependabot[bot]"}, {Repo: "o/app", Number: 2, Author: "Dependabot"}, {Repo: "o/app", Number: 3, Author: "alice"}, } for pr := 1; pr <= 3; pr++ { - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/app", pr)] = pull @@ -433,12 +437,12 @@ func TestAutoReviewScanSkipsMarkedPRs(t *testing.T) { SkipMarker: "", } gh := newFakeGitHub() - gh.searchPRs = []SearchPR{ + gh.searchPRs = []ghapi.SearchPR{ {Repo: "o/app", Number: 1, Author: "alice", Body: "Tiny maintenance change.\n\n"}, {Repo: "o/app", Number: 2, Author: "alice", Body: "Review this change."}, } for pr := 1; pr <= 2; pr++ { - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/app", pr)] = pull @@ -495,9 +499,9 @@ func TestLatestCalibrationReplyToleratesBotSuffix(t *testing.T) { gh := newFakeGitHub() svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) now := time.Now().UTC() - comment := IssueComment{Body: "0 reviews remaining. auto-generated reply by CodeRabbit", UpdatedAt: now} + comment := ghapi.IssueComment{Body: "0 reviews remaining. auto-generated reply by CodeRabbit", UpdatedAt: now} comment.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/gate", 1)] = []IssueComment{comment} + gh.comments[fakeKey("o/gate", 1)] = []ghapi.IssueComment{comment} got, ok, err := svc.latestCalibrationReply(context.Background(), 1, now.Add(-time.Minute)) if err != nil { @@ -545,7 +549,7 @@ func TestEnqueueIsIdempotentAndPumpFiresOnce(t *testing.T) { ctx := context.Background() cfg := firingConfig() gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls["owner/repo#12"] = pull @@ -590,7 +594,7 @@ func TestPumpPersistsPostedReviewAfterTransientStateFailure(t *testing.T) { ctx := context.Background() cfg := firingConfig() gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls["owner/repo#12"] = pull @@ -619,12 +623,11 @@ func TestPumpPersistsPostedReviewAfterTransientStateFailure(t *testing.T) { if firedMarker(&state, "owner/repo", 12) != "abcdef123" { t.Fatalf("fired marker was not persisted after retry") } - wait := waitView(&state, "owner/repo", 12) - if wait.Head != "abcdef123" || r.FiredAt == nil || !wait.StartedAt.Equal(*r.FiredAt) { - t.Fatalf("feedback wait should start at the fired timestamp, wait=%#v round=%#v", wait, r) + if r.FiredAt == nil || r.WaitDeadline == nil { + t.Fatalf("feedback wait should be set on the fired round: %#v", r) } - if wait.Deadline.Sub(wait.StartedAt) != cfg.FeedbackWaitTimeout { - t.Fatalf("feedback wait deadline should use CRQ_FEEDBACK_WAIT_TIMEOUT, got %s", wait.Deadline.Sub(wait.StartedAt)) + if r.WaitDeadline.Sub(*r.FiredAt) != cfg.FeedbackWaitTimeout { + t.Fatalf("feedback wait deadline should use CRQ_FEEDBACK_WAIT_TIMEOUT, got %s", r.WaitDeadline.Sub(*r.FiredAt)) } } @@ -633,16 +636,16 @@ func TestPumpAdoptsExistingReviewCommandWithoutRefiring(t *testing.T) { cfg := firingConfig() gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc - comment := IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: headTime.Add(30 * time.Second), UpdatedAt: headTime.Add(30 * time.Second)} + comment := ghapi.IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: headTime.Add(30 * time.Second), UpdatedAt: headTime.Add(30 * time.Second)} comment.User.Login = "kristofferR" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{comment} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) @@ -670,8 +673,11 @@ func TestPumpAdoptsExistingReviewCommandWithoutRefiring(t *testing.T) { if firedMarker(&state, "owner/repo", 12) != "abcdef123" { t.Fatalf("existing review command should restore fired dedupe state") } - if wait := waitView(&state, "owner/repo", 12); wait.Head != "abcdef123" || !wait.StartedAt.Equal(comment.CreatedAt) { - t.Fatalf("existing review command should create a feedback wait from the comment timestamp, got %#v", wait) + if r.FiredAt == nil || !r.FiredAt.Equal(comment.CreatedAt) { + t.Fatalf("adopted review command should set the fired timestamp from the comment, got %#v", r) + } + if r.WaitDeadline == nil || !r.WaitDeadline.Equal(comment.CreatedAt.Add(cfg.FeedbackWaitTimeout)) { + t.Fatalf("adopted review command should set the feedback wait deadline from the comment timestamp, got %#v", r) } } @@ -680,21 +686,21 @@ func TestAdoptableCommandsRequiresExpectedHead(t *testing.T) { cfg := Config{GateRepo: "owner/gate", Host: "testhost", Bot: "coderabbitai[bot]", ReviewCommand: "@coderabbitai review"} gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef9994567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc - comment := IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: headTime.Add(30 * time.Second), UpdatedAt: headTime.Add(30 * time.Second)} + comment := ghapi.IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: headTime.Add(30 * time.Second), UpdatedAt: headTime.Add(30 * time.Second)} comment.User.Login = "kristofferR" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{comment} service := NewService(cfg, gh, NewMemoryStore(cfg), nil) comments, _ := gh.ListIssueComments(ctx, "owner/repo", 12) reviews, _ := gh.ListReviews(ctx, "owner/repo", 12) - cmds, err := service.adoptableCommands(ctx, "owner/repo", 12, "abcdef123", time.Time{}, pull, comments, reviews) + cmds, err := service.adoptableCommands(ctx, "owner/repo", 12, engine.Observation{Head: "abcdef123", Open: true}, time.Time{}, pull, comments, reviews) if err != nil || len(cmds) != 0 { t.Fatalf("must not adopt a review command after the PR head changed, cmds=%v err=%v", cmds, err) } @@ -706,16 +712,16 @@ func TestPumpDryRunDoesNotAdoptExistingCommand(t *testing.T) { cfg.DryRun = true gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc - comment := IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: headTime.Add(30 * time.Second), UpdatedAt: headTime.Add(30 * time.Second)} + comment := ghapi.IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: headTime.Add(30 * time.Second), UpdatedAt: headTime.Add(30 * time.Second)} comment.User.Login = "kristofferR" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{comment} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) @@ -739,16 +745,16 @@ func TestPumpIgnoresStaleCommandAfterRequeue(t *testing.T) { cfg := firingConfig() gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc - stale := IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: headTime.Add(10 * time.Second), UpdatedAt: headTime.Add(10 * time.Second)} + stale := ghapi.IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: headTime.Add(10 * time.Second), UpdatedAt: headTime.Add(10 * time.Second)} stale.User.Login = "kristofferR" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{stale} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{stale} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) @@ -788,16 +794,16 @@ func TestPumpDoesNotAdoptCommandOlderThanForcePush(t *testing.T) { commitTime := time.Now().UTC().Add(-time.Hour) staleAt := commitTime.Add(10 * time.Minute) forcePushAt := commitTime.Add(30 * time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = commitTime gh.commits[pull.Head.SHA] = gc - stale := IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: staleAt, UpdatedAt: staleAt} + stale := ghapi.IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: staleAt, UpdatedAt: staleAt} stale.User.Login = "kristofferR" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{stale} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{stale} gh.graphQL = func(_ string, _ map[string]any, out any) error { payload := `{"repository":{"pullRequest":{"timelineItems":{"nodes":[{"createdAt":"` + forcePushAt.Format(time.RFC3339) + `"}]}}}}` return json.Unmarshal([]byte(payload), out) @@ -826,19 +832,19 @@ func TestPumpDoesNotAdoptCommandAlreadyAnsweredByReview(t *testing.T) { gh := newFakeGitHub() commitTime := time.Now().UTC().Add(-time.Hour) commandAt := commitTime.Add(10 * time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = commitTime gh.commits[pull.Head.SHA] = gc - command := IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: commandAt, UpdatedAt: commandAt} + command := ghapi.IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: commandAt, UpdatedAt: commandAt} command.User.Login = "kristofferR" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{command} - answered := Review{SubmittedAt: commandAt.Add(5 * time.Minute), CommitID: "9876543210fedcba"} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{command} + answered := ghapi.Review{SubmittedAt: commandAt.Add(5 * time.Minute), CommitID: "9876543210fedcba"} answered.User.Login = cfg.Bot - gh.reviews[fakeKey("owner/repo", 12)] = []Review{answered} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{answered} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) @@ -863,18 +869,18 @@ func TestPumpDoesNotAdoptCommandAlreadyAnsweredByCompletionReply(t *testing.T) { gh := newFakeGitHub() commitTime := time.Now().UTC().Add(-time.Hour) commandAt := commitTime.Add(10 * time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = commitTime gh.commits[pull.Head.SHA] = gc - command := IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: commandAt, UpdatedAt: commandAt} + command := ghapi.IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: commandAt, UpdatedAt: commandAt} command.User.Login = "kristofferR" - reply := IssueComment{ID: 78, Body: "\nReview finished.", CreatedAt: commandAt.Add(time.Minute), UpdatedAt: commandAt.Add(time.Minute)} + reply := ghapi.IssueComment{ID: 78, Body: "\nReview finished.", CreatedAt: commandAt.Add(time.Minute), UpdatedAt: commandAt.Add(time.Minute)} reply.User.Login = cfg.Bot - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{command, reply} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{command, reply} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) @@ -899,25 +905,25 @@ func TestPumpAdoptsCompletionAnsweredCommandWhileTopSummaryIsProcessing(t *testi gh := newFakeGitHub() commitTime := time.Now().UTC().Add(-time.Hour) commandAt := commitTime.Add(10 * time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = commitTime gh.commits[pull.Head.SHA] = gc - command := IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: commandAt, UpdatedAt: commandAt} + command := ghapi.IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: commandAt, UpdatedAt: commandAt} command.User.Login = "kristofferR" - reply := IssueComment{ID: 78, Body: "\nReview finished.", CreatedAt: commandAt.Add(time.Minute), UpdatedAt: commandAt.Add(time.Minute)} + reply := ghapi.IssueComment{ID: 78, Body: "\nReview finished.", CreatedAt: commandAt.Add(time.Minute), UpdatedAt: commandAt.Add(time.Minute)} reply.User.Login = cfg.Bot - summary := IssueComment{ + summary := ghapi.IssueComment{ ID: 79, Body: "\nCurrently processing new changes in this PR. This may take a few minutes, please wait...", CreatedAt: commandAt.Add(-time.Hour), UpdatedAt: commandAt.Add(2 * time.Minute), } summary.User.Login = cfg.Bot - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{summary, command, reply} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{summary, command, reply} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) @@ -942,14 +948,14 @@ func TestPumpDryRunDoesNotDedupeMutably(t *testing.T) { cfg.DryRun = true cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull // The bot already reviewed the head, so DecideFire would dedupe. - review := Review{CommitID: "abcdef1234567890", SubmittedAt: time.Now().UTC()} + review := ghapi.Review{CommitID: "abcdef1234567890", SubmittedAt: time.Now().UTC()} review.User.Login = cfg.Bot - gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{review} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) @@ -970,14 +976,14 @@ func TestPumpSkipsAdoptionWhenCommitLookupFails(t *testing.T) { ctx := context.Background() cfg := firingConfig() gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull gh.commitErrs[pull.Head.SHA] = errors.New("404 not found") - comment := IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: time.Now().UTC().Add(-30 * time.Second), UpdatedAt: time.Now().UTC().Add(-30 * time.Second)} + comment := ghapi.IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: time.Now().UTC().Add(-30 * time.Second), UpdatedAt: time.Now().UTC().Add(-30 * time.Second)} comment.User.Login = "kristofferR" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{comment} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) @@ -1003,13 +1009,13 @@ func TestPumpCompletesRoundWhenReviewSubmitted(t *testing.T) { cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() firedAt := time.Now().UTC().Add(-5 * time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - review := Review{CommitID: "abcdef1234567890", SubmittedAt: firedAt.Add(time.Minute)} + review := ghapi.Review{CommitID: "abcdef1234567890", SubmittedAt: firedAt.Add(time.Minute)} review.User.Login = cfg.Bot - gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{review} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseFired, firedAt, 5) @@ -1034,19 +1040,19 @@ func TestPumpCompletesRoundOnCompletionReply(t *testing.T) { cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() firedAt := time.Now().UTC().Add(-5 * time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - command := IssueComment{ID: 5, Body: cfg.ReviewCommand, CreatedAt: firedAt, UpdatedAt: firedAt} + command := ghapi.IssueComment{ID: 5, Body: cfg.ReviewCommand, CreatedAt: firedAt, UpdatedAt: firedAt} command.User.Login = "kristofferR" - reply := IssueComment{ID: 6, Body: "\nReview finished.", CreatedAt: firedAt.Add(time.Minute), UpdatedAt: firedAt.Add(time.Minute)} + reply := ghapi.IssueComment{ID: 6, Body: "\nReview finished.", CreatedAt: firedAt.Add(time.Minute), UpdatedAt: firedAt.Add(time.Minute)} reply.User.Login = cfg.Bot - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{command, reply} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{command, reply} // A completion-only round is a re-review: a prior review must exist. - prior := Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} + prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} prior.User.Login = cfg.Bot - gh.reviews[fakeKey("owner/repo", 12)] = []Review{prior} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{prior} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseFired, firedAt, command.ID) @@ -1075,15 +1081,15 @@ func TestPumpKeepsRoundReviewingOnCompletionReplyForUnreviewedPR(t *testing.T) { cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() firedAt := time.Now().UTC().Add(-5 * time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - command := IssueComment{ID: 5, Body: cfg.ReviewCommand, CreatedAt: firedAt, UpdatedAt: firedAt} + command := ghapi.IssueComment{ID: 5, Body: cfg.ReviewCommand, CreatedAt: firedAt, UpdatedAt: firedAt} command.User.Login = "kristofferR" - reply := IssueComment{ID: 6, Body: "\n✅ Action performed\n\nReview finished.", CreatedAt: firedAt.Add(5 * time.Second), UpdatedAt: firedAt.Add(5 * time.Second)} + reply := ghapi.IssueComment{ID: 6, Body: "\n✅ Action performed\n\nReview finished.", CreatedAt: firedAt.Add(5 * time.Second), UpdatedAt: firedAt.Add(5 * time.Second)} reply.User.Login = cfg.Bot - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{command, reply} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{command, reply} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseFired, firedAt, command.ID) @@ -1095,7 +1101,7 @@ func TestPumpKeepsRoundReviewingOnCompletionReplyForUnreviewedPR(t *testing.T) { t.Fatalf("the round must survive a completion reply on a never-reviewed PR (reviewing), got %s", p) } st, _, _ := store.Load(ctx) - if waitView(&st, "owner/repo", 12).Head != "abcdef123" { + if waitingHead(&st, "owner/repo", 12) != "abcdef123" { t.Fatalf("the feedback wait must survive a completion reply on a never-reviewed PR") } } @@ -1107,13 +1113,13 @@ func TestPumpKeepsRoundReviewingWhenBotOnlyReacted(t *testing.T) { cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() firedAt := time.Now().UTC().Add(-time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - reaction := Reaction{} + reaction := ghapi.Reaction{} reaction.User.Login = cfg.Bot - gh.reactions[5] = []Reaction{reaction} + gh.reactions[5] = []ghapi.Reaction{reaction} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseFired, firedAt, 5) @@ -1138,13 +1144,13 @@ func TestPumpKeepsRoundOpenUntilAllRequiredBotsReview(t *testing.T) { cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() firedAt := time.Now().UTC().Add(-5 * time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - review := Review{SubmittedAt: firedAt.Add(time.Minute), CommitID: "abcdef1234567890"} + review := ghapi.Review{SubmittedAt: firedAt.Add(time.Minute), CommitID: "abcdef1234567890"} review.User.Login = cfg.Bot - gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{review} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseFired, firedAt, 5) @@ -1157,9 +1163,9 @@ func TestPumpKeepsRoundOpenUntilAllRequiredBotsReview(t *testing.T) { } // A required-bot review for a different commit must not complete it. - staleCodex := Review{SubmittedAt: firedAt.Add(2 * time.Minute), CommitID: "0123456789abcdef"} + staleCodex := ghapi.Review{SubmittedAt: firedAt.Add(2 * time.Minute), CommitID: "0123456789abcdef"} staleCodex.User.Login = "chatgpt-codex-connector" - gh.reviews[fakeKey("owner/repo", 12)] = []Review{review, staleCodex} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{review, staleCodex} if _, err := service.Pump(ctx); err != nil { t.Fatal(err) } @@ -1168,9 +1174,9 @@ func TestPumpKeepsRoundOpenUntilAllRequiredBotsReview(t *testing.T) { } // The head review completes it. - codex := Review{SubmittedAt: firedAt.Add(3 * time.Minute), CommitID: "abcdef1234567890"} + codex := ghapi.Review{SubmittedAt: firedAt.Add(3 * time.Minute), CommitID: "abcdef1234567890"} codex.User.Login = "chatgpt-codex-connector" - gh.reviews[fakeKey("owner/repo", 12)] = []Review{review, staleCodex, codex} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{review, staleCodex, codex} if _, err := service.Pump(ctx); err != nil { t.Fatal(err) } @@ -1185,7 +1191,7 @@ func TestPumpSweepsReviewingRoundToCompletion(t *testing.T) { cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() firedAt := time.Now().UTC().Add(-5 * time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull @@ -1201,9 +1207,9 @@ func TestPumpSweepsReviewingRoundToCompletion(t *testing.T) { t.Fatalf("the round must stay reviewing while the review is still running, got %s", p) } - review := Review{SubmittedAt: firedAt.Add(2 * time.Minute), CommitID: "abcdef1234567890"} + review := ghapi.Review{SubmittedAt: firedAt.Add(2 * time.Minute), CommitID: "abcdef1234567890"} review.User.Login = cfg.Bot - gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{review} if _, err := service.Pump(ctx); err != nil { t.Fatal(err) } @@ -1218,7 +1224,7 @@ func TestPumpDryRunDoesNotSweepReviewing(t *testing.T) { cfg.DryRun = true cfg.FeedbackWaitTimeout = time.Hour gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull @@ -1239,16 +1245,16 @@ func TestPumpTreatsExistingReviewAdoptionRaceAsLostRace(t *testing.T) { cfg := firingConfig() gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc - comment := IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: headTime.Add(30 * time.Second), UpdatedAt: headTime.Add(30 * time.Second)} + comment := ghapi.IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: headTime.Add(30 * time.Second), UpdatedAt: headTime.Add(30 * time.Second)} comment.User.Login = "kristofferR" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{comment} loadState := DefaultState(cfg) r, _ := loadState.NewRound("owner/repo", 12, "abcdef123", headTime) loadState.PutRound(*r) @@ -1283,13 +1289,13 @@ func TestWaitReenqueuesAfterClearingStaleRound(t *testing.T) { cfg.InflightTimeout = time.Hour cfg.WaitTimeout = time.Second gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef9994567890" gh.pulls["owner/repo#12"] = pull - review := Review{CommitID: "abcdef1234567890", SubmittedAt: now.Add(time.Second)} + review := ghapi.Review{CommitID: "abcdef1234567890", SubmittedAt: now.Add(time.Second)} review.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("owner/repo", 12)] = []Review{review} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{review} store := NewMemoryStore(cfg) // A stale fired round for a head that has since moved (abcdef123 → abcdef999). seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseFired, now, 7) @@ -1315,11 +1321,11 @@ func TestWaitFiresRealReviewWhenOnlyCarriedThreadVisible(t *testing.T) { cfg.WaitTimeout = time.Second gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc threadCreated := headTime.Add(-time.Hour).Format(time.RFC3339) @@ -1356,16 +1362,16 @@ func TestWaitReturnsCurrentHeadFeedbackBeforeReviewSlot(t *testing.T) { cfg.WaitTimeout = time.Second gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc - comment := IssueComment{ID: 91, Body: "Actionable Codex finding on the queued head", CreatedAt: headTime.Add(time.Second), UpdatedAt: headTime.Add(time.Second)} + comment := ghapi.IssueComment{ID: 91, Body: "Actionable Codex finding on the queued head", CreatedAt: headTime.Add(time.Second), UpdatedAt: headTime.Add(time.Second)} comment.User.Login = "chatgpt-codex-connector[bot]" - gh.comments[fakeKey("owner/repo", 12)] = []IssueComment{comment} + gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{comment} service := NewService(cfg, gh, NewMemoryStore(cfg), nil) result, code, err := service.Wait(ctx, "owner/repo", 12) @@ -1389,21 +1395,21 @@ func TestWaitFiresRealReviewWhenOnlyCarriedReviewPromptVisible(t *testing.T) { cfg.WaitTimeout = time.Second gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc - stale := Review{ + stale := ghapi.Review{ ID: 7, Body: "
Prompt for AI agents\n\n```\nIn `@a.go`:\n- Around line 1: Carried-over finding.\n```\n
", CommitID: "fedcba9876543210", SubmittedAt: headTime.Add(-time.Hour), } stale.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("owner/repo", 12)] = []Review{stale} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{stale} store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) @@ -1428,21 +1434,21 @@ func TestWaitRepairsPoisonedCompletedRoundWithOnlyCarriedReviewPrompt(t *testing cfg.WaitTimeout = time.Second gh := newFakeGitHub() headTime := time.Now().UTC().Add(-time.Minute) - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("owner/repo", 12)] = pull - gc := gitCommit{SHA: pull.Head.SHA} + gc := ghapi.Commit{SHA: pull.Head.SHA} gc.Committer.Date = headTime gh.commits[pull.Head.SHA] = gc - stale := Review{ + stale := ghapi.Review{ ID: 7, Body: "
Prompt for AI agents\n\n```\nIn `@a.go`:\n- Around line 1: Carried-over finding.\n```\n
", CommitID: "fedcba9876543210", SubmittedAt: headTime.Add(-time.Hour), } stale.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("owner/repo", 12)] = []Review{stale} + gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{stale} store := NewMemoryStore(cfg) // A poisoned completed round at the head with no real head review. seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseCompleted, headTime, 0) @@ -1463,13 +1469,13 @@ func TestWaitRepairsPoisonedCompletedRoundWithOnlyCarriedReviewPrompt(t *testing func TestNeedsReviewToleratesBotSuffix(t *testing.T) { cfg := Config{Bot: "coderabbitai", GateRepo: "o/gate", Scope: []string{"o"}, ReviewDoneMarker: "summarize by coderabbit.ai"} gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls[fakeKey("o/repo", 5)] = pull - review := Review{CommitID: "abcdef1234567890"} + review := ghapi.Review{CommitID: "abcdef1234567890"} review.User.Login = "coderabbitai[bot]" - gh.reviews[fakeKey("o/repo", 5)] = []Review{review} + gh.reviews[fakeKey("o/repo", 5)] = []ghapi.Review{review} svc := NewService(cfg, gh, NewMemoryStore(cfg), nil) need, _, err := svc.needsReview(context.Background(), DefaultState(cfg), "o/repo", 5, true) @@ -1481,9 +1487,9 @@ func TestNeedsReviewToleratesBotSuffix(t *testing.T) { } gh.reviews[fakeKey("o/repo", 5)] = nil - comment := IssueComment{Body: "finished; summarize by coderabbit.ai"} + comment := ghapi.IssueComment{Body: "finished; summarize by coderabbit.ai"} comment.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/repo", 5)] = []IssueComment{comment} + gh.comments[fakeKey("o/repo", 5)] = []ghapi.IssueComment{comment} need, _, err = svc.needsReview(context.Background(), DefaultState(cfg), "o/repo", 5, false) if err != nil { t.Fatal(err) @@ -1498,7 +1504,7 @@ func TestNeedsReviewSkipsTrackedHeadButNotNewHead(t *testing.T) { cfg := Config{Bot: "coderabbitai", Host: "h"} gh := newFakeGitHub() head := "a0646f010" - pull := Pull{State: "open"} + pull := ghapi.Pull{State: "open"} pull.Head.SHA = head + "aaaaaa0" gh.pulls[fakeKey("o/carrier", 82)] = pull store := NewMemoryStore(cfg) @@ -1543,7 +1549,7 @@ func TestPumpDropsClosedPRWithoutFiring(t *testing.T) { ctx := context.Background() cfg := firingConfig() gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "closed" pull.Merged = true pull.Head.SHA = "abcdef1234567890" @@ -1573,7 +1579,7 @@ func TestPumpDropsClosedPRWhileReviewQuotaIsBlocked(t *testing.T) { ctx := context.Background() cfg := firingConfig() gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "closed" pull.Merged = true gh.pulls[fakeKey("owner/repo", 12)] = pull @@ -1619,7 +1625,7 @@ func TestEnqueueDedupesAlreadyReviewedHead(t *testing.T) { ctx := context.Background() cfg := firingConfig() gh := newFakeGitHub() - var pull Pull + var pull ghapi.Pull pull.State = "open" pull.Head.SHA = "abcdef1234567890" gh.pulls["owner/repo#7"] = pull @@ -1643,7 +1649,7 @@ func TestRateLimitedRoundParksAndBlocksAccount(t *testing.T) { cfg.InflightTimeout = time.Hour gh := newFakeGitHub() head := "a0646f010" - pull := Pull{State: "open"} + pull := ghapi.Pull{State: "open"} pull.Head.SHA = head + "abcdef0" gh.pulls[fakeKey("o/carrier", 82)] = pull store := NewMemoryStore(cfg) @@ -1663,11 +1669,11 @@ func TestRateLimitedRoundParksAndBlocksAccount(t *testing.T) { // CodeRabbit answers with a rate-limit comment and an already-reviewed claim, // but no review object. The round must park (retry later), not complete. answer := time.Now().UTC().Add(time.Minute) - rl := IssueComment{ID: 501, Body: "\n> ## Review limit reached\n> **Next review available in:** **40 minutes**", CreatedAt: answer, UpdatedAt: answer} + rl := ghapi.IssueComment{ID: 501, Body: "\n> ## Review limit reached\n> **Next review available in:** **40 minutes**", CreatedAt: answer, UpdatedAt: answer} rl.User.Login = "coderabbitai[bot]" - ack := IssueComment{ID: 502, Body: "
✅ Action performed\n\nReview finished.\n\n> Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits.
", CreatedAt: answer, UpdatedAt: answer} + ack := ghapi.IssueComment{ID: 502, Body: "
✅ Action performed\n\nReview finished.\n\n> Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits.
", CreatedAt: answer, UpdatedAt: answer} ack.User.Login = "coderabbitai[bot]" - gh.comments[fakeKey("o/carrier", 82)] = []IssueComment{rl, ack} + gh.comments[fakeKey("o/carrier", 82)] = []ghapi.IssueComment{rl, ack} res, err = svc.Pump(ctx) if err != nil { @@ -1761,7 +1767,7 @@ func TestMemoryStoreConcurrentUpdatesDoNotLoseMutations(t *testing.T) { func TestParseAvailableInHandlesMarkdownAndColon(t *testing.T) { base := time.Date(2026, 7, 11, 18, 24, 0, 0, time.UTC) body := "## Review limit reached\n\nYou have reached your review limit.\n\n**Next review available in:** **40 minutes**" - got := parseAvailableIn(body, base) + got := dialect.ParseAvailableIn(body, base) if got == nil { t.Fatal("expected a parsed reset for the verbatim rate-limit body, got nil") } @@ -1772,11 +1778,11 @@ func TestParseAvailableInHandlesMarkdownAndColon(t *testing.T) { func TestParseAvailableInPlainFormatStillWorks(t *testing.T) { base := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) - got := parseAvailableIn("You are rate limited. Reviews available in 3 minutes.", base) + got := dialect.ParseAvailableIn("You are rate limited. Reviews available in 3 minutes.", base) if got == nil || !got.Equal(base.Add(3*time.Minute)) { t.Fatalf("expected base+3m for the plain format, got %v", got) } - got = parseAvailableIn("available in 1 hour and 30 minutes", base) + got = dialect.ParseAvailableIn("available in 1 hour and 30 minutes", base) if got == nil || !got.Equal(base.Add(90*time.Minute)) { t.Fatalf("expected base+90m for compound duration, got %v", got) } diff --git a/internal/crq/state.go b/internal/crq/state.go index 569180c..4810269 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -6,6 +6,7 @@ import ( "time" "github.com/kristofferR/coderabbit-queue/internal/engine" + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" crqstate "github.com/kristofferR/coderabbit-queue/internal/state" ) @@ -53,7 +54,7 @@ func (c Config) storeConfig() StoreConfig { // 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 *GitHub, log Logger) *crqstate.GitStateStore { +func NewGitStateStore(cfg Config, gh *ghapi.GitHub, log Logger) *crqstate.GitStateStore { return crqstate.NewGitStateStore(cfg.storeConfig(), gh, log) } @@ -99,48 +100,27 @@ func QueueKey(repo string, pr int) string { return fmt.Sprintf("%s#%d", NormalizeRepo(repo), pr) } -// --- v2→v3 compatibility shims (consumed by feedback.go / Wait, rewritten in 4b) --- - -// FeedbackWait is the v2-shaped view of a fired/reviewing round, retained so -// the feedback/loop code keeps compiling against round state until stage 4b -// rewrites it. -type FeedbackWait struct { - Repo string - PR int - Head string - StartedAt time.Time - Deadline time.Time - FiredCommentID int64 - ByHost string -} +// --- round-native views consumed by Wait/Loop ----------------------------- -// waitView presents a repo#pr's current round as a FeedbackWait when it is -// fired or reviewing (the v2 "awaiting feedback" states); otherwise the zero -// value, whose empty Head reads as "no wait" at every call site. -func waitView(st *State, repo string, pr int) FeedbackWait { +// waitingHead returns the head a fired/reviewing round is currently waiting on +// (the wait IS the round), or "" when repo#pr has no active wait. Loop and Wait +// use it to tell "a review is in flight for this head" from "start a new round". +func waitingHead(st *State, repo string, pr int) string { r := st.Round(repo, pr) if r == nil || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) { - return FeedbackWait{} - } - w := FeedbackWait{Repo: r.Repo, PR: r.PR, Head: r.Head, FiredCommentID: r.CommandID, ByHost: r.ByHost} - if r.FiredAt != nil { - w.StartedAt = r.FiredAt.UTC() - } - if r.WaitDeadline != nil { - w.Deadline = r.WaitDeadline.UTC() + return "" } - return w + return r.Head } -// roundAnchor returns the fire timestamp and command id for repo#pr's current -// round when its head matches — the completion cutoff anchor that v2 read from -// AwaitingFeedback/InFlight/History. -func roundAnchor(st *State, repo string, pr int, head string) (firedAt time.Time, commandID int64, ok bool) { +// roundWaitDeadline returns the wait deadline of the fired/reviewing round at +// head, if one is set. It is the wall-clock bound Loop polls against. +func roundWaitDeadline(st *State, repo string, pr int, head string) (time.Time, bool) { r := st.Round(repo, pr) - if r == nil || r.Head != head || r.FiredAt == nil { - return time.Time{}, 0, false + if r == nil || r.Head != head || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) || r.WaitDeadline == nil { + return time.Time{}, false } - return r.FiredAt.UTC(), r.CommandID, true + return r.WaitDeadline.UTC(), true } // containsActive reports whether repo#pr has a round still occupying its slot diff --git a/internal/dialect/coderabbit.go b/internal/dialect/coderabbit.go index dca3904..6347c90 100644 --- a/internal/dialect/coderabbit.go +++ b/internal/dialect/coderabbit.go @@ -17,6 +17,23 @@ type CodeRabbit struct { CalibrationMarker string } +// Default CodeRabbit dialect vocabulary. These literals live here — the one +// package that owns bot-message text — so crq/config and the engine inject them +// without spelling out the "rate limit" wording themselves. +const ( + // DefaultRateLimitCommand is the probe crq posts to read CodeRabbit's + // account-wide review quota without spending a real review. + DefaultRateLimitCommand = "@coderabbitai rate limit" + // DefaultRateLimitMarker is the legacy phrase in CodeRabbit's account-quota + // notice (the newer Fair Usage format is matched separately). + DefaultRateLimitMarker = "rate limited by coderabbit.ai" + // ReasonRateLimited is the requeue reason recorded when a fired review comes + // back account-blocked. It is the human-readable string users grep in the + // daemon log (requeue ... reason="rate limited"); the engine and crq + // reference it so the literal stays in this package. + ReasonRateLimited = "rate limited" +) + // IsCompletionReply reports whether body is the bot's reply to a processed // review command (CodeRabbit: "Review finished."). An empty marker disables // the completion-reply convergence fallback entirely. diff --git a/internal/dialect/event.go b/internal/dialect/event.go index cd3c91d..7cddd30 100644 --- a/internal/dialect/event.go +++ b/internal/dialect/event.go @@ -13,17 +13,17 @@ import ( type EventKind int const ( - EvOther EventKind = iota - EvCommand // the review 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 - EvInProgress // editable top summary: review still processing - 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 - EvCodexNotice // non-actionable Codex notice (usage limits, acks) + EvOther EventKind = iota + EvCommand // the review 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 + EvInProgress // editable top summary: review still processing + 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 + EvCodexNotice // non-actionable Codex notice (usage limits, acks) ) // BotEvent is one classified issue comment. CreatedAt orders command↔reply diff --git a/internal/engine/completion.go b/internal/engine/completion.go index 0ddeff4..e681dea 100644 --- a/internal/engine/completion.go +++ b/internal/engine/completion.go @@ -165,6 +165,27 @@ func completionReplyForRound(obs Observation, p Policy, firedAt time.Time) bool return false } +// CommandHasCompletionReply reports whether the specific command comment was +// answered by a completion reply with no in-progress/rate-limited/paused top +// summary contradicting it since. It ports v2's reviewCommandHasCompletionReply +// (the adoption guard: a command already answered by a completion reply belongs +// to a finished round and must not be re-adopted as a fresh fire). Unlike the +// convergence fallback it does not require a prior submitted review or gate on +// a failed summary — adoption only asks "was this exact command already spoken +// for". +func CommandHasCompletionReply(obs Observation, p Policy, commandID int64) bool { + if commandID == 0 { + return false + } + for _, reply := range commandReplies(obs, p) { + if reply.commandID == commandID && reply.completion && + !stateSince(obs, p, reply.commandAt, dialect.EvInProgress, dialect.EvRateLimited, dialect.EvPaused) { + return true + } + } + return false +} + func botHasAnyReview(reviews []ReviewSeen, bot string) bool { for _, review := range reviews { if sameBot(review.Bot, bot) { diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 4c2c57c..977e76b 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -284,6 +284,51 @@ func TestPreFireReviewOfHeadCompletes(t *testing.T) { } } +// TestCompletionFlipsRequiredBotAcrossSuffix ports crq's markReviewed suffix +// test: a review whose login differs from the configured required bot only by +// the "[bot]" suffix (REST "coderabbitai[bot]" vs GraphQL "coderabbitai") must +// still flip the required key, or convergence (which ANDs every key) stays +// permanently false. +func TestCompletionFlipsRequiredBotAcrossSuffix(t *testing.T) { + r := firedRound(t, "abcdef123") + // Required key carries the suffix; the review login does not. + obs := Observation{Head: "abcdef123", Open: true, Reviews: []ReviewSeen{ + {Bot: "coderabbitai", ReviewID: 9, Commit: "abcdef1234567890", SubmittedAt: t0.Add(time.Minute)}, + }} + if got := Completion(r, obs, policy); !got.Done { + t.Fatalf("a suffix-less review login must flip the suffixed required key: %+v", got) + } + // Inverse: required key without suffix, review login with it. + noSuffix := policy + noSuffix.RequiredBots = []string{"coderabbitai"} + obs.Reviews[0].Bot = "coderabbitai[bot]" + if got := Completion(r, obs, noSuffix); !got.Done { + t.Fatalf("a suffixed review login must flip the suffix-less required key: %+v", got) + } +} + +// TestCommandHasCompletionReply covers the adoption guard: a command already +// answered by a completion reply is spoken for and must not be re-adopted, +// unless an in-progress/rate-limited/paused summary since the reply reopens it. +func TestCommandHasCompletionReply(t *testing.T) { + base := []dialect.BotEvent{ + {Kind: dialect.EvCommand, Bot: "kristofferR", CommentID: 1001, CreatedAt: t0, UpdatedAt: t0}, + {Kind: dialect.EvCompletion, Bot: "coderabbitai[bot]", CommentID: 1002, AutoReply: true, CreatedAt: t0.Add(5 * time.Second), UpdatedAt: t0.Add(5 * time.Second)}, + } + if !CommandHasCompletionReply(Observation{Events: base}, policy, 1001) { + t.Fatal("a command answered by a completion reply must read as spoken for") + } + if CommandHasCompletionReply(Observation{Events: base}, policy, 999) { + t.Fatal("an unrelated command id must not match") + } + // A processing summary edited in place after the reply reopens the round. + withProcessing := append(append([]dialect.BotEvent(nil), base...), + dialect.BotEvent{Kind: dialect.EvInProgress, Bot: "coderabbitai[bot]", CommentID: 900, CreatedAt: t0.Add(-time.Hour), UpdatedAt: t0.Add(9 * time.Second)}) + if CommandHasCompletionReply(Observation{Events: withProcessing}, policy, 1001) { + t.Fatal("an in-progress summary after the reply must reopen the command") + } +} + // TestCodexGatesCleanSummary ports the codexInactiveOrThumbed rules. func TestCodexGatesCleanSummary(t *testing.T) { r := firedRound(t, "abcdef123") diff --git a/internal/engine/progress.go b/internal/engine/progress.go index 57c6ee6..ea03f9a 100644 --- a/internal/engine/progress.go +++ b/internal/engine/progress.go @@ -16,12 +16,12 @@ const ( KeepWaiting Outcome = iota OutComplete // every required bot has evidence → completed OutReviewing // bot acknowledged; release the slot, keep the round open - OutRetry // park until Transition.RetryAt (rate limit, timeout, failure) + OutRetry // park until Transition.RetryAt (account block, timeout, failure) OutReleaseSlot // reserved but never posted → back to queued OutAbandon // PR closed/merged ) -// AccountBlock is an account-quota update observed from a rate-limit event. +// AccountBlock is an account-quota update observed from an EvRateLimited event. type AccountBlock struct { Until time.Time CommentID int64 @@ -32,7 +32,7 @@ type Transition struct { Outcome Outcome Reason string RetryAt time.Time // OutRetry: earliest re-fire for this head - Blocked *AccountBlock // rate limit: account-wide block to record + Blocked *AccountBlock // account-wide CodeRabbit quota block to record } // reserveTimeout mirrors v2: a reservation that never posted its command @@ -40,7 +40,7 @@ type Transition struct { const reserveTimeout = 2 * time.Minute // Progress decides what happened to a reserved/fired/reviewing round. Ports -// v2's inflightStatus order — submitted review → rate limit → reaction → +// v2's inflightStatus order — submitted review → account block → reaction → // other bot comment → timeout — with two deliberate fixes: the in-progress // and failed top-summary states now gate the daemon path too (v2 applied // them only in feedback.go), and every retry carries a RetryAt cooldown @@ -73,7 +73,8 @@ func Progress(r state.Round, q state.AccountQuota, obs Observation, now time.Tim return Transition{Outcome: OutReviewing, Reason: "review submitted; awaiting remaining bots"} } - // Rate limit beats every ack: the fired command did not produce a review. + // An account-quota block beats every ack: the fired command did not produce + // a review. for _, ev := range obs.Events { if ev.Kind != dialect.EvRateLimited || !sameBot(ev.Bot, p.Bot) || ev.UpdatedAt.Before(firedAt) { continue @@ -81,7 +82,7 @@ func Progress(r state.Round, q state.AccountQuota, obs Observation, now time.Tim until := resolveBlockWindow(ev, q, now, p) return Transition{ Outcome: OutRetry, - Reason: "rate limited", + Reason: dialect.ReasonRateLimited, RetryAt: until, Blocked: &AccountBlock{Until: until, CommentID: ev.CommentID, CommentUpdated: ev.UpdatedAt}, } @@ -103,8 +104,8 @@ func Progress(r state.Round, q state.AccountQuota, obs Observation, now time.Tim if obs.Reacted { return Transition{Outcome: OutReviewing, Reason: "bot reacted"} } - // Any other bot comment in the round window acknowledges it too — but a - // rate-limit/paused/already-reviewed notice is not an ack (v2), and + // Any other bot comment in the round window acknowledges it too — but an + // account-block/paused/already-reviewed notice is not an ack (v2), and // neither is the in-progress summary of a PREVIOUS round edit... which // it cannot be: UpdatedAt gates the window. An in-progress summary IS // an ack that reviewing started. @@ -132,7 +133,7 @@ func Progress(r state.Round, q state.AccountQuota, obs Observation, now time.Tim } // resolveBlockWindow ports v2's requeueInflight window logic: reuse the -// standing block when the SAME edited rate-limit comment is re-observed +// standing block when the SAME edited account-quota comment is re-observed // (CodeRabbit edits one comment in place — a re-observation must not extend // the window on every bounce), and fall back to a conservative fixed window // when no "available in" duration parsed. diff --git a/internal/state/dashboard.go b/internal/state/dashboard.go index 101a094..f1767ab 100644 --- a/internal/state/dashboard.go +++ b/internal/state/dashboard.go @@ -120,16 +120,16 @@ func RenderDashboard(st State, cfg StoreConfig) string { } remaining := "available now" if blocked { - remaining = "0 — rate-limited" + remaining = "0 — account blocked" } fmt.Fprintf(&b, "| | |\n|---|---|\n") fmt.Fprintf(&b, "| **Scope** | `%s` |\n", st.Account.Scope) fmt.Fprintf(&b, "| **Reviews remaining** | %s%s |\n", remaining, via) if blocked { - fmt.Fprintf(&b, "| **Rate limit** | ⚠️ rate limited |\n") + fmt.Fprintf(&b, "| **CodeRabbit quota** | ⚠️ account blocked |\n") } else { - fmt.Fprintf(&b, "| **Rate limit** | ✅ not currently limited |\n") + fmt.Fprintf(&b, "| **CodeRabbit quota** | ✅ not currently blocked |\n") } fmt.Fprintf(&b, "| **Last review fired** | %s |\n", fmtStamp(st.LastFired, loc)) if slot != nil { From 3edc67cc797b63ccb25f60ee46ea364baaf1c501 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 17:20:12 +0200 Subject: [PATCH 06/20] Add a replay suite for the #448 spam incident MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-enact the 2026-07-16 ha-adjustable-bed#448 incident (crq posted `@coderabbitai review` ~19x in one day, 10+ on a single head) end to end against the fakeGitHub + MemoryStore harness, proving the v3 round-state architecture makes that spam class structurally impossible. Inject a controllable clock into Service (s.clock) for the scheduling decisions in the pump/enqueue/sweep/wait paths, and let the fake GitHub timestamp posted comments off the same clock, so a whole day replays deterministically with no sleeps and no wall-clock dependence. Production leaves the clock nil and reads time.Now; logging/jitter uses are untouched. Five scenarios, each asserting at most one command per (head, retry window): - a rate-limit bounce (40m window, comment edited in place every few passes) fires once, parks at a fixed RetryAt for the whole window, then fires one retry — never more; - an instant "Review finished" ack on a first-ever command keeps the round reviewing (not converged, not completed, no double-fire) and the real review's findings surface via Loop (exit 10); - an in-progress top summary releases the fire slot (a second PR fires) while the round stays open and never re-fires; - a force-push mid-round supersedes the round and fires a fresh command for the new head without adopting the stale one; - the full incident sequence (fire, 15m-fallback rate limit, retry, parseable 40m rate limit, head move mid-window, block-gated refire, real review) posts exactly three commands and completes. No production bug surfaced: the architecture already holds the invariant, and the suite pins it against regression. --- internal/crq/feedback.go | 8 +- internal/crq/replay_test.go | 645 +++++++++++++++++++++++++++++++++++ internal/crq/service.go | 27 +- internal/crq/service_test.go | 14 +- 4 files changed, 684 insertions(+), 10 deletions(-) create mode 100644 internal/crq/replay_test.go diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 0d8a44f..c08b384 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -30,7 +30,7 @@ type FeedbackReport struct { func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackReport, error) { repo = NormalizeRepo(repo) - now := time.Now().UTC() + now := s.clock() st, _, err := s.store.Load(ctx) if err != nil { return FeedbackReport{}, err @@ -381,7 +381,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport // REST quota (and re-hit its throttle) every PollInterval. poll := s.cfg.PollInterval var blockedUntil *time.Time - now := time.Now().UTC() + now := s.clock() if st, _, lerr := s.store.Load(ctx); lerr == nil { if until, ok := accountBlockedUntil(&st, repo, pr, head, now); ok { blockedUntil = &until @@ -443,7 +443,7 @@ func (s *Service) ensureWaitDeadline(ctx context.Context, repo string, pr int, h if r == nil || r.Head != head || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) || r.WaitDeadline != nil { return ErrNoChange } - start := time.Now().UTC() + start := s.clock() if r.FiredAt != nil { start = r.FiredAt.UTC() } @@ -464,7 +464,7 @@ func (s *Service) ensureWaitDeadline(ctx context.Context, repo string, pr int, h } // The round is no longer a wait (completed/none): synthesize a transient // deadline so the loop still bounds its poll. - return time.Now().UTC().Add(s.cfg.FeedbackWaitTimeout), nil + return s.clock().Add(s.cfg.FeedbackWaitTimeout), nil } // pushWaitDeadline moves the fired/reviewing round's wait deadline later (never diff --git a/internal/crq/replay_test.go b/internal/crq/replay_test.go new file mode 100644 index 0000000..a7a22c9 --- /dev/null +++ b/internal/crq/replay_test.go @@ -0,0 +1,645 @@ +package crq + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" +) + +// This suite re-enacts the 2026-07-16 spam incident (ha-adjustable-bed#448: crq +// posted `@coderabbitai review` ~19× in one day, 10+ against a single head) and +// proves the v3 round-state architecture makes it structurally impossible. Each +// scenario drives the real Service.Pump/Feedback/Loop against the fakeGitHub + +// MemoryStore harness with an injected clock, so the whole day is replayed in +// microseconds with no sleeps and no wall-clock dependence. +// +// The invariant every scenario asserts: a review command is posted at most once +// per (head, retry window). Forgetting "already requested at this head" would +// require destroying the round record, and no v3 transition does that. + +// replayClock is a controllable UTC clock shared by the Service (scheduling +// decisions) and the fakeGitHub (posted-comment timestamps), so a fire's +// recorded FiredAt tracks the same time the test advances. +type replayClock struct { + mu sync.Mutex + t time.Time +} + +func newReplayClock(t time.Time) *replayClock { return &replayClock{t: t.UTC()} } + +func (c *replayClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *replayClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.t = c.t.Add(d) +} + +func (c *replayClock) set(t time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + c.t = t.UTC() +} + +// replayConfig is firingConfig with the timeouts relaxed so an in-flight round +// does not spuriously time out (and re-fire) mid-replay: the scenarios control +// every retry explicitly. +func replayConfig() Config { + cfg := firingConfig() + cfg.MinInterval = 0 + cfg.InflightTimeout = time.Hour + cfg.FeedbackWaitTimeout = time.Hour + cfg.RateLimitFallback = 15 * time.Minute + return cfg +} + +// CodeRabbit message shapes, pinned to the golden corpus in +// internal/dialect/testdata/coderabbit. Kept verbatim so a bot rewording that +// breaks classification breaks these replays too. +const ( + replayCompletionReply = "\nReview finished." + replayInProgress = "\n\nCurrently processing new changes in this PR. This may take a few minutes, please wait...\n\n
\nCommits\nReviewing files that changed from the base of the PR and between abc1234 and def5678.\n
" + // replayUnparseableRateLimit is a rate-limit notice with no "available in" + // window, so ParseAvailableIn returns nil and the engine falls back to the + // fixed RateLimitFallback (getting THIS wrong is what let #448 re-fire every + // couple of minutes instead of honouring the window). + replayUnparseableRateLimit = "\nYou are rate limited by coderabbit.ai. Please wait before requesting another review." +) + +// replayFairUsage renders the Fair Usage rate-limit reply carrying a parseable +// "available in N minutes" window (real message shape from +// testdata/coderabbit/rate-limit-fair-usage.md). +func replayFairUsage(minutes int) string { + return fmt.Sprintf("\nYou're currently rate limited under our Fair Usage Limits Policy. Your next review will be available in %d minutes.", minutes) +} + +// replayFixture bundles the harness for one scenario. +type replayFixture struct { + t *testing.T + ctx context.Context + clk *replayClock + gh *fakeGitHub + store StateStore + svc *Service + cfg Config + bot string +} + +func newReplayFixture(t *testing.T, base time.Time) *replayFixture { + t.Helper() + clk := newReplayClock(base) + cfg := replayConfig() + gh := newFakeGitHub() + gh.now = clk.now + store := NewMemoryStore(cfg) + svc := NewService(cfg, gh, store, nil) + svc.now = clk.now + return &replayFixture{t: t, ctx: context.Background(), clk: clk, gh: gh, store: store, svc: svc, cfg: cfg, bot: cfg.Bot} +} + +// --- harness helpers ------------------------------------------------------- + +func (f *replayFixture) openPull(repo string, pr int, sha string) { + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + var p ghapi.Pull + p.State = "open" + p.Head.SHA = sha + f.gh.pulls[fakeKey(repo, pr)] = p +} + +func (f *replayFixture) setHead(repo string, pr int, sha string) { + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + p := f.gh.pulls[fakeKey(repo, pr)] + p.Head.SHA = sha + f.gh.pulls[fakeKey(repo, pr)] = p +} + +func (f *replayFixture) setCommitDate(sha string, date time.Time) { + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + c := ghapi.Commit{SHA: sha} + c.Committer.Date = date.UTC() + f.gh.commits[sha] = c +} + +// botComment appends a CodeRabbit issue comment (created == updated == at). +func (f *replayFixture) botComment(repo string, pr int, id int64, body string, at time.Time) { + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + c := ghapi.IssueComment{ID: id, Body: body, CreatedAt: at.UTC(), UpdatedAt: at.UTC()} + c.User.Login = f.bot + key := fakeKey(repo, pr) + f.gh.comments[key] = append(f.gh.comments[key], c) +} + +// humanComment appends a comment authored by the review requester (e.g. the +// trigger command comment that is left on the PR after crq fires). +func (f *replayFixture) humanComment(repo string, pr int, id int64, body string, at time.Time) { + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + c := ghapi.IssueComment{ID: id, Body: body, CreatedAt: at.UTC(), UpdatedAt: at.UTC()} + c.User.Login = "kristofferR" + key := fakeKey(repo, pr) + f.gh.comments[key] = append(f.gh.comments[key], c) +} + +// editComment rewrites an existing comment in place and bumps its UpdatedAt, +// modelling how CodeRabbit edits its single rate-limit / top-summary comment +// rather than posting a new one. +func (f *replayFixture) editComment(repo string, pr int, id int64, body string, updated time.Time) { + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + key := fakeKey(repo, pr) + for i := range f.gh.comments[key] { + if f.gh.comments[key][i].ID == id { + if body != "" { + f.gh.comments[key][i].Body = body + } + f.gh.comments[key][i].UpdatedAt = updated.UTC() + return + } + } + f.t.Fatalf("editComment: no comment %d on %s#%d", id, repo, pr) +} + +func (f *replayFixture) botReview(repo string, pr int, id int64, commitSHA string, at time.Time) { + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + r := ghapi.Review{ID: id, CommitID: commitSHA, State: "COMMENTED", SubmittedAt: at.UTC()} + r.User.Login = f.bot + key := fakeKey(repo, pr) + f.gh.reviews[key] = append(f.gh.reviews[key], r) +} + +func (f *replayFixture) botReviewComment(repo string, pr int, id int64, commitSHA, path string, line int, body string) { + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + c := ghapi.ReviewComment{ID: id, Body: body, Path: path, Line: line, CommitID: commitSHA} + c.User.Login = f.bot + key := fakeKey(repo, pr) + f.gh.reviewComments[key] = append(f.gh.reviewComments[key], c) +} + +func (f *replayFixture) enqueue(repo string, pr int) { + f.t.Helper() + if _, err := f.svc.Enqueue(f.ctx, repo, pr); err != nil { + f.t.Fatalf("enqueue %s#%d: %v", repo, pr, err) + } +} + +func (f *replayFixture) pump() PumpResult { + f.t.Helper() + res, err := f.svc.Pump(f.ctx) + if err != nil { + f.t.Fatalf("pump: %v", err) + } + return res +} + +// autoReviewEnqueue runs the autoreview daemon's per-PR gate: needsReview, and +// enqueueBatch when it says a review is needed. It returns whether it enqueued — +// the spam-relevant question ("did the daemon decide to request another +// review?"). +func (f *replayFixture) autoReviewEnqueue(repo string, pr int) bool { + f.t.Helper() + st, _, err := f.store.Load(f.ctx) + if err != nil { + f.t.Fatalf("load: %v", err) + } + need, head, err := f.svc.needsReview(f.ctx, st, repo, pr, true) + if err != nil { + f.t.Fatalf("needsReview %s#%d: %v", repo, pr, err) + } + if !need { + return false + } + if err := f.svc.enqueueBatch(f.ctx, []queueCandidate{{Repo: repo, PR: pr, Head: head}}); err != nil { + f.t.Fatalf("enqueueBatch %s#%d: %v", repo, pr, err) + } + return true +} + +func (f *replayFixture) round(repo string, pr int) *Round { + f.t.Helper() + st, _, err := f.store.Load(f.ctx) + if err != nil { + f.t.Fatalf("load: %v", err) + } + return st.Round(repo, pr) +} + +func (f *replayFixture) archived(repo string, pr int, head string) *Round { + f.t.Helper() + st, _, err := f.store.Load(f.ctx) + if err != nil { + f.t.Fatalf("load: %v", err) + } + for i := range st.Archive { + r := st.Archive[i] + if r.PR == pr && r.Head == head && NormalizeRepo(r.Repo) == NormalizeRepo(repo) { + return &r + } + } + return nil +} + +// reviewsPosted counts how many `@coderabbitai review` commands crq actually +// posted for repo#pr — the spam meter. +func (f *replayFixture) reviewsPosted(repo string, pr int) int { + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + want := QueueKey(repo, pr) + ":" + f.cfg.ReviewCommand + n := 0 + for _, p := range f.gh.posted { + if p == want { + n++ + } + } + return n +} + +// --- (a) a rate-limit bounce cannot re-fire --------------------------------- + +// TestReplayRateLimitBounceFiresOncePerWindow is the core #448 defence: a fired +// review comes back rate-limited ("available in 40 minutes"), CodeRabbit edits +// that one comment in place repeatedly, and the daemon pumps + re-scans every +// 60s for the whole window. crq must post exactly ONE command through the +// window, park in awaiting_retry with a fixed RetryAt, then fire exactly one +// retry when the window passes — never the ~19 the incident produced. +func TestReplayRateLimitBounceFiresOncePerWindow(t *testing.T) { + base := time.Date(2026, 7, 16, 9, 0, 0, 0, time.UTC) + f := newReplayFixture(t, base) + repo, pr := "o/ha-adjustable-bed", 448 + headSHA, head := "abcdef1234567890", "abcdef123" + f.openPull(repo, pr, headSHA) + f.setCommitDate(headSHA, base.Add(-time.Hour)) + + // Fire once at head H. + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" || res.Head != head { + t.Fatalf("first pump should fire at the head, got %#v", res) + } + if f.reviewsPosted(repo, pr) != 1 { + t.Fatalf("expected exactly one command after the first fire, got %d", f.reviewsPosted(repo, pr)) + } + + // CodeRabbit answers with the Fair Usage rate limit, window 40 minutes. + const rlID = 9001 + f.botComment(repo, pr, rlID, replayFairUsage(40), base) + expectedRetry := base.Add(40 * time.Minute) // parsed from the comment's UpdatedAt (base) + + // Simulate the daemon for the full window: advance 60s each step, editing the + // SAME comment in place every 5 minutes (bumping its UpdatedAt), pumping and + // re-scanning at each. The round must stay parked and the count must stay 1. + for m := 1; m < 40; m++ { + f.clk.advance(time.Minute) + if m%5 == 0 { + f.editComment(repo, pr, rlID, "", f.clk.now()) // CodeRabbit edits it in place + } + f.pump() + if f.autoReviewEnqueue(repo, pr) { + t.Fatalf("minute %d: autoreview must not enqueue a duplicate for a head it already requested", m) + } + if got := f.reviewsPosted(repo, pr); got != 1 { + t.Fatalf("minute %d: expected still one command through the window, got %d", m, got) + } + r := f.round(repo, pr) + if r == nil || r.Phase != PhaseAwaitingRetry { + t.Fatalf("minute %d: round must be parked awaiting retry, got %#v", m, r) + } + if r.RetryAt == nil || !r.RetryAt.Equal(expectedRetry) { + t.Fatalf("minute %d: RetryAt must stay fixed at %s despite in-place edits, got %v", m, expectedRetry, r.RetryAt) + } + } + + // The window passes: exactly one retry fires. + f.clk.set(expectedRetry.Add(time.Second)) + if res := f.pump(); res.Action != "fired" || res.Head != head { + t.Fatalf("the retry must fire once the window passes, got %#v", res) + } + if got := f.reviewsPosted(repo, pr); got != 2 { + t.Fatalf("expected exactly two commands total (fire + one retry), got %d", got) + } + if r := f.round(repo, pr); r == nil || r.Phase != PhaseFired { + t.Fatalf("the retry should leave the round in flight, got %#v", r) + } + + // And never more: keep pumping past the retry — the stale rate-limit comment + // (last edited before the retry) must not re-fire it. + for i := 0; i < 5; i++ { + f.clk.advance(time.Minute) + f.pump() + if got := f.reviewsPosted(repo, pr); got != 2 { + t.Fatalf("no further command may post after the single retry, got %d", got) + } + } +} + +// --- (b) instant completion ack on a first-ever command --------------------- + +// TestReplayInstantAckDoesNotConvergeOrDoubleFire covers the case where +// CodeRabbit answers the very first review command with an immediate "Review +// finished." while the real review is still queued on its side and no review +// object exists yet. That ack must not converge the loop or complete/re-fire the +// round; the real review's findings surface once it lands (exit path 10). +func TestReplayInstantAckDoesNotConvergeOrDoubleFire(t *testing.T) { + base := time.Date(2026, 7, 16, 10, 0, 0, 0, time.UTC) + f := newReplayFixture(t, base) + repo, pr := "o/ha-adjustable-bed", 448 + headSHA := "abcdef1234567890" + f.openPull(repo, pr, headSHA) + f.setCommitDate(headSHA, base.Add(-time.Hour)) + + // First-ever command on the PR — the bot has NO submitted reviews. + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("first pump should fire, got %#v", res) + } + + // The auto-reply completion ack lands 5s later, no review object with it. + f.clk.advance(5 * time.Second) + f.botComment(repo, pr, 100, replayCompletionReply, f.clk.now()) + + f.pump() + if r := f.round(repo, pr); r == nil || r.Phase != PhaseReviewing { + t.Fatalf("an instant ack on a never-reviewed PR must keep the round open (reviewing), got %#v", r) + } + rep, err := f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatalf("feedback: %v", err) + } + if rep.Converged { + t.Fatal("a completion ack with no submitted review must not converge") + } + if f.autoReviewEnqueue(repo, pr) { + t.Fatal("autoreview must not enqueue a duplicate while the review is still running") + } + f.pump() + if got := f.reviewsPosted(repo, pr); got != 1 { + t.Fatalf("the instant ack must not trigger a second command, got %d", got) + } + + // The real review with findings lands minutes later. + f.clk.advance(5 * time.Minute) + f.botReview(repo, pr, 200, headSHA, f.clk.now()) + f.botReviewComment(repo, pr, 201, headSHA, "custom_components/adjustable_bed/bed.py", 42, + "**Potential issue.**\n\nThis dereferences a nil handle and will crash on disconnect.") + + rep, code, err := f.svc.Loop(f.ctx, repo, pr) + if err != nil { + t.Fatalf("loop: %v", err) + } + if code != 10 { + t.Fatalf("the loop must surface the real review's findings (exit 10), got code %d (%#v)", code, rep) + } + if len(rep.Findings) == 0 { + t.Fatalf("expected the landed review's findings, got none") + } + if got := f.reviewsPosted(repo, pr); got != 1 { + t.Fatalf("no re-fire may happen across the whole scenario, got %d commands", got) + } +} + +// --- (c) in-progress summary releases the slot but keeps the round open ------ + +// TestReplayInProgressSummaryReleasesSlotButKeepsRound proves the slot-release +// semantics: CodeRabbit's "Currently processing…" top summary acknowledges the +// command (freeing the global fire slot so another PR can fire) without +// completing the round or converging, and without ever re-firing the first PR. +func TestReplayInProgressSummaryReleasesSlotButKeepsRound(t *testing.T) { + base := time.Date(2026, 7, 16, 11, 0, 0, 0, time.UTC) + f := newReplayFixture(t, base) + repo := "o/ha-adjustable-bed" + pr1, pr2 := 448, 449 + head1SHA := "111111112222aaaa" + head2SHA := "aaaabbbbccccdddd" + f.openPull(repo, pr1, head1SHA) + f.openPull(repo, pr2, head2SHA) + f.setCommitDate(head1SHA, base.Add(-time.Hour)) + f.setCommitDate(head2SHA, base.Add(-time.Hour)) + + f.enqueue(repo, pr1) + f.enqueue(repo, pr2) + if res := f.pump(); res.Action != "fired" || res.PR != pr1 { + t.Fatalf("first pump should fire PR1, got %#v", res) + } + + // CodeRabbit posts the in-progress top summary for PR1 (edited to now). + f.clk.advance(time.Minute) + f.botComment(repo, pr1, 300, replayInProgress, f.clk.now()) + + f.pump() + if r := f.round(repo, pr1); r == nil || r.Phase != PhaseReviewing { + t.Fatalf("the in-progress summary must move PR1 to reviewing, got %#v", r) + } + st, _, _ := f.store.Load(f.ctx) + if st.FireSlot != nil { + t.Fatalf("the in-progress summary must release the fire slot, got %#v", st.FireSlot) + } + rep, err := f.svc.Feedback(f.ctx, repo, pr1) + if err != nil { + t.Fatalf("feedback: %v", err) + } + if rep.Converged { + t.Fatal("a still-processing round must not converge") + } + + // The freed slot lets PR2 fire. + if res := f.pump(); res.Action != "fired" || res.PR != pr2 { + t.Fatalf("with the slot released the next pump should fire PR2, got %#v", res) + } + if f.reviewsPosted(repo, pr1) != 1 || f.reviewsPosted(repo, pr2) != 1 { + t.Fatalf("each PR should have exactly one command, got PR1=%d PR2=%d", f.reviewsPosted(repo, pr1), f.reviewsPosted(repo, pr2)) + } + + // PR1 keeps reviewing and never re-fires while PR2's review runs. + for i := 0; i < 4; i++ { + f.clk.advance(time.Minute) + f.pump() + if r := f.round(repo, pr1); r == nil || r.Phase != PhaseReviewing { + t.Fatalf("PR1 must stay reviewing, got %#v", r) + } + if f.reviewsPosted(repo, pr1) != 1 { + t.Fatalf("PR1 must never get a second command, got %d", f.reviewsPosted(repo, pr1)) + } + } +} + +// --- (d) force-push mid-round supersedes without stale adoption -------------- + +// TestReplayForcePushSupersedesWithoutStaleAdoption checks that when the head +// force-pushes mid-round, the old round is abandoned and a fresh round fires its +// OWN command for the new head — the stale command comment left on the PR for +// the old head must NOT be adopted (which would mark the new head reviewed +// without a review). Total commands: exactly one per head. +func TestReplayForcePushSupersedesWithoutStaleAdoption(t *testing.T) { + base := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC) + f := newReplayFixture(t, base) + repo, pr := "o/ha-adjustable-bed", 448 + head1SHA, head1 := "1111aaaa2222bbbb", "1111aaaa2" + head2SHA, head2 := "3333cccc4444dddd", "3333cccc4" + + f.openPull(repo, pr, head1SHA) + f.setCommitDate(head1SHA, base.Add(-time.Hour)) + + // Fire at H1. + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" || res.Head != head1 { + t.Fatalf("first pump should fire at H1, got %#v", res) + } + cmd1 := f.round(repo, pr).CommandID + // The fired command comment is now on the PR (the fake records only the post, + // so mirror it as the comment CodeRabbit would show for the old head). + f.humanComment(repo, pr, cmd1, f.cfg.ReviewCommand, base) + + // A force-push retargets the PR at H2. Its commit object predates the H1 + // command (a rebase onto an older base), so ONLY the force-push timeline — + // not the commit date — keeps the stale command from being adopted. + forcePushAt := base.Add(10 * time.Minute) + f.setHead(repo, pr, head2SHA) + f.setCommitDate(head2SHA, base.Add(-30*time.Minute)) + f.gh.graphQL = func(query string, _ map[string]any, out any) error { + if strings.Contains(query, "reviewThreads") { + return errors.New("graphql unavailable") + } + payload := `{"repository":{"pullRequest":{"timelineItems":{"nodes":[{"createdAt":"` + forcePushAt.Format(time.RFC3339) + `"}]}}}}` + return json.Unmarshal([]byte(payload), out) + } + + // The daemon notices the head moved and supersedes. + f.clk.set(base.Add(15 * time.Minute)) + if !f.autoReviewEnqueue(repo, pr) { + t.Fatal("the moved head must be enqueued (superseded) by autoreview") + } + if a := f.archived(repo, pr, head1); a == nil || a.Phase != PhaseAbandoned { + t.Fatalf("the H1 round must be archived abandoned, got %#v", a) + } + + // The fresh H2 round fires its own command, not the adopted stale one. + res := f.pump() + if res.Action != "fired" || res.Head != head2 { + t.Fatalf("a fresh H2 round should fire, got %#v", res) + } + if res.Reason == "review command already posted" { + t.Fatal("the stale H1 command must NOT be adopted for the force-pushed head") + } + r := f.round(repo, pr) + if r == nil || r.Head != head2 || r.Phase != PhaseFired { + t.Fatalf("expected a fired H2 round, got %#v", r) + } + if r.CommandID == cmd1 { + t.Fatalf("the H2 round adopted the stale H1 command %d instead of firing its own", cmd1) + } + if got := f.reviewsPosted(repo, pr); got != 2 { + t.Fatalf("expected exactly two commands (one per head), got %d", got) + } +} + +// --- (e) the whole 19×-day sequence ----------------------------------------- + +// TestReplay448DaySequenceFiresThreeTimes scripts the actual incident shape end +// to end: fire → rate-limited (unparseable window → 15m fallback) → window +// expiry → retry → rate-limited again (parseable 40m, same comment edited in +// place) → head moves mid-window → the new head fires once after the block +// clears → a real review completes it. Where the old code posted ~19 commands, +// v3 posts exactly THREE (H1, the H1 retry, H2) and converges. +func TestReplay448DaySequenceFiresThreeTimes(t *testing.T) { + base := time.Date(2026, 7, 16, 8, 0, 0, 0, time.UTC) + f := newReplayFixture(t, base) + repo, pr := "o/ha-adjustable-bed", 448 + head1SHA, head1 := "abcdef1234567890", "abcdef123" + head2SHA, head2 := "fedcba0987654321", "fedcba098" + f.openPull(repo, pr, head1SHA) + f.setCommitDate(head1SHA, base.Add(-time.Hour)) + + // 1. Fire at H1. + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" || res.Head != head1 { + t.Fatalf("first pump should fire at H1, got %#v", res) + } + + // 2. Rate-limited with an unparseable window → fixed 15m fallback. + const rlID = 7001 + f.botComment(repo, pr, rlID, replayUnparseableRateLimit, base) + f.clk.advance(time.Minute) // base+1m + f.pump() + if r := f.round(repo, pr); r == nil || r.Phase != PhaseAwaitingRetry || + r.RetryAt == nil || !r.RetryAt.Equal(base.Add(16*time.Minute)) { + t.Fatalf("an unparseable rate limit must park with the 15m fallback (base+16m), got %#v", r) + } + if f.reviewsPosted(repo, pr) != 1 { + t.Fatalf("still one command after the first rate limit, got %d", f.reviewsPosted(repo, pr)) + } + + // 3. Window expires → one retry fires. + f.clk.set(base.Add(16*time.Minute + time.Second)) + if res := f.pump(); res.Action != "fired" || res.Head != head1 { + t.Fatalf("the retry must fire once the fallback window passes, got %#v", res) + } + if f.reviewsPosted(repo, pr) != 2 { + t.Fatalf("expected two commands after the retry, got %d", f.reviewsPosted(repo, pr)) + } + + // 4. Rate-limited again — CodeRabbit edits the SAME comment in place, now with + // a parseable 40-minute window. + f.clk.set(base.Add(17 * time.Minute)) + f.editComment(repo, pr, rlID, replayFairUsage(40), f.clk.now()) + f.pump() + if r := f.round(repo, pr); r == nil || r.Phase != PhaseAwaitingRetry || + r.RetryAt == nil || !r.RetryAt.Equal(base.Add(57*time.Minute)) { + t.Fatalf("the parseable 40m window must park until base+57m, got %#v", r) + } + + // 5. The head moves mid-window (still inside the 40m block). + f.clk.set(base.Add(30 * time.Minute)) + f.setHead(repo, pr, head2SHA) + f.setCommitDate(head2SHA, base.Add(50*time.Minute)) + if !f.autoReviewEnqueue(repo, pr) { + t.Fatal("the moved head must be superseded by autoreview") + } + if a := f.archived(repo, pr, head1); a == nil || a.Phase != PhaseAbandoned { + t.Fatalf("the H1 round must be archived abandoned after the head move, got %#v", a) + } + // The account block still stands, so the new head cannot fire yet. + if res := f.pump(); res.Action != "blocked" { + t.Fatalf("the account block must prevent the new head from firing early, got %#v", res) + } + if f.reviewsPosted(repo, pr) != 2 { + t.Fatalf("the blocked new head must not post yet, got %d commands", f.reviewsPosted(repo, pr)) + } + + // 6. The block clears → the new head fires exactly once. + f.clk.set(base.Add(57*time.Minute + time.Second)) + if res := f.pump(); res.Action != "fired" || res.Head != head2 { + t.Fatalf("the new head should fire once the block clears, got %#v", res) + } + if f.reviewsPosted(repo, pr) != 3 { + t.Fatalf("expected exactly three commands over the whole day (H1, H1 retry, H2), got %d", f.reviewsPosted(repo, pr)) + } + + // 7. A real review lands and completes the round. + f.clk.advance(time.Minute) + f.botReview(repo, pr, 800, head2SHA, f.clk.now()) + if res := f.pump(); res.Action != "cleared" { + t.Fatalf("the real review should complete the round, got %#v", res) + } + if r := f.round(repo, pr); r == nil || r.Phase != PhaseCompleted || r.Head != head2 { + t.Fatalf("the final round must be completed at H2, got %#v", r) + } + if f.reviewsPosted(repo, pr) != 3 { + t.Fatalf("no command may post after convergence, got %d", f.reviewsPosted(repo, pr)) + } +} diff --git a/internal/crq/service.go b/internal/crq/service.go index f37528a..37215e1 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -43,6 +43,12 @@ type Service struct { gh GitHubAPI store StateStore log Logger + // 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 + // deterministically. It intentionally does NOT reach logging/jitter/token or + // the fake GitHub timestamps, which stay on real time. + now func() time.Time } func NewService(cfg Config, gh GitHubAPI, store StateStore, log Logger) *Service { @@ -54,6 +60,17 @@ func NewService(cfg Config, gh GitHubAPI, store StateStore, log Logger) *Service return &Service{cfg: cfg, cr: cr, gh: gh, store: store, log: log} } +// clock is the service's notion of "now" (UTC) for scheduling decisions: retry +// windows, fire pacing, adoption cutoffs, feedback deadlines. Tests inject s.now +// to drive these deterministically; production leaves it nil and reads the wall +// clock. +func (s *Service) clock() time.Time { + if s.now != nil { + return s.now().UTC() + } + return time.Now().UTC() +} + // warnRateLimited is the requeue reason for a fire that came back account // blocked. It matches the engine's Transition.Reason (both reference the one // dialect constant) and is surfaced via AccountQuota, not the sticky Warn field. @@ -80,7 +97,7 @@ func (s *Service) Enqueue(ctx context.Context, repo string, pr int) (EnqueueResu return result, err } state, err := s.store.Update(ctx, func(st *State) error { - now := time.Now().UTC() + now := s.clock() r := st.Round(repo, pr) if r != nil && r.Head == head { switch r.Phase { @@ -130,7 +147,7 @@ func (s *Service) enqueueBatch(ctx context.Context, items []queueCandidate) erro return nil } state, err := s.store.Update(ctx, func(st *State) error { - now := time.Now().UTC() + now := s.clock() added := 0 for _, it := range items { repo := NormalizeRepo(it.Repo) @@ -174,7 +191,7 @@ type PumpResult struct { // completion, then fires the next eligible round. In DryRun it computes the // same decisions but writes and posts nothing. func (s *Service) Pump(ctx context.Context) (PumpResult, error) { - now := time.Now().UTC() + now := s.clock() st, _, err := s.store.Load(ctx) if err != nil { return PumpResult{}, err @@ -211,7 +228,7 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { } else { return PumpResult{}, err } - now = time.Now().UTC() + now = s.clock() if st.Account.BlockedUntil != nil && st.Account.BlockedUntil.After(now) { return PumpResult{Action: "blocked", Reason: st.Account.BlockedUntil.Format(time.RFC3339)}, nil } @@ -240,7 +257,7 @@ func (s *Service) global(st State, now time.Time) engine.Global { // progressSlotRound observes and progresses the round holding the fire slot. func (s *Service) progressSlotRound(ctx context.Context, slot Round) (PumpResult, error) { - now := time.Now().UTC() + now := s.clock() st, _, err := s.store.Load(ctx) if err != nil { return PumpResult{}, err diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 524f399..d4b2607 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -33,6 +33,17 @@ type fakeGitHub struct { postErrs map[string]error graphQL func(query string, vars map[string]any, out any) error searchPRs []ghapi.SearchPR + // now, when set, timestamps posted comments off the same injected clock the + // service uses, so a fire's recorded FiredAt tracks the fake wall clock the + // replay suite advances. nil falls back to real time (all existing tests). + now func() time.Time +} + +func (f *fakeGitHub) clock() time.Time { + if f.now != nil { + return f.now().UTC() + } + return time.Now().UTC() } func newFakeGitHub() *fakeGitHub { @@ -107,7 +118,8 @@ func (f *fakeGitHub) PostIssueComment(_ context.Context, repo string, pr int, bo } f.commentID++ f.posted = append(f.posted, repo+"#"+strconv.Itoa(pr)+":"+body) - comment := ghapi.IssueComment{ID: f.commentID, Body: body, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + now := f.clock() + comment := ghapi.IssueComment{ID: f.commentID, Body: body, CreatedAt: now, UpdatedAt: now} comment.User.Login = "kristofferR" return comment, nil } From 2c4aefbc7820eab147c15e3174daf5308126b874 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 19:14:57 +0200 Subject: [PATCH 07/20] Fire Codex reviews and gate dynamically on observed Codex activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crq now drives Codex instead of only listening for it. When Codex is in CRQ_REQUIRED_BOTS and does not auto-review the PR, the fire step posts CRQ_CODEX_CMD (default "@codex review") alongside the CodeRabbit command and records it on the round (CodexCommandID); a failed Codex post self-heals on a later pump. Auto-review is detected from evidence — any Codex review or clean summary that no @codex command preceded — and suppresses posting entirely. A Codex that joins a round uninvited (actionable comment, review, or thumbs-up in the round window, or auto-review on the PR) now gates that round's completion dynamically, so both bots converge on the same head even where Codex isn't configured as required. Its usage-limit notice releases the dynamic gate so an exhausted Codex cannot stall rounds it volunteered for; explicitly-required Codex stays bounded by the normal feedback deadline. The @codex command and the usage-limit notice are classified in dialect (EvCodexCommand/EvCodexUsageLimit, corpus-pinned); the decisions live in engine/codex.go (DecideCodexPost + the dynamic gate, table-tested); the end-to-end scenarios in codex_replay_test.go drive all three modes through the real pump: required-no-auto posts exactly once and never on retry, auto-active never posts, and the dynamic gate holds then releases on usage exhaustion. --- README.md | 10 + internal/crq/codex_replay_test.go | 225 ++++++++++++++++++ internal/crq/config.go | 16 +- internal/crq/observe.go | 121 ++++++---- internal/crq/service.go | 93 +++++++- internal/crq/service_test.go | 4 +- internal/crq/state.go | 1 + internal/dialect/codex.go | 9 + internal/dialect/common.go | 5 +- internal/dialect/event.go | 22 +- internal/dialect/golden_test.go | 19 +- .../dialect/testdata/codex/review-command.md | 1 + internal/engine/codex.go | 161 +++++++++++++ internal/engine/completion.go | 57 ++--- internal/engine/engine.go | 13 + internal/engine/engine_test.go | 74 ++++++ internal/engine/fire.go | 10 +- internal/state/state.go | 5 + llms.txt | 5 +- 19 files changed, 750 insertions(+), 101 deletions(-) create mode 100644 internal/crq/codex_replay_test.go create mode 100644 internal/dialect/testdata/codex/review-command.md create mode 100644 internal/engine/codex.go diff --git a/README.md b/README.md index a68dc46..d28c10a 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,7 @@ 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_TZ` | `UTC` | dashboard display timezone (IANA name, e.g. `Europe/Oslo`) | | `CRQ_MIN_INTERVAL` | `90s` | minimum time between fired reviews | @@ -442,6 +443,15 @@ 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. + **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/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go new file mode 100644 index 0000000..a5ad1e8 --- /dev/null +++ b/internal/crq/codex_replay_test.go @@ -0,0 +1,225 @@ +package crq + +import ( + "testing" + "time" + + ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" +) + +// End-to-end scenarios for Codex firing, auto-review detection, and the +// dynamic completion gate. They reuse the replay fixture (injected clock, fake +// GitHub, MemoryStore) so every claim is driven through the real +// Pump/Feedback/observe pipeline. + +const codexLogin = "chatgpt-codex-connector[bot]" + +// codexCleanTada is the Codex clean-summary shape pinned by +// testdata/codex/clean-summary-tada.md, parameterized on the reviewed SHA. +func codexClean(sha string) string { + return "Codex Review: Didn't find any major issues. :tada:\n\n**Reviewed commit:** `" + sha + "`" +} + +const codexUsageLimit = "You have reached your Codex usage limits for code reviews. Limits reset periodically." + +func newCodexReplayFixture(t *testing.T, base time.Time, mutate func(*Config)) *replayFixture { + t.Helper() + clk := newReplayClock(base) + cfg := replayConfig() + cfg.CodexCommand = "@codex review" + 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} +} + +// codexComment appends an issue comment authored by the Codex app. +func (f *replayFixture) codexComment(repo string, pr int, id int64, body string, at time.Time) { + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + c := ghapi.IssueComment{ID: id, Body: body, CreatedAt: at.UTC(), UpdatedAt: at.UTC()} + c.User.Login = codexLogin + key := fakeKey(repo, pr) + f.gh.comments[key] = append(f.gh.comments[key], c) +} + +// codexReview appends a submitted Codex review. +func (f *replayFixture) codexReview(repo string, pr int, id int64, commitSHA string, at time.Time) { + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + r := ghapi.Review{ID: id, CommitID: commitSHA, State: "COMMENTED", SubmittedAt: at.UTC()} + r.User.Login = codexLogin + key := fakeKey(repo, pr) + f.gh.reviews[key] = append(f.gh.reviews[key], r) +} + +// codexPosted counts how many `@codex review` commands crq actually posted. +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 + n := 0 + for _, p := range f.gh.posted { + if p == want { + n++ + } + } + return n +} + +// (i) Codex configured-required with no auto-review: crq posts the Codex +// command exactly once alongside the CodeRabbit command, does NOT repost it on +// the post-rate-limit retry of the same head, and completes the round only +// when BOTH reviews are in. +func TestCodexReplayRequiredFiresOnceAndGatesCompletion(t *testing.T) { + base := time.Date(2026, 7, 17, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, func(cfg *Config) { + cfg.RequiredBots = []string{cfg.Bot, codexLogin} + }) + repo, pr, head := "o/r", 7, "aaaabbbbccccdddd" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected fire, got %+v", res) + } + if got := f.reviewsPosted(repo, pr); got != 1 { + t.Fatalf("coderabbit commands = %d, want 1", got) + } + if got := f.codexPosted(repo, pr); got != 1 { + t.Fatalf("codex commands = %d, want 1", got) + } + if r := f.round(repo, pr); r == nil || r.CodexCommandID == 0 { + t.Fatalf("round must record the codex command, got %+v", r) + } + + // CodeRabbit answers rate-limited; the round parks. After the window one + // retry fires — but the Codex command is NOT reposted (CodexCommandID set). + f.botComment(repo, pr, 9001, replayFairUsage(10), f.clk.now().Add(5*time.Second)) + if res := f.pump(); res.Action != "requeued" { + t.Fatalf("expected requeue, got %+v", res) + } + f.clk.advance(11 * time.Minute) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected retry fire, got %+v", res) + } + if got := f.reviewsPosted(repo, pr); got != 2 { + t.Fatalf("coderabbit commands after retry = %d, want 2", got) + } + if got := f.codexPosted(repo, pr); got != 1 { + t.Fatalf("codex must not be reposted on retry, got %d", got) + } + + // CodeRabbit's review lands: with Codex still outstanding the round must + // stay open (reviewing), not complete. + f.botReview(repo, pr, 501, head, f.clk.now().Add(time.Minute)) + f.clk.advance(2 * time.Minute) + f.pump() + if r := f.round(repo, pr); r == nil || r.Phase != PhaseReviewing { + t.Fatalf("round must wait for codex, got %+v", r) + } + + // Codex's review lands too — now the round completes. + f.codexReview(repo, pr, 502, head, f.clk.now().Add(time.Minute)) + f.clk.advance(2 * time.Minute) + f.pump() + if r := f.round(repo, pr); r == nil || r.Phase != PhaseCompleted { + t.Fatalf("round must complete after both reviews, got %+v", r) + } +} + +// (ii) Codex configured-required but auto-review is active (a Codex review +// exists that no `@codex review` command preceded): crq must never post the +// Codex command; the round still gates on Codex's own review. +func TestCodexReplayAutoActiveSuppressesCommand(t *testing.T) { + base := time.Date(2026, 7, 17, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, func(cfg *Config) { + cfg.RequiredBots = []string{cfg.Bot, codexLogin} + }) + repo, pr := "o/r", 8 + oldHead, head := "1111222233334444", "5555666677778888" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + // History: Codex reviewed the previous head unprompted → auto-review is on. + f.codexReview(repo, pr, 400, oldHead, base.Add(-2*time.Hour)) + + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected fire, got %+v", res) + } + if got := f.codexPosted(repo, pr); got != 0 { + t.Fatalf("auto-active codex must never be commanded, got %d posts", got) + } + + // CodeRabbit finishes; the round still waits for Codex's auto review. + f.botReview(repo, pr, 501, head, f.clk.now().Add(time.Minute)) + f.clk.advance(2 * time.Minute) + f.pump() + if r := f.round(repo, pr); r == nil || r.Phase != PhaseReviewing { + t.Fatalf("round must wait for codex auto review, got %+v", r) + } + if got := f.codexPosted(repo, pr); got != 0 { + t.Fatalf("waiting must not trigger a codex post, got %d", got) + } + + // Codex auto-reviews the head (clean) — round completes. + f.codexComment(repo, pr, 700, codexClean(head[:10]), f.clk.now().Add(time.Minute)) + f.clk.advance(2 * time.Minute) + f.pump() + if r := f.round(repo, pr); r == nil || r.Phase != PhaseCompleted { + t.Fatalf("round must complete on codex's auto review, got %+v", r) + } +} + +// (iii) Codex NOT required: when it joins a round on its own (actionable +// comment mid-round), the dynamic gate holds completion until its review — and +// a usage-limit notice disengages the dynamic gate so the round can complete +// on CodeRabbit alone. +func TestCodexReplayDynamicGateAndUsageLimitEscape(t *testing.T) { + base := time.Date(2026, 7, 17, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, nil) // RequiredBots = coderabbit only + repo, pr, head := "o/r", 9, "aaaabbbbccccdddd" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected fire, got %+v", res) + } + // Not required + not active → no codex command. + if got := f.codexPosted(repo, pr); got != 0 { + t.Fatalf("unrequired codex must not be commanded, got %d", got) + } + + // Codex joins the round with an actionable comment; CodeRabbit finishes. + // The dynamic gate must keep the round open for Codex. + f.codexComment(repo, pr, 700, "There is a bug in `foo.go` line 3: the cutoff is inverted.", f.clk.now().Add(30*time.Second)) + f.botReview(repo, pr, 501, head, f.clk.now().Add(time.Minute)) + f.clk.advance(2 * time.Minute) + f.pump() + if r := f.round(repo, pr); r == nil || r.Phase != PhaseReviewing { + t.Fatalf("dynamic gate must hold for codex, got %+v", r) + } + report, err := f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatal(err) + } + if reviewed, tracked := report.ReviewedBy[codexLogin]; !tracked || reviewed { + t.Fatalf("dynamically-gated codex must appear pending in ReviewedBy: %+v", report.ReviewedBy) + } + + // Codex runs out of quota: the dynamic gate disengages and the round + // completes on CodeRabbit's review alone. + f.codexComment(repo, pr, 701, codexUsageLimit, f.clk.now().Add(time.Minute)) + f.clk.advance(2 * time.Minute) + f.pump() + if r := f.round(repo, pr); r == nil || r.Phase != PhaseCompleted { + t.Fatalf("usage limit must release the dynamic gate, got %+v", r) + } +} diff --git a/internal/crq/config.go b/internal/crq/config.go index 8909be8..60da8aa 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -32,12 +32,15 @@ type Config struct { SkipAuthors map[string]bool // SkipMarker suppresses fleet auto-review when present in a PR body. // Manual `crq loop` remains unaffected so an explicit review can override it. - SkipMarker string - StateRef string - Bot string - RequiredBots []string - FeedbackBots []string - ReviewCommand string + SkipMarker string + StateRef string + Bot string + RequiredBots []string + FeedbackBots []string + ReviewCommand string + // 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. + CodexCommand string RateLimitCommand string RateLimitMarker string CalibrationMarker string @@ -105,6 +108,7 @@ func LoadConfig() (Config, error) { RequiredBots: requiredBots, FeedbackBots: listEnv(env, "CRQ_FEEDBACK_BOTS", strings.Join(unionBots(requiredBots, extraFeedbackBots), ",")), ReviewCommand: stringEnv(env, "CRQ_REVIEW_CMD", "@coderabbitai review"), + CodexCommand: stringEnvAllowEmpty(env, "CRQ_CODEX_CMD", "@codex review"), 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"), diff --git a/internal/crq/observe.go b/internal/crq/observe.go index 1ce04c9..bfd5fdf 100644 --- a/internal/crq/observe.go +++ b/internal/crq/observe.go @@ -64,7 +64,7 @@ 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} + classifier := dialect.Classifier{CodeRabbit: s.cr, Bot: s.cfg.Bot, ReviewCommand: s.cfg.ReviewCommand, CodexCommand: s.cfg.CodexCommand} for _, c := range comments { o.eng.Events = append(o.eng.Events, classifier.Classify(c.User.Login, c.Body, c.ID, c.CreatedAt, c.UpdatedAt)) } @@ -100,13 +100,22 @@ 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). + o.eng.CodexAutoActive = engine.CodexAutoActive(o.eng) + if round != nil { + o.eng.CodexActiveThisRound = engine.CodexActiveThisRound(*round, o.eng) + } + // Adoptable commands are only consulted for a fire-eligible round. if round != nil && round.FireEligible(now) { - cmds, err := s.adoptableCommands(ctx, repo, pr, o.eng, adoptCutoff(*round), pull, comments, reviews) + cr, codex, err := s.reviewCommands(ctx, repo, pr, o.eng, adoptCutoff(*round), pull, comments, reviews) if err != nil { return observation{}, err } - o.eng.Commands = cmds + o.eng.Commands = cr + o.eng.CodexCommands = codex } return o, nil } @@ -140,42 +149,36 @@ func (s *Service) codexRelevant(obs engine.Observation) bool { return false } -// adoptableCommands ports v2's existingReviewCommand: it returns the newest -// review-command comment safe to adopt as an already-posted fire, or none. The -// cutoffs (LastAttemptAt floor, head-commit date, force-push, already-answered) -// are applied here so the engine only picks the newest survivor. -func (s *Service) adoptableCommands(ctx context.Context, repo string, pr int, obs engine.Observation, notBeforeCutoff time.Time, pull ghapi.Pull, comments []ghapi.IssueComment, reviews []ghapi.Review) ([]engine.CommandSeen, error) { - head := obs.Head +// 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) { command := strings.TrimSpace(s.cfg.ReviewCommand) - if command == "" { - return nil, nil - } - // The head-guard and cutoff lookups cost REST/GraphQL calls; skip them - // entirely in the common case of no command comment on the PR at all. - hasCandidate := false - for _, comment := range comments { - if strings.TrimSpace(comment.Body) == command { - hasCandidate = true - break - } - } - if !hasCandidate { - return nil, nil + codexCommand := strings.TrimSpace(s.cfg.CodexCommand) + hasCR := command != "" && hasCommentBody(comments, command) + hasCodex := codexCommand != "" && dialect.HasCodexBot(s.cfg.RequiredBots) && hasCommentBody(comments, codexCommand) + if !hasCR && !hasCodex { + return nil, nil, nil } cutoff := notBeforeCutoff if pull.Head.SHA != "" { - if dialect.ShortOID(pull.Head.SHA) != head { - return nil, nil + if dialect.ShortOID(pull.Head.SHA) != obs.Head { + return nil, nil, nil } - commit, err := s.gh.GetCommit(ctx, repo, pull.Head.SHA) - if err != nil { - if _, ok := ghapi.ThrottleWait(err); ok { - return nil, err + commit, gerr := s.gh.GetCommit(ctx, repo, pull.Head.SHA) + if gerr != nil { + if _, ok := ghapi.ThrottleWait(gerr); ok { + return nil, nil, gerr } // No head-commit cutoff available (unreadable/404 head): skip adoption // rather than wedge the queue — the worst case is posting a command that // already exists, the pre-adoption behavior. - return nil, nil + return nil, nil, nil } if commit.Committer.Date.After(cutoff) { cutoff = commit.Committer.Date @@ -187,6 +190,43 @@ func (s *Service) adoptableCommands(ctx context.Context, repo string, pr int, ob if fp := s.headForcePushCutoff(ctx, repo, pr); fp.After(cutoff) { cutoff = fp } + if hasCR { + cr = s.adoptableCR(obs, cutoff, command, comments, reviews) + } + if hasCodex { + codex = newestCommandSince(codexCommand, cutoff, comments) + } + return cr, codex, nil +} + +// adoptableCR returns the newest CodeRabbit command comment safe to adopt as an +// already-posted fire, or none. A command the bot already answered with a review +// or a completion reply belongs to a finished round for an earlier head and is +// never adopted (adopting it would mark the new head fired without reviewing it). +func (s *Service) adoptableCR(obs engine.Observation, cutoff time.Time, command string, comments []ghapi.IssueComment, reviews []ghapi.Review) []engine.CommandSeen { + best := newestCommandSince(command, cutoff, comments) + if len(best) == 0 { + return nil + } + bestAt := best[0].CreatedAt + if bestAt.IsZero() { + bestAt = best[0].UpdatedAt + } + for _, review := range reviews { + if s.isConfiguredBot(review.User.Login) && !review.SubmittedAt.Before(bestAt) { + return nil + } + } + if engine.CommandHasCompletionReply(obs, s.policy(), best[0].ID) { + return nil + } + return best +} + +// newestCommandSince returns the newest comment whose trimmed body is command +// and which is not older than cutoff, as a single-element CommandSeen slice +// (empty when none). +func newestCommandSince(command string, cutoff time.Time, comments []ghapi.IssueComment) []engine.CommandSeen { var best ghapi.IssueComment var bestAt time.Time ok := false @@ -208,20 +248,19 @@ func (s *Service) adoptableCommands(ctx context.Context, repo string, pr int, ob } } if !ok { - return nil, nil + return nil } - // A command the bot has already answered with a review belongs to a completed - // round for an earlier head; adopting it would mark the new head fired without - // reviewing it. Skip adoption — the worst case is a duplicate command. - for _, review := range reviews { - if s.isConfiguredBot(review.User.Login) && !review.SubmittedAt.Before(bestAt) { - return nil, nil + return []engine.CommandSeen{{ID: best.ID, CreatedAt: best.CreatedAt, UpdatedAt: best.UpdatedAt}} +} + +// hasCommentBody reports whether any comment's trimmed body equals body. +func hasCommentBody(comments []ghapi.IssueComment, body string) bool { + for _, comment := range comments { + if strings.TrimSpace(comment.Body) == body { + return true } } - if engine.CommandHasCompletionReply(obs, s.policy(), best.ID) { - return nil, nil - } - return []engine.CommandSeen{{ID: best.ID, CreatedAt: best.CreatedAt, UpdatedAt: best.UpdatedAt}}, nil + return false } // headForcePushCutoff returns when the PR head was last force-pushed, zero if diff --git a/internal/crq/service.go b/internal/crq/service.go index 37215e1..8a78fae 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -266,6 +266,7 @@ func (s *Service) progressSlotRound(ctx context.Context, slot Round) (PumpResult if err != nil { return PumpResult{}, err } + s.selfHealCodex(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 @@ -398,6 +399,7 @@ func (s *Service) sweepReviewing(ctx context.Context, st State, now time.Time) ( } return st, nil } + s.selfHealCodex(ctx, *target, obs.eng, now) tr := engine.Progress(*target, st.Account, obs.eng, now, s.policy()) if tr.Outcome == engine.KeepWaiting { return st, nil @@ -433,9 +435,9 @@ func (s *Service) applyFire(ctx context.Context, round Round, obs engine.Observa 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, now) + return s.fireRound(ctx, round, obs, false, d.AdoptCommandID, d.AdoptAt, d.Reason, d.PostCodex, now) case engine.FirePost: - return s.fireRound(ctx, round, obs, true, 0, time.Time{}, "", now) + return s.fireRound(ctx, round, obs, true, 0, time.Time{}, "", d.PostCodex, now) default: // FireNo return PumpResult{Action: mapFireNo(d.Reason), Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: d.Reason}, nil } @@ -538,8 +540,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. -func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observation, post bool, adoptID int64, adoptAt time.Time, reason string, now time.Time) (PumpResult, error) { +// 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) { 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 @@ -589,6 +593,9 @@ 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) + } return PumpResult{Action: "fired", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil } @@ -643,7 +650,13 @@ func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observa if firedAt.IsZero() { firedAt = now } - updated, err := s.recordFire(ctx, round, token, comment.ID, 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) + } + updated, err := s.recordFire(ctx, round, token, comment.ID, codexID, firedAt, now) if err != nil { if errors.Is(err, ErrNoChange) { return PumpResult{Action: "lost_race"}, nil @@ -658,8 +671,10 @@ func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observa } // 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. -func (s *Service) recordFire(ctx context.Context, round Round, token string, commandID int64, firedAt, now time.Time) (State, error) { +// 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) { record := func(c context.Context) (State, bool, error) { recorded := false st, err := s.store.Update(c, func(st *State) error { @@ -671,6 +686,9 @@ 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 + } lf := firedAt st.LastFired = &lf dl := firedAt.Add(s.cfg.FeedbackWaitTimeout) @@ -697,6 +715,67 @@ 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 +// folds the returned id into recordFire's write. +func (s *Service) postCodexReviewComment(ctx context.Context, round Round) int64 { + comment, err := s.gh.PostIssueComment(ctx, round.Repo, round.PR, s.cfg.CodexCommand) + 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) + } + return 0 + } + if s.log != nil { + s.log.Printf("fire %s@%s (posted %s)", QueueKey(round.Repo, round.PR), round.Head, strings.TrimSpace(s.cfg.CodexCommand)) + } + return comment.ID +} + +// 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 := s.postCodexReviewComment(ctx, round) + if codexID == 0 { + return + } + updated, err := s.store.Update(ctx, func(st *State) error { + r := st.Round(round.Repo, round.PR) + if r == nil || r.Head != round.Head || r.CodexCommandID != 0 { + return ErrNoChange + } + r.CodexCommandID = codexID + 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) + } + return + } + s.sync(ctx, updated) +} + +// 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 { + return + } + commandPresent := engine.CodexCommandSince(obs, round.FiredAt.UTC()) + if !engine.DecideCodexPost(round, obs, s.policy(), commandPresent) { + return + } + s.fireCodexReview(ctx, round) +} + func (s *Service) Cancel(ctx context.Context, repo string, pr int) error { repo = NormalizeRepo(repo) state, err := s.store.Update(ctx, func(st *State) error { diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index d4b2607..57a1e41 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -712,7 +712,7 @@ func TestAdoptableCommandsRequiresExpectedHead(t *testing.T) { comments, _ := gh.ListIssueComments(ctx, "owner/repo", 12) reviews, _ := gh.ListReviews(ctx, "owner/repo", 12) - cmds, err := service.adoptableCommands(ctx, "owner/repo", 12, engine.Observation{Head: "abcdef123", Open: true}, time.Time{}, pull, comments, reviews) + cmds, _, err := service.reviewCommands(ctx, "owner/repo", 12, engine.Observation{Head: "abcdef123", Open: true}, time.Time{}, pull, comments, reviews) if err != nil || len(cmds) != 0 { t.Fatalf("must not adopt a review command after the PR head changed, cmds=%v err=%v", cmds, err) } @@ -1288,7 +1288,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, time.Now().UTC(), time.Now().UTC()) + _, err := svc.recordFire(context.Background(), round, "token", 1, 0, 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) } diff --git a/internal/crq/state.go b/internal/crq/state.go index 4810269..8f114cc 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -84,6 +84,7 @@ func (s *Service) policy() engine.Policy { return 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, diff --git a/internal/dialect/codex.go b/internal/dialect/codex.go index 7aee9f5..ea276ba 100644 --- a/internal/dialect/codex.go +++ b/internal/dialect/codex.go @@ -43,6 +43,15 @@ func IsCodexNoActionReviewCompletion(text string) bool { CodexReviewedCommitSHA(text) != "" } +// IsCodexUsageLimit reports whether a Codex comment is its usage-limit +// exhaustion notice ("You have reached your Codex usage limits for code +// reviews"). It is non-actionable like Codex's other acks, but distinct: it +// means Codex cannot produce a review this round, which the dynamic completion +// gate uses to avoid waiting on a Codex that will never finish. +func IsCodexUsageLimit(text string) bool { + return strings.Contains(NormalizeReviewText(text), "usage limits for code reviews") +} + // CodexReviewedCommitSHA extracts the commit hash Codex says it reviewed, // or "" when the comment carries no such line. func CodexReviewedCommitSHA(text string) string { diff --git a/internal/dialect/common.go b/internal/dialect/common.go index 3220a14..0545329 100644 --- a/internal/dialect/common.go +++ b/internal/dialect/common.go @@ -121,10 +121,10 @@ func LooksLikePath(summary string) bool { } func IsNonActionableText(text string) bool { - text = NormalizeReviewText(text) - if IsCodexNoActionReviewCompletion(text) { + if IsCodexNoActionReviewCompletion(text) || IsCodexUsageLimit(text) { return true } + text = NormalizeReviewText(text) nonActionable := []string{ "lgtm", "also applies to:", @@ -140,7 +140,6 @@ func IsNonActionableText(text string) bool { "confirm intended ux", "worth confirming", "skipped: comment is from another github bot", - "you have reached your codex usage limits for code reviews", } for _, phrase := range nonActionable { if strings.Contains(text, phrase) { diff --git a/internal/dialect/event.go b/internal/dialect/event.go index 7cddd30..eb5961e 100644 --- a/internal/dialect/event.go +++ b/internal/dialect/event.go @@ -15,6 +15,7 @@ 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 EvCompletion // "Review finished." auto-reply (and not rate-limited) EvRateLimited // CodeRabbit account-quota notice EvPaused // "Reviews paused" auto-pause notice @@ -23,7 +24,8 @@ const ( EvAlreadyReviewed // "does not re-review already reviewed commits" claim EvNoAction // CodeRabbit clean-review summary (no actionable comments) EvCodexClean // Codex clean-summary issue comment - EvCodexNotice // non-actionable Codex notice (usage limits, acks) + EvCodexUsageLimit // Codex "usage limits for code reviews" exhaustion notice + EvCodexNotice // other non-actionable Codex notice (acks, lgtm) ) // BotEvent is one classified issue comment. CreatedAt orders command↔reply @@ -61,11 +63,13 @@ 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. +// CodeRabbit login; ReviewCommand is the exact trigger comment body; CodexCommand +// is the exact Codex trigger comment body ("" disables Codex-command matching). type Classifier struct { CodeRabbit CodeRabbit Bot string ReviewCommand string + CodexCommand string } // Classify maps one issue comment to its BotEvent. Unrecognized comments @@ -79,11 +83,21 @@ 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 + } if IsCodexBot(author) { - if IsCodexNoActionReviewCompletion(body) { + switch { + case IsCodexNoActionReviewCompletion(body): ev.Kind = EvCodexClean ev.SHA = CodexReviewedCommitSHA(body) - } else if IsNonActionableText(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 } return ev diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index 0cb5940..5965152 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -38,9 +38,14 @@ func TestGoldenClassification(t *testing.T) { autoReply bool noAction bool codexClean bool + codexUsageLimit bool nonActionable bool availableIn time.Duration // 0 = no window must parse reviewedSHA string + // author + wantKind pin Classifier.Classify's dominant kind for the file; + // wantKind == EvOther (the zero value) skips the Classify assertion. + author string + wantKind EventKind }{ {file: "coderabbit/rate-limit-fair-usage.md", rateLimited: true, autoReply: true, availableIn: 48 * time.Minute}, // Contains the "does not re-review" boilerplate in its help section — @@ -53,11 +58,13 @@ func TestGoldenClassification(t *testing.T) { {file: "coderabbit/no-actionable-comments.md", noAction: true}, {file: "coderabbit/already-reviewed.md", alreadyDone: true, autoReply: true}, {file: "coderabbit/completion-reply.md", completionReply: true, autoReply: true}, - {file: "codex/clean-summary-legacy.md", codexClean: true, noAction: true, nonActionable: true}, - {file: "codex/clean-summary-tada.md", codexClean: true, noAction: true, nonActionable: true, reviewedSHA: "4d9e8bca82"}, - {file: "codex/usage-limit.md", nonActionable: true}, + {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/review-command.md", author: "kristofferR", wantKind: EvCodexCommand}, } 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"} for _, tc := range cases { t.Run(tc.file, func(t *testing.T) { body := readGolden(t, tc.file) @@ -75,6 +82,7 @@ func TestGoldenClassification(t *testing.T) { {"IsAutoReply", goldenCR.IsAutoReply(body), tc.autoReply}, {"IsNoActionReviewCompletion", IsNoActionReviewCompletion(body), tc.noAction}, {"IsCodexNoActionReviewCompletion", IsCodexNoActionReviewCompletion(body), tc.codexClean}, + {"IsCodexUsageLimit", IsCodexUsageLimit(body), tc.codexUsageLimit}, {"IsNonActionableText", IsNonActionableText(body), tc.nonActionable}, } for _, c := range checks { @@ -93,6 +101,11 @@ func TestGoldenClassification(t *testing.T) { if got := CodexReviewedCommitSHA(body); got != tc.reviewedSHA { t.Errorf("CodexReviewedCommitSHA = %q, want %q", got, tc.reviewedSHA) } + if tc.wantKind != EvOther { + if got := classifier.Classify(tc.author, body, 1, base, base).Kind; got != tc.wantKind { + t.Errorf("Classify kind = %v, want %v", got, tc.wantKind) + } + } }) } } diff --git a/internal/dialect/testdata/codex/review-command.md b/internal/dialect/testdata/codex/review-command.md new file mode 100644 index 0000000..3f8e4b4 --- /dev/null +++ b/internal/dialect/testdata/codex/review-command.md @@ -0,0 +1 @@ +@codex review diff --git a/internal/engine/codex.go b/internal/engine/codex.go new file mode 100644 index 0000000..e580f2a --- /dev/null +++ b/internal/engine/codex.go @@ -0,0 +1,161 @@ +package engine + +import ( + "strings" + "time" + + "github.com/kristofferR/coderabbit-queue/internal/dialect" + "github.com/kristofferR/coderabbit-queue/internal/state" +) + +// codexBot is the Codex GitHub app login. The dialect owns the normalization +// (IsCodexBot/HasCodexBot); this is the canonical key the engine flips in +// ReviewedBy when Codex gates a round. +const codexBot = "chatgpt-codex-connector[bot]" + +// 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{} +} + +// 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 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 +} + +// 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 +} + +// codexReviewedHead reports whether Codex has a submitted review whose commit +// prefixes the observed head — the "Codex already reviewed this head" fire guard. +func codexReviewedHead(obs Observation) bool { + for _, review := range obs.Reviews { + if dialect.IsCodexBot(review.Bot) && obs.Head != "" && review.Commit != "" && strings.HasPrefix(review.Commit, obs.Head) { + return true + } + } + return false +} + +// 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. +func CodexActiveThisRound(r state.Round, obs Observation) bool { + cutoff := roundCutoff(r) + return codexReviewedRound(r, obs, cutoff) || codexCommentedRound(obs, cutoff) || obs.CodexThumbsUp +} + +// CodexAutoActive reports whether Codex reviews this PR on its own: it has a +// submitted review or a clean summary that no `@codex review` command preceded. +// When true, crq must never post the Codex command — Codex reviews unprompted. +func CodexAutoActive(obs Observation) bool { + firstCmd, ok := firstCodexCommand(obs) + notPreceded := func(at time.Time) bool { return !ok || at.Before(firstCmd) } + for _, review := range obs.Reviews { + if dialect.IsCodexBot(review.Bot) && !review.SubmittedAt.IsZero() && notPreceded(review.SubmittedAt) { + return true + } + } + for _, ev := range obs.Events { + if ev.Kind == dialect.EvCodexClean && notPreceded(ev.PairTime()) { + return true + } + } + return false +} + +// firstCodexCommand returns the earliest `@codex review` command time, and +// whether any exists. +func firstCodexCommand(obs Observation) (time.Time, bool) { + var first time.Time + ok := false + for _, ev := range obs.Events { + if ev.Kind != dialect.EvCodexCommand { + continue + } + if at := ev.PairTime(); !ok || at.Before(first) { + first, ok = at, true + } + } + return first, ok +} + +// 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. +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 +} + +// 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 +} + +// 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. +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) +} diff --git a/internal/engine/completion.go b/internal/engine/completion.go index e681dea..f8cfe64 100644 --- a/internal/engine/completion.go +++ b/internal/engine/completion.go @@ -46,6 +46,17 @@ 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 + } + // 1. Submitted reviews. for _, review := range obs.Reviews { if r.Head != "" { @@ -78,6 +89,15 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { } } + // A Codex thumbs-up stands in for its review whenever Codex gates the round. + // codexInactiveOrThumbed 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. + if obs.CodexThumbsUp && needsBotReview(reviewedBy, codexBot) { + markReviewed(reviewedBy, codexBot) + } + // 3. CodeRabbit clean-review summary, Codex-gated. if r.FiredAt != nil { for _, ev := range obs.Events { @@ -104,43 +124,16 @@ func Completion(r state.Round, obs Observation, p Policy) CompletionStatus { // 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 { - codexActive := dialect.HasCodexBot(p.RequiredBots) - codexReviewed := reviewedByBot(reviewedBy, "chatgpt-codex-connector[bot]") - for _, review := range obs.Reviews { - if !dialect.IsCodexBot(review.Bot) { - continue - } - if r.Head != "" && review.Commit != "" && strings.HasPrefix(review.Commit, r.Head) { - codexActive, codexReviewed = true, true - break - } - if review.Commit == "" && !review.SubmittedAt.IsZero() && notBefore(review.SubmittedAt, cutoff) { - codexActive, codexReviewed = true, true - break - } - } - if codexReviewed { + if reviewedByBot(reviewedBy, codexBot) || codexReviewedRound(r, obs, cutoff) { return true } - if !codexActive { - for _, ev := range obs.Events { - // Any potentially-actionable Codex comment in this round means Codex - // is participating (its notices — usage limits, acks — do not). - if dialect.IsCodexBot(ev.Bot) && ev.Kind == dialect.EvOther && notBefore(ev.ObservedTime(), cutoff) { - codexActive = true - break - } - if ev.Kind == dialect.EvCodexClean && notBefore(ev.ObservedTime(), cutoff) { - codexActive = true - break - } - } - } - if !codexActive { + // 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, "chatgpt-codex-connector[bot]") + markReviewed(reviewedBy, codexBot) return true } return false diff --git a/internal/engine/engine.go b/internal/engine/engine.go index c559b4e..287a293 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -16,6 +16,7 @@ import ( 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) MinInterval time.Duration // global pacing between fires InflightTimeout time.Duration // fired round with no bot response at all @@ -63,11 +64,23 @@ 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 } // notBefore mirrors v2: GitHub timestamps are second-granular, so a bot diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 977e76b..d525367 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -329,6 +329,80 @@ func TestCommandHasCompletionReply(t *testing.T) { } } +// TestDecideCodexPost is the PostCodex 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]", "chatgpt-codex-connector[bot]"}, + CodexCommand: "@codex review", + } + head := "abcdef123" + base := Observation{Head: head, Open: true} + codexReviewHead := ReviewSeen{Bot: "chatgpt-codex-connector[bot]", Commit: "abcdef1234567890", SubmittedAt: t0} + + cases := []struct { + name string + round state.Round + obs Observation + policy Policy + commandPresent bool + 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: "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}, + {name: "codex command empty", round: state.Round{Head: head}, obs: base, policy: Policy{RequiredBots: codexReq.RequiredBots}, want: false}, + {name: "already asked this round", round: state.Round{Head: head, CodexCommandID: 42}, obs: base, policy: codexReq, want: false}, + } + 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 { + t.Fatalf("DecideCodexPost = %v, want %v", got, tc.want) + } + }) + } +} + +// TestDynamicCodexGate covers the dynamic completion gate: an observed-active +// Codex gates a round it isn't configured-required for, a usage-limit notice +// disengages that dynamic gate, and a configured-required Codex is left gating +// regardless of the usage limit. +func TestDynamicCodexGate(t *testing.T) { + r := firedRound(t, "abcdef123") + cutoff := r.FiredAt.UTC() + crReview := ReviewSeen{Bot: "coderabbitai[bot]", Commit: "abcdef1234567890", SubmittedAt: cutoff.Add(time.Minute)} + codexReview := ReviewSeen{Bot: "chatgpt-codex-connector[bot]", Commit: "abcdef1234567890", SubmittedAt: cutoff.Add(time.Minute)} + usageLimit := dialect.BotEvent{Kind: dialect.EvCodexUsageLimit, Bot: "chatgpt-codex-connector[bot]", 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 { + 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 { + 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 { + 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. + gated := policy + gated.RequiredBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"} + if got := Completion(r, limited, gated); got.Done { + t.Fatalf("a usage limit must NOT disengage the configured-required Codex gate: %+v", got) + } +} + // TestCodexGatesCleanSummary ports the codexInactiveOrThumbed rules. func TestCodexGatesCleanSummary(t *testing.T) { r := firedRound(t, "abcdef123") diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 1245b54..2c2742b 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -27,6 +27,9 @@ type FireDecision struct { // Adopt fields identify the existing command comment (FireAdopt). AdoptCommandID int64 AdoptAt time.Time + // PostCodex asks the apply layer to post the Codex review command alongside + // the CodeRabbit one (FirePost/FireAdopt). See DecideCodexPost. + PostCodex bool } // Global is the cross-PR state a fire decision needs. @@ -73,6 +76,9 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic return FireDecision{Verdict: FireDedupe, Reason: "bot already reviewed head"} } } + // 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) // Adopt the newest already-posted command instead of posting a duplicate. // observe() has already applied the adoption cutoffs (LastAttemptAt, // force-push, already-answered). @@ -88,7 +94,7 @@ 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} + return FireDecision{Verdict: FireAdopt, Reason: "review command already posted", AdoptCommandID: newest.ID, AdoptAt: at, PostCodex: postCodex} } - return FireDecision{Verdict: FirePost} + return FireDecision{Verdict: FirePost, PostCodex: postCodex} } diff --git a/internal/state/state.go b/internal/state/state.go index 14a12e4..c3af294 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -54,6 +54,11 @@ type Round struct { // adopted). It anchors completion-reply pairing to this round. CommandID int64 `json:"command_id,omitempty"` + // 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. + CodexCommandID int64 `json:"codex_command_id,omitempty"` + // RetryAt is the earliest time this head may fire again (awaiting_retry). RetryAt *time.Time `json:"retry_at,omitempty"` diff --git a/llms.txt b/llms.txt index de04c08..725bc27 100644 --- a/llms.txt +++ b/llms.txt @@ -61,7 +61,10 @@ 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 a bot reports +`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 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. Only after every `CRQ_REQUIRED_BOTS` entry is true From 2d988ff358229bb969c3da4095fd6c87529ad74c Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 19:24:48 +0200 Subject: [PATCH 08/20] Address round-one review findings from the self-hosted loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfooding crq v3 on its own PR (crq loop → exit 10, drain-first) surfaced twelve findings; nine were valid and are fixed here: - A dry-run Pump no longer calls RefreshQuota — the calibration probe it can post is a side effect dry-run promises not to have. - The detached recordFire retry derives from context.WithoutCancel(ctx) instead of context.Background(), keeping parent values. - ParseReviewBodyFindings moves to dialect/common.go — it dispatches across both dialects and never belonged to coderabbit.go. - engine/findings.go compares commits via dialect.SHAPrefixMatch, so a 10-char Codex commit matches a 9-char head instead of silently failing the one-directional prefix test. - The dashboard renders the recorded remaining-review count, hashes with SHA-256, and AGENTS.md's diagram fence declares a language. - The round-view helpers (WaitingHead, RoundWaitDeadline, ContainsActive, FiredMarker, AccountBlockedUntil) move onto state.State where they belong; crq keeps only genuinely crq-shaped adapters. - The replay suite loads bot messages from the dialect corpus instead of duplicating the wording (new corpus file: rate-limit-no-window.md). Declined with reasons on the PR threads: the duplicate-type claim (the suite compiles; phantom finding), merging the pullHead preflight into observe (the cheap head check deliberately gates the expensive fetch behind quota/pacing), and keeping abandoned rounds in Rounds (archiving on terminal PR events is the designed semantics; the live already-reviewed check covers re-fire safety). --- AGENTS.md | 2 +- internal/crq/codex_replay_test.go | 2 +- internal/crq/feedback.go | 8 +-- internal/crq/feedback_test.go | 12 ++-- internal/crq/replay_test.go | 49 ++++++++------- internal/crq/service.go | 20 +++--- internal/crq/service_test.go | 16 ++--- internal/crq/state.go | 61 ------------------- internal/dialect/coderabbit.go | 15 ----- internal/dialect/common.go | 15 +++++ internal/dialect/golden_test.go | 2 + .../coderabbit/rate-limit-no-window.md | 2 + internal/engine/findings.go | 6 +- internal/state/dashboard.go | 7 ++- internal/state/state.go | 60 ++++++++++++++++++ 15 files changed, 145 insertions(+), 132 deletions(-) create mode 100644 internal/dialect/testdata/coderabbit/rate-limit-no-window.md diff --git a/AGENTS.md b/AGENTS.md index 2fa3f20..6aec2b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ engine, crq). "rate limit" as literal text lives only in `gh` and `dialect`. ## State: one Round per PR, never deleted -``` +```text queued → reserved → fired → reviewing → completed ↑ │ │ │ └─────────┘ ├─────────┴→ awaiting_retry ─→ (fire-eligible once RetryAt passes) diff --git a/internal/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go index a5ad1e8..42675b8 100644 --- a/internal/crq/codex_replay_test.go +++ b/internal/crq/codex_replay_test.go @@ -101,7 +101,7 @@ func TestCodexReplayRequiredFiresOnceAndGatesCompletion(t *testing.T) { // CodeRabbit answers rate-limited; the round parks. After the window one // retry fires — but the Codex command is NOT reposted (CodexCommandID set). - f.botComment(repo, pr, 9001, replayFairUsage(10), f.clk.now().Add(5*time.Second)) + f.botComment(repo, pr, 9001, replayFairUsage(t, 10), f.clk.now().Add(5*time.Second)) if res := f.pump(); res.Action != "requeued" { t.Fatalf("expected requeue, got %+v", res) } diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index c08b384..ec94808 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -259,7 +259,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport if loadErr != nil { return FeedbackReport{}, 1, loadErr } - if waitingHead(&state, repo, pr) != head { + if state.WaitingHead(repo, pr) != head { for { report, feedbackErr := s.Feedback(ctx, repo, pr) if feedbackErr != nil { @@ -383,7 +383,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport var blockedUntil *time.Time now := s.clock() if st, _, lerr := s.store.Load(ctx); lerr == nil { - if until, ok := accountBlockedUntil(&st, repo, pr, head, now); ok { + if until, ok := st.AccountBlockedUntil(repo, pr, head, now); ok { blockedUntil = &until } } @@ -433,7 +433,7 @@ func (s *Service) ensureWaitDeadline(ctx context.Context, repo string, pr int, h if err != nil { return time.Time{}, err } - if dl, ok := roundWaitDeadline(&st, repo, pr, head); ok { + if dl, ok := st.RoundWaitDeadline(repo, pr, head); ok { return dl, nil } changed := false @@ -459,7 +459,7 @@ func (s *Service) ensureWaitDeadline(ctx context.Context, repo string, pr int, h if changed { s.sync(ctx, updated) } - if dl, ok := roundWaitDeadline(&updated, repo, pr, head); ok { + if dl, ok := updated.RoundWaitDeadline(repo, pr, head); ok { return dl, nil } // The round is no longer a wait (completed/none): synthesize a transient diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index ff3076e..6bfbc2b 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -1360,11 +1360,11 @@ func TestAccountBlockedUntilHonorsPerHeadCooldownAfterGlobalBlockClears(t *testi cooldownUntil := now.Add(15 * time.Minute) st := parkedRound(t, "owner/repo", 947, "168df6ae6", cooldownUntil, now) - got, ok := accountBlockedUntil(&st, "owner/repo", 947, "168df6ae6", now) + got, ok := st.AccountBlockedUntil("owner/repo", 947, "168df6ae6", now) if !ok || !got.Equal(cooldownUntil) { t.Fatalf("matching head retry window must keep the feedback wait blocked: got %v, ok=%v", got, ok) } - if _, ok := accountBlockedUntil(&st, "owner/repo", 947, "different", now); ok { + if _, ok := st.AccountBlockedUntil("owner/repo", 947, "different", now); ok { t.Fatal("a retry window for an older head must not block the current head") } } @@ -1376,7 +1376,7 @@ func TestAccountBlockedUntilUsesLatestAccountOrHeadWindow(t *testing.T) { st := parkedRound(t, "owner/repo", 947, "168df6ae6", cooldownUntil, now) st.Account.BlockedUntil = &accountUntil - got, ok := accountBlockedUntil(&st, "owner/repo", 947, "168df6ae6", now) + got, ok := st.AccountBlockedUntil("owner/repo", 947, "168df6ae6", now) if !ok || !got.Equal(accountUntil) { t.Fatalf("latest blocking window must win: got %v, ok=%v", got, ok) } @@ -1526,10 +1526,10 @@ func TestLoopResumesAwaitingFeedbackWithoutRefiring(t *testing.T) { if err != nil { t.Fatal(err) } - if waitingHead(&state, "owner/repo", 12) != "" { + if state.WaitingHead("owner/repo", 12) != "" { t.Fatalf("feedback wait should clear after findings are collected") } - if firedMarker(&state, "owner/repo", 12) != "abcdef123" { + if state.FiredMarker("owner/repo", 12) != "abcdef123" { t.Fatalf("fired marker should remain for dedupe after collection") } } @@ -1835,7 +1835,7 @@ func TestLoopUsesPersistedFeedbackDeadline(t *testing.T) { if err != nil { t.Fatal(err) } - if waitingHead(&state, "owner/repo", 12) != "" { + if state.WaitingHead("owner/repo", 12) != "" { t.Fatalf("expired feedback wait should clear after timeout") } } diff --git a/internal/crq/replay_test.go b/internal/crq/replay_test.go index a7a22c9..da639b4 100644 --- a/internal/crq/replay_test.go +++ b/internal/crq/replay_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "os" + "path/filepath" "strings" "sync" "testing" @@ -64,24 +66,27 @@ func replayConfig() Config { return cfg } -// CodeRabbit message shapes, pinned to the golden corpus in -// internal/dialect/testdata/coderabbit. Kept verbatim so a bot rewording that -// breaks classification breaks these replays too. -const ( - replayCompletionReply = "\nReview finished." - replayInProgress = "\n\nCurrently processing new changes in this PR. This may take a few minutes, please wait...\n\n
\nCommits\nReviewing files that changed from the base of the PR and between abc1234 and def5678.\n
" - // replayUnparseableRateLimit is a rate-limit notice with no "available in" - // window, so ParseAvailableIn returns nil and the engine falls back to the - // fixed RateLimitFallback (getting THIS wrong is what let #448 re-fire every - // couple of minutes instead of honouring the window). - replayUnparseableRateLimit = "\nYou are rate limited by coderabbit.ai. Please wait before requesting another review." -) +// corpusMessage loads a bot message from the dialect golden corpus, so the +// replays and the classifiers share ONE source of truth for bot wording — a +// rewording that breaks classification breaks these replays too. +func corpusMessage(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "dialect", "testdata", filepath.FromSlash(name))) + if err != nil { + t.Fatalf("corpus message: %v", err) + } + return strings.TrimRight(string(data), "\n") +} -// replayFairUsage renders the Fair Usage rate-limit reply carrying a parseable -// "available in N minutes" window (real message shape from -// testdata/coderabbit/rate-limit-fair-usage.md). -func replayFairUsage(minutes int) string { - return fmt.Sprintf("\nYou're currently rate limited under our Fair Usage Limits Policy. Your next review will be available in %d minutes.", minutes) +// replayFairUsage renders the Fair Usage rate-limit reply with a chosen +// "available in N minutes" window, templated from the corpus message. +func replayFairUsage(t *testing.T, minutes int) string { + msg := corpusMessage(t, "coderabbit/rate-limit-fair-usage.md") + out := strings.Replace(msg, "48 minutes", fmt.Sprintf("%d minutes", minutes), 1) + if out == msg { + t.Fatal("fair-usage corpus message no longer carries the 48-minute window") + } + return out } // replayFixture bundles the harness for one scenario. @@ -298,7 +303,7 @@ func TestReplayRateLimitBounceFiresOncePerWindow(t *testing.T) { // CodeRabbit answers with the Fair Usage rate limit, window 40 minutes. const rlID = 9001 - f.botComment(repo, pr, rlID, replayFairUsage(40), base) + f.botComment(repo, pr, rlID, replayFairUsage(t, 40), base) expectedRetry := base.Add(40 * time.Minute) // parsed from the comment's UpdatedAt (base) // Simulate the daemon for the full window: advance 60s each step, editing the @@ -371,7 +376,7 @@ func TestReplayInstantAckDoesNotConvergeOrDoubleFire(t *testing.T) { // The auto-reply completion ack lands 5s later, no review object with it. f.clk.advance(5 * time.Second) - f.botComment(repo, pr, 100, replayCompletionReply, f.clk.now()) + f.botComment(repo, pr, 100, corpusMessage(t, "coderabbit/completion-reply.md"), f.clk.now()) f.pump() if r := f.round(repo, pr); r == nil || r.Phase != PhaseReviewing { @@ -439,7 +444,7 @@ func TestReplayInProgressSummaryReleasesSlotButKeepsRound(t *testing.T) { // CodeRabbit posts the in-progress top summary for PR1 (edited to now). f.clk.advance(time.Minute) - f.botComment(repo, pr1, 300, replayInProgress, f.clk.now()) + f.botComment(repo, pr1, 300, corpusMessage(t, "coderabbit/review-in-progress.md"), f.clk.now()) f.pump() if r := f.round(repo, pr1); r == nil || r.Phase != PhaseReviewing { @@ -573,7 +578,7 @@ func TestReplay448DaySequenceFiresThreeTimes(t *testing.T) { // 2. Rate-limited with an unparseable window → fixed 15m fallback. const rlID = 7001 - f.botComment(repo, pr, rlID, replayUnparseableRateLimit, base) + f.botComment(repo, pr, rlID, corpusMessage(t, "coderabbit/rate-limit-no-window.md"), base) f.clk.advance(time.Minute) // base+1m f.pump() if r := f.round(repo, pr); r == nil || r.Phase != PhaseAwaitingRetry || @@ -596,7 +601,7 @@ func TestReplay448DaySequenceFiresThreeTimes(t *testing.T) { // 4. Rate-limited again — CodeRabbit edits the SAME comment in place, now with // a parseable 40-minute window. f.clk.set(base.Add(17 * time.Minute)) - f.editComment(repo, pr, rlID, replayFairUsage(40), f.clk.now()) + f.editComment(repo, pr, rlID, replayFairUsage(t, 40), f.clk.now()) f.pump() if r := f.round(repo, pr); r == nil || r.Phase != PhaseAwaitingRetry || r.RetryAt == nil || !r.RetryAt.Equal(base.Add(57*time.Minute)) { diff --git a/internal/crq/service.go b/internal/crq/service.go index 8a78fae..611e011 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -223,10 +223,14 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { } else if !open { return s.abandonRound(ctx, *next, "pr closed", "skipped") } - if refreshed, err := s.RefreshQuota(ctx); err == nil { - st = refreshed - } else { - return PumpResult{}, err + // A dry-run pump reports decisions and writes nothing — that includes the + // calibration probe RefreshQuota may post; decide from the loaded snapshot. + if !s.cfg.DryRun { + if refreshed, err := s.RefreshQuota(ctx); err == nil { + st = refreshed + } else { + return PumpResult{}, err + } } now = s.clock() if st.Account.BlockedUntil != nil && st.Account.BlockedUntil.After(now) { @@ -702,7 +706,7 @@ func (s *Service) recordFire(ctx context.Context, round Round, token string, com } st, recorded, err := record(ctx) if err != nil { - retryCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + retryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer cancel() st, recorded, err = record(retryCtx) } @@ -1122,7 +1126,7 @@ func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, in if err != nil { return PumpResult{}, 1, err } - if waitingHead(&state, repo, pr) == result.Head { + if state.WaitingHead(repo, pr) == result.Head { return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: result.Head}, 3, nil } report, err := s.Feedback(ctx, repo, pr) @@ -1178,13 +1182,13 @@ func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, in if r := state.Round(repo, pr); r != nil && r.Phase == PhaseFired { return PumpResult{Action: "fired", Repo: repo, PR: pr, Head: r.Head}, 0, nil } - if !containsActive(&state, repo, pr) { + if !state.ContainsActive(repo, pr) { head, open, herr := s.pullHead(ctx, repo, pr) if herr == nil && !open { // PR closed/merged and dropped — nothing to review; stop the loop. return PumpResult{Action: "skipped", Repo: repo, PR: pr, Reason: "pr closed"}, 2, nil } - if herr == nil && head != "" && firedMarker(&state, repo, pr) == head { + if herr == nil && head != "" && state.FiredMarker(repo, pr) == head { return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: head}, 3, nil } if result.Action == "fired" && result.Repo == repo && result.PR == pr { diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 57a1e41..faf43d1 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -428,7 +428,7 @@ func TestAutoReviewScanSkipsConfiguredAuthors(t *testing.T) { if err != nil { t.Fatal(err) } - if firedMarker(&st, "o/app", 3) == "" { + if st.FiredMarker("o/app", 3) == "" { t.Fatalf("only the human-authored PR should be enqueued and fired, got rounds=%#v", st.Rounds) } if st.Round("o/app", 1) != nil || st.Round("o/app", 2) != nil { @@ -469,7 +469,7 @@ func TestAutoReviewScanSkipsMarkedPRs(t *testing.T) { if err != nil { t.Fatal(err) } - if firedMarker(&st, "o/app", 2) == "" { + if st.FiredMarker("o/app", 2) == "" { t.Fatalf("only the unmarked PR should be reviewed, got rounds=%#v", st.Rounds) } if st.Round("o/app", 1) != nil { @@ -632,7 +632,7 @@ func TestPumpPersistsPostedReviewAfterTransientStateFailure(t *testing.T) { if r == nil || r.Phase != PhaseFired || r.CommandID == 0 { t.Fatalf("posted review metadata was not persisted after retry: %#v", r) } - if firedMarker(&state, "owner/repo", 12) != "abcdef123" { + if state.FiredMarker("owner/repo", 12) != "abcdef123" { t.Fatalf("fired marker was not persisted after retry") } if r.FiredAt == nil || r.WaitDeadline == nil { @@ -682,7 +682,7 @@ func TestPumpAdoptsExistingReviewCommandWithoutRefiring(t *testing.T) { if r == nil || r.Phase != PhaseFired || r.CommandID != comment.ID || r.Head != "abcdef123" { t.Fatalf("existing review command should be persisted as a fired round, got %#v", r) } - if firedMarker(&state, "owner/repo", 12) != "abcdef123" { + if state.FiredMarker("owner/repo", 12) != "abcdef123" { t.Fatalf("existing review command should restore fired dedupe state") } if r.FiredAt == nil || !r.FiredAt.Equal(comment.CreatedAt) { @@ -1113,7 +1113,7 @@ func TestPumpKeepsRoundReviewingOnCompletionReplyForUnreviewedPR(t *testing.T) { t.Fatalf("the round must survive a completion reply on a never-reviewed PR (reviewing), got %s", p) } st, _, _ := store.Load(ctx) - if waitingHead(&st, "owner/repo", 12) != "abcdef123" { + if st.WaitingHead("owner/repo", 12) != "abcdef123" { t.Fatalf("the feedback wait must survive a completion reply on a never-reviewed PR") } } @@ -1630,7 +1630,7 @@ func containsActiveRound(store StateStore, t *testing.T, repo string, pr int) bo if err != nil { t.Fatal(err) } - return containsActive(&st, repo, pr) + return st.ContainsActive(repo, pr) } func TestEnqueueDedupesAlreadyReviewedHead(t *testing.T) { @@ -1698,13 +1698,13 @@ func TestRateLimitedRoundParksAndBlocksAccount(t *testing.T) { if r := st.Round("o/carrier", 82); r == nil || r.Phase != PhaseAwaitingRetry { t.Fatalf("a rate-limited round must park awaiting retry, got %#v", r) } - if firedMarker(&st, "o/carrier", 82) != "" { + if st.FiredMarker("o/carrier", 82) != "" { t.Fatalf("a parked round must not be a dedup marker") } if st.Account.BlockedUntil == nil { t.Fatal("the real rate-limit response must block the next attempt") } - if !containsActive(&st, "o/carrier", 82) { + if !st.ContainsActive("o/carrier", 82) { t.Fatal("the unreviewed PR must remain active") } diff --git a/internal/crq/state.go b/internal/crq/state.go index 8f114cc..b3364fd 100644 --- a/internal/crq/state.go +++ b/internal/crq/state.go @@ -3,7 +3,6 @@ package crq import ( "fmt" "strings" - "time" "github.com/kristofferR/coderabbit-queue/internal/engine" ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" @@ -100,63 +99,3 @@ func NormalizeRepo(repo string) string { func QueueKey(repo string, pr int) string { return fmt.Sprintf("%s#%d", NormalizeRepo(repo), pr) } - -// --- round-native views consumed by Wait/Loop ----------------------------- - -// waitingHead returns the head a fired/reviewing round is currently waiting on -// (the wait IS the round), or "" when repo#pr has no active wait. Loop and Wait -// use it to tell "a review is in flight for this head" from "start a new round". -func waitingHead(st *State, repo string, pr int) string { - r := st.Round(repo, pr) - if r == nil || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) { - return "" - } - return r.Head -} - -// roundWaitDeadline returns the wait deadline of the fired/reviewing round at -// head, if one is set. It is the wall-clock bound Loop polls against. -func roundWaitDeadline(st *State, repo string, pr int, head string) (time.Time, bool) { - r := st.Round(repo, pr) - if r == nil || r.Head != head || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) || r.WaitDeadline == nil { - return time.Time{}, false - } - return r.WaitDeadline.UTC(), true -} - -// containsActive reports whether repo#pr has a round still occupying its slot -// (queued through awaiting_retry) — the v2 State.Contains for the queue/inflight. -func containsActive(st *State, repo string, pr int) bool { - r := st.Round(repo, pr) - return r != nil && r.Active() -} - -// firedMarker returns the head for which repo#pr has already been requested and -// must not be re-fired without a new head — the v2 Fired[key] dedupe. A -// completed round, or one still fired/reviewing, is such a marker; a parked -// awaiting_retry round is not (Pump re-fires it once RetryAt passes). -func firedMarker(st *State, repo string, pr int) string { - r := st.Round(repo, pr) - if r == nil { - return "" - } - switch r.Phase { - case PhaseFired, PhaseReviewing, PhaseCompleted: - return r.Head - } - return "" -} - -// accountBlockedUntil returns the latest active block preventing repo#pr@head -// from firing: the account-wide quota block or this round's own retry window -// (the v2 feedbackBlockedUntil over Blocked + per-head Cooldown). -func accountBlockedUntil(st *State, repo string, pr int, head string, now time.Time) (time.Time, bool) { - var until time.Time - if st.Account.BlockedUntil != nil && st.Account.BlockedUntil.After(now) { - until = st.Account.BlockedUntil.UTC() - } - if r := st.Round(repo, pr); r != nil && r.Phase == PhaseAwaitingRetry && r.Head == head && r.RetryAt != nil && r.RetryAt.After(now) && r.RetryAt.After(until) { - until = r.RetryAt.UTC() - } - return until, !until.IsZero() -} diff --git a/internal/dialect/coderabbit.go b/internal/dialect/coderabbit.go index 6347c90..6405421 100644 --- a/internal/dialect/coderabbit.go +++ b/internal/dialect/coderabbit.go @@ -211,21 +211,6 @@ var ( promptBulletRE = regexp.MustCompile("^- (?:Around line|Line)\\s+([0-9]+)(?:\\s*-\\s*([0-9]+))?:\\s*(.*)$") ) -// 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. -func ParseReviewBodyFindings(body string, review ReviewMeta, bot string) []Finding { - body = strings.TrimSpace(body) - if body == "" { - return nil - } - clean := StripMarkdownQuote(body) - out := ParseDetailedReviewFindings(clean, review, bot) - out = append(out, ParsePromptReviewFindings(clean, review, bot)...) - out = append(out, ParseCodexReviewFindings(clean, review, bot)...) - return out -} - func ParseDetailedReviewFindings(body string, review ReviewMeta, bot string) []Finding { lines := strings.Split(body, "\n") var out []Finding diff --git a/internal/dialect/common.go b/internal/dialect/common.go index 0545329..0d112b0 100644 --- a/internal/dialect/common.go +++ b/internal/dialect/common.go @@ -198,6 +198,21 @@ func NormalizeBotName(login string) string { return strings.TrimSuffix(login, "[bot]") } +// 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. +func ParseReviewBodyFindings(body string, review ReviewMeta, bot string) []Finding { + body = strings.TrimSpace(body) + if body == "" { + return nil + } + clean := StripMarkdownQuote(body) + out := ParseDetailedReviewFindings(clean, review, bot) + out = append(out, ParsePromptReviewFindings(clean, review, bot)...) + out = append(out, ParseCodexReviewFindings(clean, review, bot)...) + return out +} + func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index 5965152..e8a93ab 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -52,6 +52,8 @@ func TestGoldenClassification(t *testing.T) { // must still classify as a rate limit, NOT as an already-reviewed ack. {file: "coderabbit/rate-limit-bold-window.md", rateLimited: true, autoReply: true, availableIn: 40 * time.Minute}, {file: "coderabbit/rate-limit-legacy.md", rateLimited: true, availableIn: 3 * time.Minute}, + // No parseable window: the engine must fall back to its conservative fixed block. + {file: "coderabbit/rate-limit-no-window.md", rateLimited: true, autoReply: true}, {file: "coderabbit/review-in-progress.md", inProgress: true}, {file: "coderabbit/review-failed.md", failed: true}, {file: "coderabbit/reviews-paused.md", paused: true}, diff --git a/internal/dialect/testdata/coderabbit/rate-limit-no-window.md b/internal/dialect/testdata/coderabbit/rate-limit-no-window.md new file mode 100644 index 0000000..670eb1a --- /dev/null +++ b/internal/dialect/testdata/coderabbit/rate-limit-no-window.md @@ -0,0 +1,2 @@ + +You are rate limited by coderabbit.ai. Please wait before requesting another review. diff --git a/internal/engine/findings.go b/internal/engine/findings.go index 54279bd..417a19a 100644 --- a/internal/engine/findings.go +++ b/internal/engine/findings.go @@ -1,8 +1,6 @@ package engine import ( - "strings" - "github.com/kristofferR/coderabbit-queue/internal/dialect" ) @@ -14,7 +12,7 @@ import ( func BlockingFindings(findings []dialect.Finding, head string) []dialect.Finding { blocking := make([]dialect.Finding, 0, len(findings)) for _, finding := range findings { - if finding.ThreadID != "" || finding.Commit == "" || head == "" || strings.HasPrefix(finding.Commit, head) { + if finding.ThreadID != "" || finding.Commit == "" || head == "" || dialect.SHAPrefixMatch(finding.Commit, head) { blocking = append(blocking, finding) } } @@ -27,7 +25,7 @@ func BlockingFindings(findings []dialect.Finding, head string) []dialect.Finding func FindingsOnHead(findings []dialect.Finding, head string) []dialect.Finding { current := make([]dialect.Finding, 0, len(findings)) for _, finding := range findings { - if finding.Commit == "" || head == "" || strings.HasPrefix(finding.Commit, head) { + if finding.Commit == "" || head == "" || dialect.SHAPrefixMatch(finding.Commit, head) { current = append(current, finding) } } diff --git a/internal/state/dashboard.go b/internal/state/dashboard.go index f1767ab..f5a0838 100644 --- a/internal/state/dashboard.go +++ b/internal/state/dashboard.go @@ -1,7 +1,7 @@ package state import ( - "crypto/sha1" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -119,6 +119,9 @@ func RenderDashboard(st State, cfg StoreConfig) string { via = fmt.Sprintf(" _(via %s)_", st.Account.Source) } remaining := "available now" + if st.Account.Remaining != nil { + remaining = fmt.Sprintf("%d", *st.Account.Remaining) + } if blocked { remaining = "0 — account blocked" } @@ -204,6 +207,6 @@ func IssueBody(st State, cfg StoreConfig) (string, error) { } func hashString(value string) string { - sum := sha1.Sum([]byte(value)) + sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) } diff --git a/internal/state/state.go b/internal/state/state.go index c3af294..c24115e 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -433,3 +433,63 @@ func (s *State) Normalize(now time.Time) { s.Archive = s.Archive[len(s.Archive)-ArchiveMax:] } } + +// --- round-native views consumed by crq's Wait/Loop ------------------------ + +// waitingHead returns the head a fired/reviewing round is currently waiting on +// (the wait IS the round), or "" when repo#pr has no active wait. Loop and Wait +// use it to tell "a review is in flight for this head" from "start a new round". +func (st *State) WaitingHead(repo string, pr int) string { + r := st.Round(repo, pr) + if r == nil || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) { + return "" + } + return r.Head +} + +// roundWaitDeadline returns the wait deadline of the fired/reviewing round at +// head, if one is set. It is the wall-clock bound Loop polls against. +func (st *State) RoundWaitDeadline(repo string, pr int, head string) (time.Time, bool) { + r := st.Round(repo, pr) + if r == nil || r.Head != head || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) || r.WaitDeadline == nil { + return time.Time{}, false + } + return r.WaitDeadline.UTC(), true +} + +// containsActive reports whether repo#pr has a round still occupying its slot +// (queued through awaiting_retry) — the v2 State.Contains for the queue/inflight. +func (st *State) ContainsActive(repo string, pr int) bool { + r := st.Round(repo, pr) + return r != nil && r.Active() +} + +// firedMarker returns the head for which repo#pr has already been requested and +// must not be re-fired without a new head — the v2 Fired[key] dedupe. A +// completed round, or one still fired/reviewing, is such a marker; a parked +// awaiting_retry round is not (Pump re-fires it once RetryAt passes). +func (st *State) FiredMarker(repo string, pr int) string { + r := st.Round(repo, pr) + if r == nil { + return "" + } + switch r.Phase { + case PhaseFired, PhaseReviewing, PhaseCompleted: + return r.Head + } + return "" +} + +// accountBlockedUntil returns the latest active block preventing repo#pr@head +// from firing: the account-wide quota block or this round's own retry window +// (the v2 feedbackBlockedUntil over Blocked + per-head Cooldown). +func (st *State) AccountBlockedUntil(repo string, pr int, head string, now time.Time) (time.Time, bool) { + var until time.Time + if st.Account.BlockedUntil != nil && st.Account.BlockedUntil.After(now) { + until = st.Account.BlockedUntil.UTC() + } + if r := st.Round(repo, pr); r != nil && r.Phase == PhaseAwaitingRetry && r.Head == head && r.RetryAt != nil && r.RetryAt.After(now) && r.RetryAt.After(until) { + until = r.RetryAt.UTC() + } + return until, !until.IsZero() +} From 99e0242236c30bb1bcf6e86a917f8483bca6cfd1 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 19:36:51 +0200 Subject: [PATCH 09/20] Fix two silent-drop bugs the self-hosted loop exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfooding on PR #30 surfaced both, one per review round: 1. CodeRabbit appends an "Also applies to: " trailer to real finding bodies. The non-actionable denylist matched the phrase as a substring, so all four findings carrying the trailer in round one were silently dropped — never surfaced, never resolved. The phrase now only marks a comment that IS the trailer (prefix match); corpus files pin both directions. 2. CodeRabbit submits empty-bodied COMMENTED review objects as carriers for its inline-comment batches, minutes before the real review lands. Counting a shell as review evidence converged round two with zero findings at 17:26 while the real review was still posting until 17:32. observe() now skips body-less COMMENTED reviews; the replay suite pins the shell-then-real-review sequence, and review fixtures carry real bodies as actual bot reviews do. --- internal/crq/codex_replay_test.go | 3 +- internal/crq/feedback_test.go | 8 +-- internal/crq/observe.go | 8 +++ internal/crq/replay_test.go | 57 ++++++++++++++++++- internal/crq/service_test.go | 2 +- internal/dialect/common.go | 8 ++- internal/dialect/golden_test.go | 4 ++ .../finding-with-also-applies-trailer.md | 5 ++ .../coderabbit/thread-ack-also-applies.md | 1 + 9 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 internal/dialect/testdata/coderabbit/finding-with-also-applies-trailer.md create mode 100644 internal/dialect/testdata/coderabbit/thread-ack-also-applies.md diff --git a/internal/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go index 42675b8..0709142 100644 --- a/internal/crq/codex_replay_test.go +++ b/internal/crq/codex_replay_test.go @@ -52,7 +52,8 @@ func (f *replayFixture) codexComment(repo string, pr int, id int64, body string, func (f *replayFixture) codexReview(repo string, pr int, id int64, commitSHA string, at time.Time) { f.gh.mu.Lock() defer f.gh.mu.Unlock() - r := ghapi.Review{ID: id, CommitID: commitSHA, State: "COMMENTED", SubmittedAt: at.UTC()} + r := ghapi.Review{ID: id, CommitID: commitSHA, State: "COMMENTED", SubmittedAt: at.UTC(), + Body: "### \U0001F4A1 Codex Review\n\nReviewed the changes."} r.User.Login = codexLogin key := fakeKey(repo, pr) f.gh.reviews[key] = append(f.gh.reviews[key], r) diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index 6bfbc2b..9862efe 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -107,7 +107,7 @@ func TestFeedbackCountsCompletionReplyForFiredHead(t *testing.T) { // A no-findings re-review presupposes an earlier review of the PR // (on some older commit); without one the completion reply must // not stand in for a review (covered by the dedicated case below). - prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} + prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour), Body: "**Actionable comments posted: 2**"} prior.User.Login = "coderabbitai[bot]" gh.reviews[fakeKey("o/repo", 3)] = []ghapi.Review{prior} } @@ -212,7 +212,7 @@ func TestFeedbackRejectsCompletionReplyWhileTopSummaryIsProcessing(t *testing.T) completion := mk(2, cfg.Bot, "\nReview finished.", firedAt.Add(time.Minute), firedAt.Add(time.Minute)) summary := mk(3, cfg.Bot, "\nCurrently processing new changes in this PR. This may take a few minutes, please wait...", firedAt.Add(-time.Hour), firedAt.Add(2*time.Minute)) gh.comments[fakeKey("o/repo", 3)] = []ghapi.IssueComment{summary, command, completion} - prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} + prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour), Body: "**Actionable comments posted: 2**"} prior.User.Login = cfg.Bot gh.reviews[fakeKey("o/repo", 3)] = []ghapi.Review{prior} store := NewMemoryStore(cfg) @@ -266,7 +266,7 @@ func TestFeedbackRejectsCompletionReplyWhenTopSummaryFailed(t *testing.T) { completion := mk(2, cfg.Bot, "\nReview finished.", firedAt.Add(time.Minute), firedAt.Add(3*time.Minute)) failure := mk(3, cfg.Bot, "\n## Review failed\n\nAn error occurred during the review process.", firedAt.Add(-time.Hour), firedAt.Add(2*time.Minute)) gh.comments[fakeKey("o/repo", 3)] = []ghapi.IssueComment{failure, command, completion} - prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} + prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour), Body: "**Actionable comments posted: 2**"} prior.User.Login = cfg.Bot gh.reviews[fakeKey("o/repo", 3)] = []ghapi.Review{prior} store := NewMemoryStore(cfg) @@ -317,7 +317,7 @@ func TestFeedbackRejectsCompletionReplyFromEarlierRound(t *testing.T) { gh.comments[fakeKey("o/repo", 3)] = comments // The completion fallback requires a prior review by the bot (the old // round's review of the previous head). - prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} + prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour), Body: "**Actionable comments posted: 2**"} prior.User.Login = "coderabbitai[bot]" gh.reviews[fakeKey("o/repo", 3)] = []ghapi.Review{prior} store := NewMemoryStore(cfg) diff --git a/internal/crq/observe.go b/internal/crq/observe.go index bfd5fdf..d140f40 100644 --- a/internal/crq/observe.go +++ b/internal/crq/observe.go @@ -51,6 +51,14 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round } o.reviews = reviews for _, review := range reviews { + // CodeRabbit submits empty-bodied COMMENTED review objects as carriers + // for its inline-comment batches, minutes before the real review (the + // one with an "Actionable comments posted" body) lands. A shell is not + // review evidence: counting one converged a round with zero findings at + // 17:26 while the real review was still posting until 17:32. + if strings.TrimSpace(review.Body) == "" && strings.EqualFold(review.State, "COMMENTED") { + continue + } o.eng.Reviews = append(o.eng.Reviews, engine.ReviewSeen{ Bot: review.User.Login, ReviewID: review.ID, diff --git a/internal/crq/replay_test.go b/internal/crq/replay_test.go index da639b4..6bcffe7 100644 --- a/internal/crq/replay_test.go +++ b/internal/crq/replay_test.go @@ -183,7 +183,8 @@ func (f *replayFixture) editComment(repo string, pr int, id int64, body string, func (f *replayFixture) botReview(repo string, pr int, id int64, commitSHA string, at time.Time) { f.gh.mu.Lock() defer f.gh.mu.Unlock() - r := ghapi.Review{ID: id, CommitID: commitSHA, State: "COMMENTED", SubmittedAt: at.UTC()} + r := ghapi.Review{ID: id, CommitID: commitSHA, State: "COMMENTED", SubmittedAt: at.UTC(), + Body: "**Actionable comments posted: 0**"} r.User.Login = f.bot key := fakeKey(repo, pr) f.gh.reviews[key] = append(f.gh.reviews[key], r) @@ -648,3 +649,57 @@ func TestReplay448DaySequenceFiresThreeTimes(t *testing.T) { t.Fatalf("no command may post after convergence, got %d", f.reviewsPosted(repo, pr)) } } + +// shellReview appends an empty-bodied COMMENTED review object — the carrier +// CodeRabbit submits for an inline-comment batch before its real review. +func (f *replayFixture) shellReview(repo string, pr int, id int64, commitSHA string, at time.Time) { + f.gh.mu.Lock() + defer f.gh.mu.Unlock() + r := ghapi.Review{ID: id, CommitID: commitSHA, State: "COMMENTED", SubmittedAt: at.UTC()} + r.User.Login = f.bot + key := fakeKey(repo, pr) + f.gh.reviews[key] = append(f.gh.reviews[key], r) +} + +// TestReplayEmptyReviewShellDoesNotConverge pins the 17:26-vs-17:32 incident: +// CodeRabbit submitted five empty review shells at the head minutes before the +// real review; a loop polling in that window must NOT converge on them. +func TestReplayEmptyReviewShellDoesNotConverge(t *testing.T) { + base := time.Date(2026, 7, 17, 17, 20, 0, 0, time.UTC) + f := newReplayFixture(t, base) + repo, pr, head := "o/r", 30, "aaaabbbbccccdddd" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected fire, got %+v", res) + } + + // Comment-batch shells arrive at the fired head. + f.shellReview(repo, pr, 601, head, f.clk.now().Add(90*time.Second)) + f.shellReview(repo, pr, 602, head, f.clk.now().Add(91*time.Second)) + f.clk.advance(2 * time.Minute) + f.pump() + report, err := f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatal(err) + } + if report.Converged { + t.Fatalf("empty review shells must not converge the round: %+v", report.ReviewedBy) + } + if r := f.round(repo, pr); r == nil || r.Phase == PhaseCompleted { + t.Fatalf("round must stay open on shells, got %+v", r) + } + + // The real review lands six minutes later — now it converges. + f.botReview(repo, pr, 603, head, f.clk.now().Add(6*time.Minute)) + f.clk.advance(7 * time.Minute) + f.pump() + report, err = f.svc.Feedback(f.ctx, repo, pr) + if err != nil { + t.Fatal(err) + } + if !report.Converged { + t.Fatalf("real review must converge: %+v", report.ReviewedBy) + } +} diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index faf43d1..46f464b 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -1062,7 +1062,7 @@ func TestPumpCompletesRoundOnCompletionReply(t *testing.T) { reply.User.Login = cfg.Bot gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{command, reply} // A completion-only round is a re-review: a prior review must exist. - prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)} + prior := ghapi.Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour), Body: "**Actionable comments posted: 2**"} prior.User.Login = cfg.Bot gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{prior} store := NewMemoryStore(cfg) diff --git a/internal/dialect/common.go b/internal/dialect/common.go index 0d112b0..18b0709 100644 --- a/internal/dialect/common.go +++ b/internal/dialect/common.go @@ -125,9 +125,15 @@ func IsNonActionableText(text string) bool { return true } text = NormalizeReviewText(text) + // CodeRabbit appends an "Also applies to: " trailer to REAL finding + // bodies listing other affected locations — a substring match on it silently + // dropped four genuine findings in one review. Only a comment that IS the + // trailer (an ack pointing at an already-reported location) is noise. + if strings.HasPrefix(strings.TrimSpace(text), "also applies to:") { + return true + } nonActionable := []string{ "lgtm", - "also applies to:", "no issue here", "incorrect or invalid review comment", "likely an incorrect or invalid review comment", diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index e8a93ab..776776a 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -60,6 +60,10 @@ func TestGoldenClassification(t *testing.T) { {file: "coderabbit/no-actionable-comments.md", noAction: 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 + // 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}, diff --git a/internal/dialect/testdata/coderabbit/finding-with-also-applies-trailer.md b/internal/dialect/testdata/coderabbit/finding-with-also-applies-trailer.md new file mode 100644 index 0000000..850f357 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/finding-with-also-applies-trailer.md @@ -0,0 +1,5 @@ +**Guard the cutoff against a zero fired time.** + +A round that never fired has no cutoff; comparing against the zero time makes every comment look in-window and can converge a round on stale evidence. Return early when FiredAt is nil. + +Also applies to: lines 84-91 diff --git a/internal/dialect/testdata/coderabbit/thread-ack-also-applies.md b/internal/dialect/testdata/coderabbit/thread-ack-also-applies.md new file mode 100644 index 0000000..3a1d2f6 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/thread-ack-also-applies.md @@ -0,0 +1 @@ +Also applies to: lines 120-124, 350-361 From e0ebdcc87624ed28a5a0c26f01e9684e00251003 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 20:18:42 +0200 Subject: [PATCH 10/20] Harden CAS identity, Codex gating, and adoption guards from round-two review - CAS identity guards: every round-mutating CAS closure (abandon, dedupe, supersede, both fireRound reservations, recordFire, fireCodexOnly) now verifies the stored round still matches the observed Seq+Head via a shared sameRound helper, so a concurrent supersede reads as a benign lost race instead of mutating a replacement round. - Canonical Codex login: export dialect.CodexBotLogin and consume it from IsCodexBot, engine's codexBot, config's extraFeedbackBots, and the tests; the replay suite now loads the clean-summary (SHA-templated) and usage-limit texts from the dialect corpus instead of duplicating bot wording. - Adoption force-push guard: headForcePushCutoff returns (time, error); on any lookup failure adoptableCommands skips adoption this pass rather than adopting an old-head command blind (worst case is re-posting an existing command). - Codex auto-review detection scoped to the latest evidence: only the most recent Codex review/clean-summary decides auto-active, so an old unprompted review no longer suppresses posting once a later commanded review lands. - Dedupe no longer skips a required Codex: a CodeRabbit review at the head that a gating Codex has not yet reviewed yields FireCodexOnly (post only the Codex command, wait on Codex alone) when crq may post, FireNo when Codex will still produce its own evidence, and plain FireDedupe only when Codex is truly unobtainable so the round never wedges. - Calibration scheduling uses the injectable s.clock() so replay tests can drive quota freshness deterministically. --- internal/crq/codex_replay_test.go | 79 +++++++++++++++++++--- internal/crq/config.go | 2 +- internal/crq/observe.go | 29 +++++--- internal/crq/service.go | 106 +++++++++++++++++++++++++++--- internal/crq/service_test.go | 10 +++ internal/dialect/codex.go | 7 +- internal/engine/codex.go | 67 ++++++++++++------- internal/engine/engine_test.go | 104 ++++++++++++++++++++++++++--- internal/engine/fire.go | 33 +++++++++- 9 files changed, 372 insertions(+), 65 deletions(-) diff --git a/internal/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go index 0709142..1445a65 100644 --- a/internal/crq/codex_replay_test.go +++ b/internal/crq/codex_replay_test.go @@ -1,9 +1,11 @@ package crq import ( + "strings" "testing" "time" + "github.com/kristofferR/coderabbit-queue/internal/dialect" ghapi "github.com/kristofferR/coderabbit-queue/internal/gh" ) @@ -12,15 +14,25 @@ import ( // GitHub, MemoryStore) so every claim is driven through the real // Pump/Feedback/observe pipeline. -const codexLogin = "chatgpt-codex-connector[bot]" +// codexLogin is the canonical Codex app login, owned by internal/dialect. +const codexLogin = dialect.CodexBotLogin -// codexCleanTada is the Codex clean-summary shape pinned by -// testdata/codex/clean-summary-tada.md, parameterized on the reviewed SHA. -func codexClean(sha string) string { - return "Codex Review: Didn't find any major issues. :tada:\n\n**Reviewed commit:** `" + sha + "`" +// 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. +func codexClean(t *testing.T, sha string) string { + msg := corpusMessage(t, "codex/clean-summary-tada.md") + out := strings.Replace(msg, "4d9e8bca82", sha, 1) + if out == msg { + t.Fatal("clean-summary corpus no longer carries the 4d9e8bca82 anchor SHA") + } + return out } -const codexUsageLimit = "You have reached your Codex usage limits for code reviews. Limits reset periodically." +// codexUsageLimit loads the Codex usage-limit exhaustion notice from the corpus. +func codexUsageLimit(t *testing.T) string { + return corpusMessage(t, "codex/usage-limit.md") +} func newCodexReplayFixture(t *testing.T, base time.Time, mutate func(*Config)) *replayFixture { t.Helper() @@ -170,7 +182,7 @@ func TestCodexReplayAutoActiveSuppressesCommand(t *testing.T) { } // Codex auto-reviews the head (clean) — round completes. - f.codexComment(repo, pr, 700, codexClean(head[:10]), f.clk.now().Add(time.Minute)) + f.codexComment(repo, pr, 700, codexClean(t, head[:10]), f.clk.now().Add(time.Minute)) f.clk.advance(2 * time.Minute) f.pump() if r := f.round(repo, pr); r == nil || r.Phase != PhaseCompleted { @@ -217,10 +229,61 @@ func TestCodexReplayDynamicGateAndUsageLimitEscape(t *testing.T) { // Codex runs out of quota: the dynamic gate disengages and the round // completes on CodeRabbit's review alone. - f.codexComment(repo, pr, 701, codexUsageLimit, f.clk.now().Add(time.Minute)) + f.codexComment(repo, pr, 701, codexUsageLimit(t), f.clk.now().Add(time.Minute)) f.clk.advance(2 * time.Minute) f.pump() if r := f.round(repo, pr); r == nil || r.Phase != PhaseCompleted { t.Fatalf("usage limit must release the dynamic gate, got %+v", r) } } + +// (iv) CodeRabbit already reviewed the head before crq fires, with Codex +// configured-required: the dedupe must NOT complete the round Codex-less. crq +// posts exactly one @codex review (and zero @coderabbitai review), and the round +// completes only once Codex answers. +func TestCodexReplayDedupeStillCommandsCodex(t *testing.T) { + base := time.Date(2026, 7, 17, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, func(cfg *Config) { + cfg.RequiredBots = []string{cfg.Bot, codexLogin} + }) + repo, pr, head := "o/r", 11, "aaaabbbbccccdddd" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + // CodeRabbit already reviewed this head before crq gets to fire. + f.botReview(repo, pr, 500, head, base.Add(-time.Minute)) + + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected a codex-only fire, got %+v", res) + } + if got := f.reviewsPosted(repo, pr); got != 0 { + t.Fatalf("coderabbit must not be commanded when it already reviewed, got %d", got) + } + if got := f.codexPosted(repo, pr); got != 1 { + t.Fatalf("codex must be commanded exactly once, got %d", got) + } + if r := f.round(repo, pr); r == nil || r.CodexCommandID == 0 { + t.Fatalf("round must record the codex command, got %+v", r) + } + + // CodeRabbit is already satisfied; the round waits on Codex alone. + f.clk.advance(2 * time.Minute) + f.pump() + if r := f.round(repo, pr); r == nil || r.Phase != PhaseReviewing { + t.Fatalf("round must wait for codex, got %+v", r) + } + if got := f.codexPosted(repo, pr); got != 1 { + t.Fatalf("waiting must not repost the codex command, got %d", got) + } + + // Codex answers → the round completes; still no coderabbit command posted. + f.codexReview(repo, pr, 502, head, f.clk.now().Add(time.Minute)) + f.clk.advance(2 * time.Minute) + f.pump() + if r := f.round(repo, pr); r == nil || r.Phase != PhaseCompleted { + t.Fatalf("round must complete after codex answers, got %+v", r) + } + if got := f.reviewsPosted(repo, pr); got != 0 { + t.Fatalf("no coderabbit command may post across the scenario, got %d", got) + } +} diff --git a/internal/crq/config.go b/internal/crq/config.go index 60da8aa..1424202 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -248,7 +248,7 @@ func listEnv(env map[string]string, key, fallback string) []string { // 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{"chatgpt-codex-connector[bot]"} +var extraFeedbackBots = []string{dialect.CodexBotLogin} // unionBots concatenates bot lists, dropping blanks and case-insensitively // de-duplicating on the normalized login (so "coderabbitai" and diff --git a/internal/crq/observe.go b/internal/crq/observe.go index d140f40..4ef4b2b 100644 --- a/internal/crq/observe.go +++ b/internal/crq/observe.go @@ -194,8 +194,16 @@ func (s *Service) reviewCommands(ctx context.Context, repo string, pr int, obs e } // A force-push can point the PR at a commit object whose committer date // predates commands made for an earlier head, so any command older than the - // last force-push belongs to a previous head and must not be adopted. - if fp := s.headForcePushCutoff(ctx, repo, pr); fp.After(cutoff) { + // last force-push belongs to a previous head and must not be adopted. Without + // this guard an old-head command could be adopted after a force-push, marking + // an unreviewed head fired — so if the lookup is unavailable, skip adoption + // this pass. The worst case then is posting a command that already exists, the + // documented-safe pre-adoption fallback. + fp, err := s.headForcePushCutoff(ctx, repo, pr) + if err != nil { + return nil, nil, nil + } + if fp.After(cutoff) { cutoff = fp } if hasCR { @@ -271,13 +279,14 @@ func hasCommentBody(comments []ghapi.IssueComment, body string) bool { return false } -// headForcePushCutoff returns when the PR head was last force-pushed, zero if -// unknown or never. Best-effort: on GraphQL failure adoption falls back to the -// commit-date cutoff rather than blocking the pump. -func (s *Service) headForcePushCutoff(ctx context.Context, repo string, pr int) time.Time { +// headForcePushCutoff returns when the PR head was last force-pushed (zero when +// never), or an error when the lookup could not run. The caller must not adopt a +// command without this guard: a lookup error means the force-push protection is +// unavailable, so adoption is skipped rather than done blind. +func (s *Service) headForcePushCutoff(ctx context.Context, repo string, pr int) (time.Time, error) { owner, name, found := strings.Cut(repo, "/") if !found { - return time.Time{} + return time.Time{}, nil } var result struct { Repository struct { @@ -300,11 +309,11 @@ func (s *Service) headForcePushCutoff(ctx context.Context, repo string, pr int) } }` if err := s.gh.GraphQL(ctx, query, map[string]any{"owner": owner, "name": name, "number": pr}, &result); err != nil { - return time.Time{} + return time.Time{}, err } nodes := result.Repository.PullRequest.TimelineItems.Nodes if len(nodes) == 0 { - return time.Time{} + return time.Time{}, nil } - return nodes[len(nodes)-1].CreatedAt.UTC() + return nodes[len(nodes)-1].CreatedAt.UTC(), nil } diff --git a/internal/crq/service.go b/internal/crq/service.go index 611e011..8101d2b 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -342,6 +342,15 @@ func releaseSlot(st *State, key string) { } } +// sameRound reports whether the stored round r is still the one that was +// observed — same Seq and Head. Every CAS mutation guards on it so a concurrent +// supersede (which archives the old round and creates a fresh one with a new Seq) +// between observe and write reads as a benign lost race rather than a mutation of +// the wrong round. +func sameRound(r *Round, want Round) bool { + return r != nil && r.Seq == want.Seq && r.Head == want.Head +} + // applyAccountBlock ports requeueInflight's account-quota bookkeeping. The window // (including same-comment reuse) was resolved by the engine, so only the store // write happens here. @@ -436,6 +445,8 @@ 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.FireSupersede: return s.supersedeRound(ctx, round, obs.Head, now) case engine.FireAdopt: @@ -463,7 +474,8 @@ func mapFireNo(reason string) string { } // abandonRound ends a round (closed/merged PR) without consuming review -// readiness. The existence check makes a concurrent cancel a benign lost race. +// readiness. The identity guard makes a concurrent cancel or supersede between +// observe and write a benign lost race, never an abandon of a replacement round. func (s *Service) abandonRound(ctx context.Context, round Round, reason, action string) (PumpResult, error) { result := PumpResult{Action: action, Repo: round.Repo, PR: round.PR, Reason: reason} if s.cfg.DryRun { @@ -471,7 +483,7 @@ func (s *Service) abandonRound(ctx context.Context, round Round, reason, action } ended := false updated, err := s.store.Update(ctx, func(st *State) error { - if st.Round(round.Repo, round.PR) == nil { + if !sameRound(st.Round(round.Repo, round.PR), round) { return ErrNoChange } st.EndRound(round.Repo, round.PR, reason) @@ -500,7 +512,7 @@ func (s *Service) dedupeRound(ctx context.Context, round Round, now time.Time, r updated, err := s.store.Update(ctx, func(st *State) error { deduped = false r := st.Round(round.Repo, round.PR) - if r == nil || r.Head != round.Head || !r.FireEligible(now) { + if !sameRound(r, round) || !r.FireEligible(now) { return ErrNoChange } if err := r.Dedupe(now); err != nil { @@ -529,8 +541,7 @@ func (s *Service) supersedeRound(ctx context.Context, round Round, head string, return result, nil } updated, err := s.store.Update(ctx, func(st *State) error { - r := st.Round(round.Repo, round.PR) - if r == nil || r.Head == head { + if !sameRound(st.Round(round.Repo, round.PR), round) { return ErrNoChange } _, err := st.Supersede(round.Repo, round.PR, head, now) @@ -568,7 +579,7 @@ func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observa return ErrNoChange } r := st.Round(round.Repo, round.PR) - if r == nil || !r.FireEligible(now) { + if !sameRound(r, round) || !r.FireEligible(now) { return ErrNoChange } if err := r.Reserve(token, s.cfg.Host, now); err != nil { @@ -609,7 +620,7 @@ func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observa return ErrNoChange } r := st.Round(round.Repo, round.PR) - if r == nil || !r.FireEligible(now) { + if !sameRound(r, round) || !r.FireEligible(now) { return ErrNoChange } if err := r.Reserve(token, s.cfg.Host, now); err != nil { @@ -674,6 +685,81 @@ 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) { + 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 + } + 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 + } + st.FireSlot = &FireSlot{Key: key, Token: token, Since: now} + st.PutRound(*r) + return nil + }) + if err != nil { + return PumpResult{}, err + } + if reserved.FireSlot == nil || reserved.FireSlot.Token != token { + return PumpResult{Action: "lost_race"}, nil + } + s.sync(ctx, reserved) + + 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 { + r := st.Round(round.Repo, round.PR) + if r == nil || r.Token != token { + return ErrNoChange + } + if rerr := r.ReleaseToQueue("failed to post codex review command: "+err.Error(), now); rerr != nil { + return rerr + } + releaseSlot(st, key) + st.Warn = "failed to post codex review command: " + err.Error() + st.PutRound(*r) + return nil + }) + if uerr == nil { + s.sync(ctx, updated) + } + return PumpResult{Action: "post_failed", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: err.Error()}, err + } + firedAt := comment.CreatedAt.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 + } + return PumpResult{}, err + } + 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)) + } + return PumpResult{Action: "fired", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil +} + // 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 @@ -684,7 +770,7 @@ func (s *Service) recordFire(ctx context.Context, round Round, token string, com st, err := s.store.Update(c, func(st *State) error { recorded = false r := st.Round(round.Repo, round.PR) - if r == nil || st.FireSlot == nil || st.FireSlot.Token != token || r.Token != token { + if !sameRound(r, round) || st.FireSlot == nil || st.FireSlot.Token != token || r.Token != token { return ErrNoChange } if err := r.Fire(commandID, firedAt); err != nil { @@ -813,7 +899,7 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { if s.cfg.CalibrationPR <= 0 { return state, nil } - now := time.Now().UTC() + now := s.clock() // Honor the freshness shortcut only when the last reading was conclusive. If a // probe is still pending (CalibAskedAt set, no reply yet), keep re-checking so a // late "account blocked" reply isn't ignored for the full TTL. @@ -825,7 +911,7 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { return state, err } updated, err := s.store.Update(ctx, func(st *State) error { - if st.Account.CalibAskedAt == nil && st.Account.CheckedAt != nil && time.Since(*st.Account.CheckedAt) < s.cfg.CalibrationTTL { + if st.Account.CalibAskedAt == nil && st.Account.CheckedAt != nil && now.Sub(*st.Account.CheckedAt) < s.cfg.CalibrationTTL { return ErrNoChange } // A fresh reading replaces the whole quota; carry the account-quota comment diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index 46f464b..b6aaca7 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -195,6 +195,14 @@ func (f *fakeGitHub) GraphQL(_ context.Context, query string, vars map[string]an return errors.New("graphql unavailable") } +// noForcePush is a GraphQL handler reporting no HEAD_REF_FORCE_PUSHED_EVENT, so +// headForcePushCutoff succeeds with a zero cutoff and adoption proceeds. Tests +// exercising successful adoption need it now that a failed force-push lookup +// skips adoption. +func noForcePush(_ string, _ map[string]any, out any) error { + return json.Unmarshal([]byte(`{"repository":{"pullRequest":{"timelineItems":{"nodes":[]}}}}`), out) +} + // --- test store fakes (v3) --- type failNthUpdateStore struct { @@ -658,6 +666,7 @@ func TestPumpAdoptsExistingReviewCommandWithoutRefiring(t *testing.T) { comment := ghapi.IssueComment{ID: 77, Body: cfg.ReviewCommand, CreatedAt: headTime.Add(30 * time.Second), UpdatedAt: headTime.Add(30 * time.Second)} comment.User.Login = "kristofferR" gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{comment} + gh.graphQL = noForcePush store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) @@ -936,6 +945,7 @@ func TestPumpAdoptsCompletionAnsweredCommandWhileTopSummaryIsProcessing(t *testi } summary.User.Login = cfg.Bot gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{summary, command, reply} + gh.graphQL = noForcePush store := NewMemoryStore(cfg) service := NewService(cfg, gh, store, nil) diff --git a/internal/dialect/codex.go b/internal/dialect/codex.go index ea276ba..42d5ee1 100644 --- a/internal/dialect/codex.go +++ b/internal/dialect/codex.go @@ -16,8 +16,13 @@ var ( codexReviewedCommitRE = regexp.MustCompile("(?i)reviewed commit[:*\\s]*`([0-9a-fA-F]{7,40})`") ) +// CodexBotLogin is the canonical Codex GitHub app login. It is the one place +// this literal may appear; engine/state/crq consume this constant rather than +// repeating the wording. +const CodexBotLogin = "chatgpt-codex-connector[bot]" + func IsCodexBot(login string) bool { - return NormalizeBotName(login) == "chatgpt-codex-connector" + return NormalizeBotName(login) == NormalizeBotName(CodexBotLogin) } func HasCodexBot(bots []string) bool { diff --git a/internal/engine/codex.go b/internal/engine/codex.go index e580f2a..abe28b8 100644 --- a/internal/engine/codex.go +++ b/internal/engine/codex.go @@ -8,10 +8,10 @@ import ( "github.com/kristofferR/coderabbit-queue/internal/state" ) -// codexBot is the Codex GitHub app login. The dialect owns the normalization -// (IsCodexBot/HasCodexBot); this is the canonical key the engine flips in -// ReviewedBy when Codex gates a round. -const codexBot = "chatgpt-codex-connector[bot]" +// 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. +const codexBot = dialect.CodexBotLogin // roundCutoff is the round-window floor: the fire time (UTC), or zero when the // round has not fired. @@ -75,39 +75,56 @@ func CodexActiveThisRound(r state.Round, obs Observation) bool { return codexReviewedRound(r, obs, cutoff) || codexCommentedRound(obs, cutoff) || obs.CodexThumbsUp } -// CodexAutoActive reports whether Codex reviews this PR on its own: it has a -// submitted review or a clean summary that no `@codex review` command preceded. -// When true, crq must never post the Codex command — Codex reviews unprompted. +// 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 { - firstCmd, ok := firstCodexCommand(obs) - notPreceded := func(at time.Time) bool { return !ok || at.Before(firstCmd) } + latest, ok := latestCodexEvidence(obs) + if !ok { + return false + } + return !codexCommandAtOrBefore(obs, latest) +} + +// latestCodexEvidence returns the timestamp of the most recent Codex review or +// clean-summary event, and whether any exists. +func latestCodexEvidence(obs Observation) (time.Time, bool) { + var latest time.Time + ok := false + consider := func(at time.Time) { + if at.IsZero() { + return + } + if !ok || at.After(latest) { + latest, ok = at, true + } + } for _, review := range obs.Reviews { - if dialect.IsCodexBot(review.Bot) && !review.SubmittedAt.IsZero() && notPreceded(review.SubmittedAt) { - return true + if dialect.IsCodexBot(review.Bot) { + consider(review.SubmittedAt) } } for _, ev := range obs.Events { - if ev.Kind == dialect.EvCodexClean && notPreceded(ev.PairTime()) { - return true + if ev.Kind == dialect.EvCodexClean { + consider(ev.PairTime()) } } - return false + return latest, ok } -// firstCodexCommand returns the earliest `@codex review` command time, and -// whether any exists. -func firstCodexCommand(obs Observation) (time.Time, bool) { - var first time.Time - ok := false +// codexCommandAtOrBefore reports whether an `@codex review` command was posted +// at or before t. +func codexCommandAtOrBefore(obs Observation, t time.Time) bool { for _, ev := range obs.Events { - if ev.Kind != dialect.EvCodexCommand { - continue - } - if at := ev.PairTime(); !ok || at.Before(first) { - first, ok = at, true + if ev.Kind == dialect.EvCodexCommand && !ev.PairTime().After(t) { + return true } } - return first, ok + return false } // CodexCommandSince reports whether an `@codex review` command comment exists diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index d525367..33b7ed8 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -335,12 +335,12 @@ func TestCommandHasCompletionReply(t *testing.T) { func TestDecideCodexPost(t *testing.T) { codexReq := Policy{ Bot: "coderabbitai[bot]", - RequiredBots: []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"}, + RequiredBots: []string{"coderabbitai[bot]", dialect.CodexBotLogin}, CodexCommand: "@codex review", } head := "abcdef123" base := Observation{Head: head, Open: true} - codexReviewHead := ReviewSeen{Bot: "chatgpt-codex-connector[bot]", Commit: "abcdef1234567890", SubmittedAt: t0} + codexReviewHead := ReviewSeen{Bot: dialect.CodexBotLogin, Commit: "abcdef1234567890", SubmittedAt: t0} cases := []struct { name string @@ -367,6 +367,94 @@ func TestDecideCodexPost(t *testing.T) { } } +// TestCodexAutoActive covers the "latest evidence decides" rule: only the most +// recent Codex review/clean-summary determines auto-review, so an old unprompted +// review no longer suppresses posting once a later commanded review lands. +func TestCodexAutoActive(t *testing.T) { + codexReview := func(at time.Time) ReviewSeen { + 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} + } + codexClean := func(at time.Time) dialect.BotEvent { + return dialect.BotEvent{Kind: dialect.EvCodexClean, Bot: dialect.CodexBotLogin, SHA: "abcdef1234", CommentID: 2, CreatedAt: at, UpdatedAt: at} + } + t1 := t0.Add(time.Hour) + + cases := []struct { + name string + obs Observation + want bool + }{ + {name: "no evidence", obs: Observation{}, want: false}, + {name: "unprompted review", obs: Observation{Reviews: []ReviewSeen{codexReview(t0)}}, want: true}, + {name: "unprompted clean summary", obs: Observation{Events: []dialect.BotEvent{codexClean(t0)}}, want: true}, + {name: "commanded review", obs: Observation{ + Reviews: []ReviewSeen{codexReview(t0.Add(time.Minute))}, + Events: []dialect.BotEvent{codexCommand(t0)}, + }, want: false}, + // Old unprompted review, then a command, then a later commanded review: the + // latest evidence was commanded, so the old epoch stops suppressing posting. + {name: "old unprompted then commanded", obs: Observation{ + Reviews: []ReviewSeen{codexReview(t0), codexReview(t1.Add(time.Minute))}, + Events: []dialect.BotEvent{codexCommand(t1)}, + }, want: false}, + } + 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) + } + }) + } +} + +// TestDecideFireCodexDedupe covers the dedupe/Codex interaction: a head +// CodeRabbit already reviewed must still command (or wait for) a gating Codex +// rather than completing the round Codex-less. +func TestDecideFireCodexDedupe(t *testing.T) { + free := Global{SlotFree: true} + now := t0.Add(10 * time.Minute) + head := "abcdef123" + 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" + + crReviewed := ReviewSeen{Bot: "coderabbitai", Commit: "abcdef1234567890", SubmittedAt: now} + codexReviewed := ReviewSeen{Bot: dialect.CodexBotLogin, Commit: "abcdef1234567890", SubmittedAt: now} + + // 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 { + 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. + autoObs := Observation{Head: head, Open: true, CodexAutoActive: true, Reviews: []ReviewSeen{crReviewed}} + if d := DecideFire(free, queued, autoObs, now, codexReq); d.Verdict != FireNo { + t.Fatalf("auto-active codex must wait, not dedupe, got %+v", d) + } + // Codex already reviewed the head → the round is genuinely done. + doneObs := Observation{Head: head, Open: true, Reviews: []ReviewSeen{crReviewed, codexReviewed}} + if d := DecideFire(free, queued, doneObs, now, codexReq); d.Verdict != FireDedupe { + t.Fatalf("both bots reviewed the head must dedupe, got %+v", d) + } + // No Codex configured or active → plain dedupe as before. + if d := DecideFire(free, queued, obs, now, policy); d.Verdict != FireDedupe { + t.Fatalf("without a gating codex a reviewed head must dedupe, got %+v", d) + } + // Codex required but no command configured and not auto-active: crq cannot + // 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 = "" + 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) + } +} + // TestDynamicCodexGate covers the dynamic completion gate: an observed-active // Codex gates a round it isn't configured-required for, a usage-limit notice // disengages that dynamic gate, and a configured-required Codex is left gating @@ -375,8 +463,8 @@ func TestDynamicCodexGate(t *testing.T) { r := firedRound(t, "abcdef123") cutoff := r.FiredAt.UTC() crReview := ReviewSeen{Bot: "coderabbitai[bot]", Commit: "abcdef1234567890", SubmittedAt: cutoff.Add(time.Minute)} - codexReview := ReviewSeen{Bot: "chatgpt-codex-connector[bot]", Commit: "abcdef1234567890", SubmittedAt: cutoff.Add(time.Minute)} - usageLimit := dialect.BotEvent{Kind: dialect.EvCodexUsageLimit, Bot: "chatgpt-codex-connector[bot]", CommentID: 700, + codexReview := ReviewSeen{Bot: dialect.CodexBotLogin, Commit: "abcdef1234567890", SubmittedAt: cutoff.Add(time.Minute)} + usageLimit := dialect.BotEvent{Kind: dialect.EvCodexUsageLimit, 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 @@ -397,7 +485,7 @@ func TestDynamicCodexGate(t *testing.T) { } // The configured-required gate is unchanged by a usage limit: it still waits. gated := policy - gated.RequiredBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"} + gated.RequiredBots = []string{"coderabbitai[bot]", dialect.CodexBotLogin} if got := Completion(r, limited, gated); got.Done { t.Fatalf("a usage limit must NOT disengage the configured-required Codex gate: %+v", got) } @@ -416,7 +504,7 @@ func TestCodexGatesCleanSummary(t *testing.T) { // Codex active in the round (a real Codex comment) without its review or // thumbs-up: the summary must NOT converge. - codexComment := dialect.BotEvent{Kind: dialect.EvOther, Bot: "chatgpt-codex-connector[bot]", CommentID: 2001, + 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 { @@ -432,8 +520,8 @@ func TestCodexGatesCleanSummary(t *testing.T) { // A Codex clean summary naming the head counts as Codex's review — and if // Codex gates the round, flips its ReviewedBy too. gated := policy - gated.RequiredBots = []string{"coderabbitai[bot]", "chatgpt-codex-connector[bot]"} - codexClean := dialect.BotEvent{Kind: dialect.EvCodexClean, Bot: "chatgpt-codex-connector[bot]", SHA: "abcdef1234", + gated.RequiredBots = []string{"coderabbitai[bot]", dialect.CodexBotLogin} + codexClean := dialect.BotEvent{Kind: dialect.EvCodexClean, 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 { diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 2c2742b..a4af331 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -4,6 +4,7 @@ import ( "strings" "time" + "github.com/kristofferR/coderabbit-queue/internal/dialect" "github.com/kristofferR/coderabbit-queue/internal/state" ) @@ -17,6 +18,7 @@ const ( 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 FireSupersede // observed head differs — supersede the round first FireDrop // PR closed/merged — abandon the round ) @@ -70,10 +72,12 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic return FireDecision{Verdict: FireNo, Reason: "min interval"} } // 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). + // 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. for _, review := range obs.Reviews { if sameBot(review.Bot, p.Bot) && review.Commit != "" && strings.HasPrefix(review.Commit, obs.Head) { - return FireDecision{Verdict: FireDedupe, Reason: "bot already reviewed head"} + return codexAwareDedupe(r, obs, p) } } // crq posts the Codex command in the same fire step for a configured-required @@ -98,3 +102,28 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic } return FireDecision{Verdict: FirePost, PostCodex: postCodex} } + +// 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 — keep the round queued and wait +// (FireNo). 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: FireNo, Reason: "awaiting codex auto review"} + } + return FireDecision{Verdict: FireDedupe, Reason: "bot already reviewed head"} +} From 58f6f86ecfc85b71fec6d78a4431ea1ef9f26b2d Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 21:06:17 +0200 Subject: [PATCH 11/20] Anchor the slot-wait timeout on the injectable clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the calibration-clock finding: Wait's WaitTimeout deadline now reads s.clock() like the quota-freshness path, so a replay test can drive the slot-wait timeout deterministically. Cadence timers (poll/log) stay on the wall clock — they gate real sleeps. --- internal/crq/service.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/crq/service.go b/internal/crq/service.go index 8101d2b..9888373 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -1192,13 +1192,16 @@ func isCommentCapError(err error) bool { // round rather than a separate wait record. func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, int, error) { repo = NormalizeRepo(repo) - start := time.Now() + // The slot-wait timeout anchors on the injectable clock so replay tests can + // drive it deterministically; cadence timers below stay on the wall clock + // because they gate real sleeps. + start := s.clock() enqueued := false var lastLog time.Time var lastFeedbackCheck time.Time feedbackCheckEvery := queuedFeedbackCheckEvery(s.cfg.PollInterval) for { - if s.cfg.WaitTimeout > 0 && time.Since(start) > s.cfg.WaitTimeout { + if s.cfg.WaitTimeout > 0 && s.clock().Sub(start) > s.cfg.WaitTimeout { return PumpResult{Action: "timeout", Repo: repo, PR: pr}, 2, nil } if !enqueued { @@ -1288,7 +1291,7 @@ func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, in if reason == "" { reason = result.Action } - s.log.Printf("%s#%d waiting for a review slot — %s (%s elapsed)", repo, pr, reason, time.Since(start).Round(time.Second)) + s.log.Printf("%s#%d waiting for a review slot — %s (%s elapsed)", repo, pr, reason, s.clock().Sub(start).Round(time.Second)) lastLog = time.Now() } select { From e92c3b1ebc6afc62c5ff76f89c9221c5db3643df Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 21:29:21 +0200 Subject: [PATCH 12/20] Harden shell-filter scope, post-failure backoff, and Codex auto-detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-three self-hosted-loop findings on the new head: - The empty-COMMENTED review filter (CodeRabbit's inline-comment carriers) is scoped to the configured reviewer, so it can never drop another bot's empty review that a Codex-gated round waits on. - Both fire paths (CodeRabbit and Codex-only) park in awaiting_retry with a bounded cooldown when a command post fails, instead of re-posting on the next pump — the same no-cooldown-requeue shape that fed the #448 spam. - CodexAutoActive keys on a command in the window since the previous Codex evidence, so a stale @codex review from an earlier round no longer masks a later unprompted review as commanded and suppresses posting forever. Declined on the PR: converting TestDecideFireCodexDedupe to a table test (the cases are already readable; churn), and giving the codex-wait branch a timed phase (it only fires when Codex evidence is imminent — auto-active, commanded, or already asked — and the loop's own deadline bounds the agent-facing wait). --- internal/crq/codex_replay_test.go | 89 +++++++++++++++++++++++++++++++ internal/crq/observe.go | 7 ++- internal/crq/replay_test.go | 4 +- internal/crq/service.go | 9 +++- internal/engine/codex.go | 46 ++++++++++------ internal/engine/engine_test.go | 7 +++ 6 files changed, 142 insertions(+), 20 deletions(-) diff --git a/internal/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go index 1445a65..b1707ca 100644 --- a/internal/crq/codex_replay_test.go +++ b/internal/crq/codex_replay_test.go @@ -1,6 +1,7 @@ package crq import ( + "errors" "strings" "testing" "time" @@ -287,3 +288,91 @@ func TestCodexReplayDedupeStillCommandsCodex(t *testing.T) { t.Fatalf("no coderabbit command may post across the scenario, got %d", got) } } + +// TestObserveScopesShellFilterToCodeRabbit pins fix #1: the empty-COMMENTED +// review filter (which drops CodeRabbit's inline-comment carrier shells) must +// not drop another bot's empty review — a Codex-gated round could be waiting on +// exactly that evidence. +func TestObserveScopesShellFilterToCodeRabbit(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} + }) + repo, pr, head := "o/r", 11, "aaaabbbbccccdddd" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + + // A CodeRabbit shell (empty COMMENTED) and a Codex empty COMMENTED review, + // both at head. + f.gh.mu.Lock() + key := fakeKey(repo, pr) + crShell := ghapi.Review{ID: 800, CommitID: head, State: "COMMENTED", SubmittedAt: base} + crShell.User.Login = f.bot + codexReview := ghapi.Review{ID: 801, CommitID: head, State: "COMMENTED", SubmittedAt: base} + codexReview.User.Login = codexLogin + f.gh.reviews[key] = []ghapi.Review{crShell, codexReview} + f.gh.mu.Unlock() + + obs, err := f.svc.observe(f.ctx, repo, pr, nil, f.clk.now()) + if err != nil { + t.Fatal(err) + } + var sawCR, sawCodex bool + for _, r := range obs.eng.Reviews { + if r.ReviewID == 800 { + sawCR = true + } + if r.ReviewID == 801 { + sawCodex = true + } + } + if sawCR { + t.Fatal("CodeRabbit's empty COMMENTED shell must be filtered out") + } + if !sawCodex { + t.Fatal("a non-CodeRabbit empty COMMENTED review must be kept as evidence") + } +} + +// TestFireCodexOnlyPostFailureParks 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) { + 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} + }) + 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. + f.botReview(repo, pr, 500, head, base.Add(-time.Minute)) + // The Codex command post fails. + f.gh.mu.Lock() + if f.gh.postErrs == nil { + f.gh.postErrs = map[string]error{} + } + f.gh.postErrs[fakeKey(repo, pr)] = errors.New("boom") + f.gh.mu.Unlock() + + f.enqueue(repo, pr) + // fireCodexOnly returns the post error alongside the post_failed result, so + // call Pump directly rather than through the fatal-on-error helper. + res, err := f.svc.Pump(f.ctx) + if res.Action != "post_failed" { + t.Fatalf("expected post_failed, got %+v (err=%v)", res, err) + } + r := f.round(repo, pr) + if r == nil || r.Phase != PhaseAwaitingRetry { + t.Fatalf("failed post must park the round, got %+v", r) + } + if r.RetryAt == nil || !r.RetryAt.Equal(f.clk.now().Add(postFailureBackoff)) { + t.Fatalf("park must carry the post-failure cooldown, got RetryAt=%v", r.RetryAt) + } + if r.FireEligible(f.clk.now()) { + t.Fatal("a just-parked round must not be immediately fire-eligible") + } + if r.FireEligible(f.clk.now().Add(postFailureBackoff)) == false { + t.Fatal("the round must become eligible once the cooldown passes") + } +} diff --git a/internal/crq/observe.go b/internal/crq/observe.go index 4ef4b2b..fb6b42e 100644 --- a/internal/crq/observe.go +++ b/internal/crq/observe.go @@ -55,8 +55,11 @@ func (s *Service) observe(ctx context.Context, repo string, pr int, round *Round // for its inline-comment batches, minutes before the real review (the // one with an "Actionable comments posted" body) lands. A shell is not // review evidence: counting one converged a round with zero findings at - // 17:26 while the real review was still posting until 17:32. - if strings.TrimSpace(review.Body) == "" && strings.EqualFold(review.State, "COMMENTED") { + // 17:26 while the real review was still posting until 17:32. Scope the + // filter to the configured reviewer — only CodeRabbit posts these + // carriers, and dropping another bot's empty review could discard real + // evidence a Codex-gated round waits on. + if s.isConfiguredBot(review.User.Login) && strings.TrimSpace(review.Body) == "" && strings.EqualFold(review.State, "COMMENTED") { continue } o.eng.Reviews = append(o.eng.Reviews, engine.ReviewSeen{ diff --git a/internal/crq/replay_test.go b/internal/crq/replay_test.go index 6bcffe7..7d2bd9a 100644 --- a/internal/crq/replay_test.go +++ b/internal/crq/replay_test.go @@ -183,8 +183,10 @@ func (f *replayFixture) editComment(repo string, pr int, id int64, body string, func (f *replayFixture) botReview(repo string, pr int, id int64, commitSHA string, at time.Time) { f.gh.mu.Lock() defer f.gh.mu.Unlock() + // A neutral non-empty body: the shell filter keys only on empty-vs-not, and + // real bot wording belongs in the dialect corpus, not a test fixture. r := ghapi.Review{ID: id, CommitID: commitSHA, State: "COMMENTED", SubmittedAt: at.UTC(), - Body: "**Actionable comments posted: 0**"} + Body: "[review body]"} r.User.Login = f.bot key := fakeKey(repo, pr) f.gh.reviews[key] = append(f.gh.reviews[key], r) diff --git a/internal/crq/service.go b/internal/crq/service.go index 9888373..a7eb99c 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -645,7 +645,7 @@ func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observa if r == nil || r.Token != token { return ErrNoChange } - if rerr := r.ReleaseToQueue("failed to post review command: "+err.Error(), now); rerr != nil { + if rerr := r.AwaitRetry(now.Add(postFailureBackoff), "failed to post review command: "+err.Error(), now); rerr != nil { return rerr } releaseSlot(st, key) @@ -727,7 +727,7 @@ func (s *Service) fireCodexOnly(ctx context.Context, round Round, reason string, if r == nil || r.Token != token { return ErrNoChange } - if rerr := r.ReleaseToQueue("failed to post codex review command: "+err.Error(), now); rerr != nil { + if rerr := r.AwaitRetry(now.Add(postFailureBackoff), "failed to post codex review command: "+err.Error(), now); rerr != nil { return rerr } releaseSlot(st, key) @@ -1190,6 +1190,11 @@ func isCommentCapError(err error) bool { // round for the head is the in-flight wait, a completed round is the "already // reviewed" dedup marker, and firedMarker/waitingHead read those states off the // round rather than a separate wait record. +// postFailureBackoff parks a round after a review-command post fails, so a +// persistent failure (auth, a 4xx, GitHub down past the client's own retries) +// retries on a bounded cadence instead of re-posting on every pump. +const postFailureBackoff = 2 * time.Minute + func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, int, error) { repo = NormalizeRepo(repo) // The slot-wait timeout anchors on the injectable clock so replay tests can diff --git a/internal/engine/codex.go b/internal/engine/codex.go index abe28b8..9a4ebda 100644 --- a/internal/engine/codex.go +++ b/internal/engine/codex.go @@ -83,24 +83,31 @@ func CodexActiveThisRound(r state.Round, obs Observation) bool { // 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, ok := latestCodexEvidence(obs) + latest, prev, ok := latestCodexEvidence(obs) if !ok { return false } - return !codexCommandAtOrBefore(obs, latest) + // 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 timestamp of the most recent Codex review or -// clean-summary event, and whether any exists. -func latestCodexEvidence(obs Observation) (time.Time, bool) { - var latest time.Time - ok := false +// 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 } - if !ok || at.After(latest) { - latest, ok = at, true + switch { + case !ok || at.After(latest): + prev, latest, ok = latest, at, true + case at.After(prev): + prev = at } } for _, review := range obs.Reviews { @@ -113,16 +120,25 @@ func latestCodexEvidence(obs Observation) (time.Time, bool) { consider(ev.PairTime()) } } - return latest, ok + return latest, prev, ok } -// codexCommandAtOrBefore reports whether an `@codex review` command was posted -// at or before t. -func codexCommandAtOrBefore(obs Observation, t time.Time) bool { +// 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 && !ev.PairTime().After(t) { - return true + 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 } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 33b7ed8..f740227 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -400,6 +400,13 @@ func TestCodexAutoActive(t *testing.T) { Reviews: []ReviewSeen{codexReview(t0), codexReview(t1.Add(time.Minute))}, Events: []dialect.BotEvent{codexCommand(t1)}, }, want: false}, + // An old command, a commanded review, then a LATER unprompted review: the + // stale command is before the previous evidence, so it must not mask the + // latest review as commanded — auto-review is active again. + {name: "stale command does not mask later auto review", obs: Observation{ + Reviews: []ReviewSeen{codexReview(t0.Add(time.Minute)), codexReview(t1)}, + Events: []dialect.BotEvent{codexCommand(t0)}, + }, want: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { From 545f02c8fb756bd32faf9af5bfb9805be5540efe Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 22:06:52 +0200 Subject: [PATCH 13/20] Bound the Codex co-review wait so a silent Codex can't hang the loop --- internal/crq/codex_replay_test.go | 55 ++++++++++++++++++++++++++++ internal/crq/service.go | 61 ++++++++++++++++++++++++++++++- internal/engine/engine_test.go | 53 +++++++++++++++++++++++++-- internal/engine/fire.go | 31 +++++++++------- internal/engine/progress.go | 24 ++++++++++++ internal/state/state.go | 26 +++++++++++++ internal/state/state_test.go | 45 +++++++++++++++++++++++ 7 files changed, 277 insertions(+), 18 deletions(-) diff --git a/internal/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go index b1707ca..4f158b6 100644 --- a/internal/crq/codex_replay_test.go +++ b/internal/crq/codex_replay_test.go @@ -289,6 +289,61 @@ func TestCodexReplayDedupeStillCommandsCodex(t *testing.T) { } } +// TestCodexReplayCoReviewWaitBoundsSilentCodex reproduces the hang a CodeRabbit +// review of our own PR surfaced: CodeRabbit posts a clean review at head with +// Codex configured-required and a `@codex review` command already on the PR (so +// crq must not repost — the FireCoReviewWait branch). Before the fix the round +// stayed queued with no WaitDeadline and Wait looped forever. Now a pump parks it +// in reviewing WITH a deadline; past the deadline a pump completes it on +// CodeRabbit's standing review rather than spinning, and crq never posts a second +// review command of either kind. +func TestCodexReplayCoReviewWaitBoundsSilentCodex(t *testing.T) { + base := time.Date(2026, 7, 17, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, func(cfg *Config) { + cfg.RequiredBots = []string{cfg.Bot, codexLogin} + }) + f.gh.graphQL = noForcePush // let the head-commit/force-push cutoff resolve so the codex command is adoptable + repo, pr, head := "o/r", 13, "aaaabbbbccccdddd" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + // 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.enqueue(repo, pr) + if res := f.pump(); res.Action != "waiting" { + t.Fatalf("expected a bounded co-review wait, got %+v", res) + } + r := f.round(repo, pr) + if r == nil || r.Phase != PhaseReviewing { + t.Fatalf("round must park in reviewing, not stay queued, got %+v", r) + } + if r.WaitDeadline == nil { + t.Fatalf("the co-review wait must be bounded by a WaitDeadline, got %+v", r) + } + if got := f.codexPosted(repo, pr); got != 0 { + t.Fatalf("the wait must not post a codex command, got %d", got) + } + if got := f.reviewsPosted(repo, pr); got != 0 { + t.Fatalf("the wait must not fire @coderabbitai review, got %d", got) + } + + // Codex stays silent past the deadline: the next pump completes the round on + // CodeRabbit's standing review (Progress OutComplete) rather than looping. + f.clk.advance(f.cfg.FeedbackWaitTimeout + time.Minute) + f.pump() + if r := f.round(repo, pr); r == nil || r.Phase != PhaseCompleted { + t.Fatalf("past the deadline the round must complete, got %+v", r) + } + if got := f.codexPosted(repo, pr); got != 0 { + t.Fatalf("no codex command may post across the scenario, got %d", got) + } + if got := f.reviewsPosted(repo, pr); got != 0 { + t.Fatalf("no coderabbit command may post across the scenario, got %d", got) + } +} + // TestObserveScopesShellFilterToCodeRabbit pins fix #1: the empty-COMMENTED // review filter (which drops CodeRabbit's inline-comment carrier shells) must // not drop another bot's empty review — a Codex-gated round could be waiting on diff --git a/internal/crq/service.go b/internal/crq/service.go index a7eb99c..8b88b1e 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -447,6 +447,8 @@ func (s *Service) applyFire(ctx context.Context, round Round, obs engine.Observa return s.dedupeRound(ctx, round, now, d.Reason) case engine.FireCodexOnly: return s.fireCodexOnly(ctx, round, d.Reason, 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: @@ -760,6 +762,60 @@ func (s *Service) fireCodexOnly(ctx context.Context, round Round, reason string, return PumpResult{Action: "fired", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil } +// newestCommandID returns the id of the most recently created adoptable command, +// or 0 when there are none. +func newestCommandID(cmds []engine.CommandSeen) int64 { + var best engine.CommandSeen + for _, c := range cmds { + if best.ID == 0 || c.CreatedAt.After(best.CreatedAt) { + best = c + } + } + return best.ID +} + +// fireCoReviewWait bounds a co-review wait: CodeRabbit already reviewed the head +// but a gating Codex has not yet, and crq must not post (Codex auto-reviews, or a +// command is already outstanding). Leaving the round queued with no WaitDeadline +// is the hang — Wait then loops forever. Park it in reviewing with a WaitDeadline +// instead: no slot is reserved and no command is posted. An existing `@codex +// review` command on the PR is adopted as the round's CodexCommandID so the +// self-heal path (which anchors on the round's fire time, later than a pre-existing +// command) does not re-post it. +func (s *Service) fireCoReviewWait(ctx context.Context, round Round, obs engine.Observation, reason string, now time.Time) (PumpResult, error) { + result := PumpResult{Action: "waiting", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason} + if s.cfg.DryRun { + return result, nil + } + codexID := newestCommandID(obs.CodexCommands) + changed := false + updated, err := s.store.Update(ctx, func(st *State) error { + changed = false + r := st.Round(round.Repo, round.PR) + if !sameRound(r, round) || !r.FireEligible(now) { + return ErrNoChange + } + deadline := now.Add(s.cfg.FeedbackWaitTimeout) + if err := r.AwaitCoReview(deadline, now); err != nil { + return err + } + if r.CodexCommandID == 0 && codexID != 0 { + r.CodexCommandID = codexID + } + st.PutRound(*r) + changed = true + return nil + }) + if err != nil { + return PumpResult{}, err + } + if !changed { + return PumpResult{Action: "lost_race"}, nil + } + s.sync(ctx, updated) + return result, nil +} + // 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 @@ -1273,7 +1329,10 @@ func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, in if err != nil { return PumpResult{}, 1, err } - if r := state.Round(repo, pr); r != nil && r.Phase == PhaseFired { + if r := state.Round(repo, pr); r != nil && (r.Phase == PhaseFired || r.Phase == PhaseReviewing) { + // A reviewing round is in flight (a fired ack, or a bounded co-review + // wait): advance from the slot wait into feedback polling, which the + // WaitDeadline bounds — don't spin here. return PumpResult{Action: "fired", Repo: repo, PR: pr, Head: r.Head}, 0, nil } if !state.ContainsActive(repo, pr) { diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index f740227..ae242bd 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -175,6 +175,46 @@ func TestInflightTimeoutCarriesCooldown(t *testing.T) { } } +// TestReviewingRoundDeadlineBoundsCoReviewWait covers the daemon-side co-review +// bound: a reviewing round past its WaitDeadline completes when the primary bot +// reviewed the head (its review stands; give up on the silent co-bot). Without a +// primary review it keeps waiting — the loop bounds and times out its own wait, +// so the daemon never resets or re-fires an expired head. Before the deadline it +// keeps waiting on the co-bot too. +func TestReviewingRoundDeadlineBoundsCoReviewWait(t *testing.T) { + codexReq := policy + codexReq.RequiredBots = []string{"coderabbitai[bot]", dialect.CodexBotLogin} + codexReq.CodexCommand = "@codex review" + + reviewing := func() state.Round { + r := firedRound(t, "abcdef123") + if err := r.Acknowledge(); err != nil { + t.Fatal(err) + } + dl := t0.Add(time.Hour) + r.WaitDeadline = &dl + return r + } + crAtHead := Observation{Head: "abcdef123", Open: true, + Reviews: []ReviewSeen{{Bot: "coderabbitai[bot]", Commit: "abcdef1234567890", SubmittedAt: t0}}} + + // At the deadline with the primary review standing → complete (co-bot gave up). + past := t0.Add(time.Hour).Add(time.Second) + if tr := Progress(reviewing(), state.AccountQuota{}, crAtHead, past, codexReq); tr.Outcome != OutComplete { + t.Fatalf("primary review at head past the deadline must complete, got %+v", tr) + } + // At the deadline with NO primary review → keep waiting (the loop times out its + // own wait; the daemon must not reset the deadline or re-fire the head). + noReview := Observation{Head: "abcdef123", Open: true} + if tr := Progress(reviewing(), state.AccountQuota{}, noReview, past, codexReq); tr.Outcome != KeepWaiting { + t.Fatalf("no primary review past the deadline must keep waiting, not re-fire, got %+v", tr) + } + // Before the deadline the bound must not fire: keep waiting on the co-bot. + if tr := Progress(reviewing(), state.AccountQuota{}, crAtHead, t0.Add(30*time.Minute), codexReq); tr.Outcome != OutReviewing { + t.Fatalf("before the deadline a co-review wait must keep waiting, got %+v", tr) + } +} + func TestDecideFireGuards(t *testing.T) { free := Global{SlotFree: true} now := t0.Add(10 * time.Minute) @@ -438,10 +478,17 @@ func TestDecideFireCodexDedupe(t *testing.T) { if d := DecideFire(free, queued, obs, now, codexReq); d.Verdict != FireCodexOnly { 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. + // 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}} - if d := DecideFire(free, queued, autoObs, now, codexReq); d.Verdict != FireNo { - t.Fatalf("auto-active codex must wait, not dedupe, got %+v", d) + 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}}} + if d := DecideFire(free, queued, cmdObs, now, codexReq); d.Verdict != FireCoReviewWait { + t.Fatalf("an outstanding codex command must wait (bounded), got %+v", d) } // Codex already reviewed the head → the round is genuinely done. doneObs := Observation{Head: head, Open: true, Reviews: []ReviewSeen{crReviewed, codexReviewed}} diff --git a/internal/engine/fire.go b/internal/engine/fire.go index a4af331..79803dc 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -14,13 +14,14 @@ 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 - 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 + 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 + FireSupersede // observed head differs — supersede the round first + FireDrop // PR closed/merged — abandon the round ) type FireDecision struct { @@ -108,12 +109,14 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic // 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 — keep the round queued and wait -// (FireNo). 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. +// 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) { @@ -123,7 +126,7 @@ func codexAwareDedupe(r state.Round, obs Observation, p Policy) FireDecision { return FireDecision{Verdict: FireCodexOnly, Reason: "coderabbit reviewed head; codex still required"} } if obs.CodexAutoActive || len(obs.CodexCommands) > 0 || r.CodexCommandID != 0 { - return FireDecision{Verdict: FireNo, Reason: "awaiting codex auto review"} + return FireDecision{Verdict: FireCoReviewWait, Reason: "awaiting codex co-review"} } return FireDecision{Verdict: FireDedupe, Reason: "bot already reviewed head"} } diff --git a/internal/engine/progress.go b/internal/engine/progress.go index ea03f9a..0143513 100644 --- a/internal/engine/progress.go +++ b/internal/engine/progress.go @@ -61,6 +61,18 @@ func Progress(r state.Round, q state.AccountQuota, obs Observation, now time.Tim firedAt := r.FiredAt.UTC() completion := Completion(r, obs, p) + // A reviewing round past its wait deadline whose primary review already + // stands: a gating co-bot (Codex) has gone silent too long, so give up on it — + // the primary review stands, and re-firing a head the primary already reviewed + // would spam. Checked before the review loop below, which would otherwise hold + // a co-review wait open forever on the primary review's ack. A reviewing round + // 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"} + } + // An "already reviewed" ack is only trusted alongside real review // evidence; a review matching the round completes or hands off the wait. for _, review := range obs.Reviews { @@ -150,6 +162,18 @@ func resolveBlockWindow(ev dialect.BotEvent, q state.AccountQuota, now time.Time return until.UTC() } +// primaryReviewedHead reports whether the configured primary bot has a submitted +// review whose commit prefixes the round's head — the review that stands when a +// co-review wait gives up on a silent co-bot. +func primaryReviewedHead(r state.Round, obs Observation, p Policy) bool { + for _, review := range obs.Reviews { + if sameBot(review.Bot, p.Bot) && r.Head != "" && review.Commit != "" && strings.HasPrefix(review.Commit, r.Head) { + return true + } + } + return false +} + // reviewMatchesRound mirrors v2: a known head must match the review commit; // submission time alone could otherwise let a delayed review of an older // head complete the new one. diff --git a/internal/state/state.go b/internal/state/state.go index c24115e..0292b92 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -249,6 +249,32 @@ func (r *Round) AwaitRetry(retryAt time.Time, reason string, now time.Time) erro return nil } +// AwaitCoReview bounds a co-review wait: the configured primary bot already +// reviewed the head, but a gating co-bot (Codex) has not — so crq waits for it, +// bounded by deadline, WITHOUT posting a command or holding the fire slot. Legal +// from queued|awaiting_retry|fired|reviewing → reviewing. FiredAt is the wait +// anchor: the primary review already stands in as the fire, so it is set to now +// only when no fire was recorded. Token/ReservedAt are cleared (no slot is held); +// CodexCommandID is left as-is so an existing Codex command is not re-posted. +func (r *Round) AwaitCoReview(deadline, now time.Time) error { + switch r.Phase { + case PhaseQueued, PhaseAwaitingRetry, PhaseFired, PhaseReviewing: + default: + return r.illegal(PhaseReviewing) + } + r.Phase = PhaseReviewing + dl := deadline.UTC() + r.WaitDeadline = &dl + if r.FiredAt == nil { + t := now.UTC() + r.FiredAt = &t + } + r.Token = "" + r.ReservedAt = nil + r.Note = "awaiting codex co-review" + return nil +} + // Complete finishes the round: fired|reviewing → completed. A completed round // stays in Rounds (it IS the "this head was reviewed" dedup marker) until a // new head supersedes it or the PR closes. diff --git a/internal/state/state_test.go b/internal/state/state_test.go index e09aeee..27d5052 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -144,6 +144,51 @@ func TestReleaseToQueueKeepsAdoptionCutoff(t *testing.T) { } } +// TestAwaitCoReviewBoundsTheWait covers the co-review wait: a queued round with +// CodeRabbit's review already standing moves to reviewing with a deadline and a +// fire anchor (so the wait is bounded), and the transition is illegal from a +// finished round. +func TestAwaitCoReviewBoundsTheWait(t *testing.T) { + s := New() + r, err := s.NewRound("owner/repo", 14, "abcdef123", t0) + if err != nil { + t.Fatal(err) + } + deadline := t0.Add(time.Hour) + if err := r.AwaitCoReview(deadline, t0.Add(time.Second)); err != nil { + t.Fatal(err) + } + if r.Phase != PhaseReviewing { + t.Fatalf("await co-review must move to reviewing, got %s", r.Phase) + } + if r.WaitDeadline == nil || !r.WaitDeadline.Equal(deadline) { + t.Fatalf("wait deadline must be set, got %+v", r.WaitDeadline) + } + if r.FiredAt == nil || !r.FiredAt.Equal(t0.Add(time.Second)) { + t.Fatalf("no prior fire must anchor FiredAt at now, got %+v", r.FiredAt) + } + if r.Token != "" || r.ReservedAt != nil { + t.Fatalf("co-review wait holds no slot: token=%q reserved=%+v", r.Token, r.ReservedAt) + } + + // Illegal from a finished round. + s2 := New() + completed := newFired(t, &s2) + if err := completed.Complete(); err != nil { + t.Fatal(err) + } + var te *TransitionError + if err := completed.AwaitCoReview(deadline, t0); !errors.As(err, &te) { + t.Fatalf("await co-review from completed must be illegal, got %v", err) + } + s3 := New() + abandoned := newFired(t, &s3) + abandoned.Abandon("cancelled") + if err := abandoned.AwaitCoReview(deadline, t0); !errors.As(err, &te) { + t.Fatalf("await co-review from abandoned must be illegal, got %v", err) + } +} + func TestOneRoundPerPR(t *testing.T) { s := New() if _, err := s.NewRound("Owner/Repo", 7, "abcdef123", t0); err != nil { From ae061d50f10dc7f2388e9c0b91da0384201afbdf Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 22:13:10 +0200 Subject: [PATCH 14/20] Surface contested bot replies to declined findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent declines a finding with 'crq decline --resolve', the bot replies either conceding ('I'm withdrawing this finding') or contesting ('I'm retaining the finding: ...'). crq dropped resolved threads wholesale, so a valid rebuttal vanished and the loop converged over an unaddressed disagreement — exactly how the real #30 review caught a bug I'd wrongly declined. Now the feedback pass reads that reply: dialect gains the verdict classifiers IsReviewFindingWithdrawn/IsReviewFindingRetained (golden-pinned with CodeRabbit's actual concede/contest messages from PR #30), and feedback.go re-surfaces a resolved thread whose latest comment is a bot reply following the agent's own comment and not a clear withdrawal, as a 'review_reply' finding. Ambiguous replies surface too — never bury a possible rebuttal on a false concession. The loop then holds until the agent re-addresses it (fix, or decline again convincingly enough that the bot withdraws). --- AGENTS.md | 8 +- README.md | 6 ++ internal/crq/feedback.go | 63 ++++++++++++++ internal/crq/feedback_test.go | 83 +++++++++++++++++++ internal/dialect/golden_test.go | 24 ++++++ internal/dialect/reply.go | 39 +++++++++ .../testdata/coderabbit/reply-retained.md | 7 ++ .../testdata/coderabbit/reply-withdrawn.md | 1 + llms.txt | 6 ++ 9 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 internal/dialect/reply.go create mode 100644 internal/dialect/testdata/coderabbit/reply-retained.md create mode 100644 internal/dialect/testdata/coderabbit/reply-withdrawn.md diff --git a/AGENTS.md b/AGENTS.md index 6aec2b0..2cbe851 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,9 +12,11 @@ Dependency rule (Go-enforced, no cycles): `dialect ← engine ← crq`, `state - `internal/dialect/` — ALL bot-text knowledge, zero deps. CodeRabbit/Codex completion, rate-limit, paused, in-progress, failed and clean-review - classifiers; finding parsers; SHA/severity vocabulary; the `Finding` type - (frozen JSON tags); the typed `BotEvent`/`Classifier`. The only place a bot's - literal wording may appear. + 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". diff --git a/README.md b/README.md index d28c10a..88fde12 100644 --- a/README.md +++ b/README.md @@ -402,6 +402,12 @@ feedback. crq keys resolution off GitHub's own thread state, so a finding keeps surfaces still-open findings from earlier commits, so nothing is silently dropped between passes. A finding without `thread_id` came from a review body or comment GitHub can't expose as a resolvable thread; CodeRabbit clears those on its next review. + +`source: "review_reply"` is special: when you `crq decline --resolve` a finding and the bot replies +**contesting** the decline ("I'm retaining the finding: …") rather than conceding ("I'm withdrawing +this finding"), crq re-surfaces that rebuttal as a finding so the loop won't converge over a rebuttal +you haven't answered. Fix it, or `crq decline` again with a stronger reason; a bot that then withdraws +clears it. Ambiguous replies surface too — crq never buries a possible rebuttal on a false concession.
## Configuration diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index ec94808..41dd881 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -132,6 +132,12 @@ func (s *Service) Feedback(ctx context.Context, repo string, pr int) (FeedbackRe for _, key := range promptSuppressKeys(thread, extractBots) { suppressPromptAt[key] = true } + // A resolved thread where the bot got the last word contesting the + // agent's decline is NOT actually settled — surface the rebuttal so + // the loop re-addresses it instead of silently dropping it. + if rebuttal := threadRebuttal(thread, extractBots); rebuttal != nil { + report.Findings = append(report.Findings, *rebuttal) + } } } } else if ghapi.IsThrottled(err) { @@ -823,6 +829,63 @@ func reviewNewer(a, b ghapi.Review) bool { return a.ID > b.ID } +// threadRebuttal surfaces a bot's contested reply on a RESOLVED thread as a +// finding. When the agent declines a finding with `crq decline --resolve`, the +// bot often replies conceding ("I'm withdrawing this finding") or contesting +// ("I'm retaining the finding: ..."). threadFindings drops resolved threads, so +// a contest would vanish and the loop would converge over an unaddressed +// rebuttal. This re-surfaces it: the thread's latest comment is a bot reply that +// follows the agent's own comment and does not clearly withdraw the finding. +// Ambiguous replies surface too — never bury a rebuttal on a false concession. +// Returns nil when the thread is unresolved (threadFindings already covers it), +// when the last word is not the bot's, when the agent never replied, or when the +// bot withdrew. +func threadRebuttal(thread reviewThread, bots map[string]struct{}) *dialect.Finding { + if !thread.IsResolved && !thread.IsOutdated { + return nil + } + nodes := thread.Comments.Nodes + if len(nodes) < 2 { + return nil + } + last := nodes[len(nodes)-1] + if !dialect.InBots(bots, last.Author.Login) { + return nil // the agent, not the bot, had the last word + } + agentReplied := false + for _, c := range nodes[:len(nodes)-1] { + if !dialect.InBots(bots, c.Author.Login) { + agentReplied = true + break + } + } + if !agentReplied { + return nil // the bot is talking to itself, not answering a decline + } + if dialect.IsReviewFindingWithdrawn(last.Body) { + return nil // conceded — the decline stands + } + // A contested decline deserves attention even when the finding's own severity + // is a nitpick, so floor an unknown severity at major. + severity := dialect.SeverityOf(last.Body) + if severity == "unknown" { + severity = "major" + } + return &dialect.Finding{ + Bot: last.Author.Login, + Severity: severity, + Path: firstNonEmpty(thread.Path, last.Path), + Line: firstPositive(thread.Line, last.Line, last.OriginalLine), + Title: "Reviewer contests your reply — re-address or reply again: " + dialect.TitleOf(last.Body), + Body: strings.TrimSpace(last.Body), + ThreadID: thread.ID, + CommentID: last.DatabaseID, + URL: last.URL, + Source: "review_reply", + CreatedAt: last.CreatedAt, + } +} + // threadFindings turns one GitHub review thread into findings. An unresolved, // non-outdated thread is still actionable no matter which commit its comments // were filed on: GitHub's own resolution/outdated state is the source of truth, diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index 9862efe..b75989b 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -1877,3 +1877,86 @@ func TestCodexCleanSummaryFormats(t *testing.T) { t.Fatal("different shas must not match") } } + +// addThreadComment appends a comment to a reviewThread (the node type is +// anonymous, so this hides the verbose literal). +func addThreadComment(th *reviewThread, id int64, login, body string) { + node := th.Comments.Nodes[:0:0] + _ = node + var n struct { + DatabaseID int64 `json:"databaseId"` + Body string `json:"body"` + URL string `json:"url"` + Path string `json:"path"` + Line int `json:"line"` + OriginalLine int `json:"originalLine"` + CreatedAt time.Time `json:"createdAt"` + Author struct { + Login string `json:"login"` + } `json:"author"` + Commit struct { + OID string `json:"oid"` + } `json:"commit"` + OriginalCommit struct { + OID string `json:"oid"` + } `json:"originalCommit"` + } + n.DatabaseID = id + n.Body = body + n.Author.Login = login + th.Comments.Nodes = append(th.Comments.Nodes, n) +} + +func TestThreadRebuttalSurfacesContestedResolvedThreads(t *testing.T) { + bots := dialect.BotSet([]string{"coderabbitai[bot]"}) + newThread := func(resolved bool) reviewThread { + return reviewThread{ID: "PRRT_x", Path: "internal/engine/fire.go", Line: 126, IsResolved: resolved} + } + + // Contested rebuttal on a resolved thread → surfaced. + th := newThread(true) + addThreadComment(&th, 1, "coderabbitai", "**Potential issue** the wait is unbounded") + addThreadComment(&th, 2, "kristofferR", "Declined: the loop deadline bounds it.") + addThreadComment(&th, 3, "coderabbitai", "I'm retaining the finding: adopt/record the existing command into a timed waiting round.") + if got := threadRebuttal(th, bots); got == nil { + t.Fatal("a contested bot reply on a resolved thread must surface") + } else if got.Source != "review_reply" || got.ThreadID != "PRRT_x" || got.CommentID != 3 { + t.Fatalf("rebuttal finding mismatch: %#v", got) + } + + // Withdrawn rebuttal → not surfaced. + th = newThread(true) + addThreadComment(&th, 1, "coderabbitai", "**Potential issue** duplicate declaration") + addThreadComment(&th, 2, "kristofferR", "Declined: it compiles, single declaration.") + addThreadComment(&th, 3, "coderabbitai", "You're right—my finding was incorrect. I'm withdrawing this comment.") + if got := threadRebuttal(th, bots); got != nil { + t.Fatalf("a withdrawn finding must not surface, got %#v", got) + } + + // Ambiguous bot reply after the agent → surfaced (never bury a rebuttal). + th = newThread(true) + addThreadComment(&th, 1, "coderabbitai", "**Nitpick** rename this") + addThreadComment(&th, 2, "kristofferR", "Declined: name is intentional.") + addThreadComment(&th, 3, "coderabbitai", "Here is some additional context on the naming convention.") + if got := threadRebuttal(th, bots); got == nil { + t.Fatal("an ambiguous (non-withdrawal) reply must surface by default") + } else if got.Severity != "major" { + t.Fatalf("an unknown-severity rebuttal must floor at major, got %q", got.Severity) + } + + // No agent reply (just the bot's finding) → not a rebuttal. + th = newThread(true) + addThreadComment(&th, 1, "coderabbitai", "**Potential issue** fix this") + if got := threadRebuttal(th, bots); got != nil { + t.Fatalf("a lone bot finding is not a rebuttal, got %#v", got) + } + + // Unresolved thread → threadFindings already covers it; no double-surface. + th = newThread(false) + addThreadComment(&th, 1, "coderabbitai", "**Potential issue** the wait is unbounded") + addThreadComment(&th, 2, "kristofferR", "Declined.") + addThreadComment(&th, 3, "coderabbitai", "I'm retaining the finding.") + if got := threadRebuttal(th, bots); got != nil { + t.Fatalf("unresolved threads are handled by threadFindings, got %#v", got) + } +} diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index 776776a..bb59b25 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -198,3 +198,27 @@ func TestGoldenFindings(t *testing.T) { }) } } + +// TestGoldenReplyVerdict pins the concede/contest classification of a bot's +// reply to the agent's decline, using CodeRabbit's real replies from PR #30. +func TestGoldenReplyVerdict(t *testing.T) { + cases := []struct { + file string + withdrawn bool + retained bool + }{ + {file: "coderabbit/reply-withdrawn.md", withdrawn: true}, + {file: "coderabbit/reply-retained.md", retained: true}, + } + for _, tc := range cases { + t.Run(tc.file, func(t *testing.T) { + body := readGolden(t, tc.file) + if got := IsReviewFindingWithdrawn(body); got != tc.withdrawn { + t.Errorf("IsReviewFindingWithdrawn = %v, want %v", got, tc.withdrawn) + } + if got := IsReviewFindingRetained(body); got != tc.retained { + t.Errorf("IsReviewFindingRetained = %v, want %v", got, tc.retained) + } + }) + } +} diff --git a/internal/dialect/reply.go b/internal/dialect/reply.go new file mode 100644 index 0000000..da3e4a2 --- /dev/null +++ b/internal/dialect/reply.go @@ -0,0 +1,39 @@ +package dialect + +import "strings" + +// A bot replies to the agent's decline of a review finding with one of two +// verdicts: it withdraws the finding (concedes) or retains it (contests). crq +// must read that reply — a contested rebuttal on a thread the agent already +// resolved would otherwise be silently dropped, and the agent would never learn +// the reviewer stood its ground. These classifiers own that wording; the +// surfacing decision lives in crq/feedback. The golden corpus pins CodeRabbit's +// real phrasing from testdata/coderabbit/reply-*.md. + +// 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 { + t := NormalizeReviewText(text) + return strings.Contains(t, "withdrawing this") || + strings.Contains(t, "withdrawing the finding") || + strings.Contains(t, "withdrawing my") || + strings.Contains(t, "i'll withdraw") || + strings.Contains(t, "i will withdraw") || + strings.Contains(t, "finding was incorrect") || + strings.Contains(t, "my mistake") +} + +// IsReviewFindingRetained reports whether a bot's reply retains or contests its +// finding despite the agent's decline — a rebuttal the agent must re-address. +func IsReviewFindingRetained(text string) bool { + t := NormalizeReviewText(text) + return strings.Contains(t, "retaining the finding") || + strings.Contains(t, "retaining this") || + strings.Contains(t, "keeping this finding") || + strings.Contains(t, "keeping the finding") || + strings.Contains(t, "i disagree") || + strings.Contains(t, "i'm not convinced") || + strings.Contains(t, "still stands") || + strings.Contains(t, "still applies") || + strings.Contains(t, "still holds") +} diff --git a/internal/dialect/testdata/coderabbit/reply-retained.md b/internal/dialect/testdata/coderabbit/reply-retained.md new file mode 100644 index 0000000..f554c06 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/reply-retained.md @@ -0,0 +1,7 @@ +`@kristofferR`, I rechecked the apply and deadline paths, and the bounded loop does not cover this branch as implemented: + +- `applyFire` handles `FireNo` without mutating the round. +- `ensureWaitDeadline` only writes a deadline for `PhaseFired` or `PhaseReviewing`. +- This branch can therefore leave a queued/awaiting-retry round with no `WaitDeadline`; a missing Codex response then remains eligible for repeated `FireNo` decisions. + +The "evidence is imminent" condition is a reasonable operational expectation, but it is not a timeout guarantee. I'm retaining the finding: adopt/record the existing Codex command into a timed waiting round, or introduce a persisted wait outcome for this case. diff --git a/internal/dialect/testdata/coderabbit/reply-withdrawn.md b/internal/dialect/testdata/coderabbit/reply-withdrawn.md new file mode 100644 index 0000000..bfe4282 --- /dev/null +++ b/internal/dialect/testdata/coderabbit/reply-withdrawn.md @@ -0,0 +1 @@ +`@kristofferR`, you're right—my finding was incorrect. The current `internal/crq/service_test.go` declares `retryNoChangeStore` only once, so there is no Go redeclaration error. I'm withdrawing this comment. Sorry for the noise. diff --git a/llms.txt b/llms.txt index 725bc27..58551b4 100644 --- a/llms.txt +++ b/llms.txt @@ -92,6 +92,12 @@ Decline a finding you are not addressing: post the reason on its thread (left un crq decline OWNER/REPO PR_NUMBER --thread THREAD_ID --reason "why declined" ``` +If the bot replies contesting a decline you resolved ("I'm retaining the finding: ..."), crq +re-surfaces that reply as a `source: "review_reply"` finding on the next `feedback`/`loop`, so a +convergence never hides a rebuttal you have not answered. Address it or decline again with a stronger +reason; the loop clears it once the bot withdraws (or you stop declining and fix it). Ambiguous bot +replies surface too — crq errs toward showing a possible rebuttal rather than burying it. + Current feedback without triggering a review: ```bash From f673edee0208ccff3fc683ebcee7ecba4d9dd635 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Fri, 17 Jul 2026 22:19:16 +0200 Subject: [PATCH 15/20] Fix three concurrency and quota bugs Codex found dogfooding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex auto-reviewed PR #30 and caught three real edges: - readQuota/RefreshQuota wiped a still-active account block when a calibration probe was inconclusive (no fresh reply): after CalibrationTTL the block vanished and Pump could fire queued reviews inside the original blocked window. The refresh now carries a live block forward until a conclusive reply lands. - The loop completed a fired/reviewing round on findings-return even when a required (or dynamically-gating) bot was still pending — so a Codex finding posted before CodeRabbit's review released the slot and stopped observing CodeRabbit's review/rate-limit/timeout, losing evidence. It now leaves the round active while any reviewer is pending, matching hold-the-head; the co-review wait deadline bounds it. - sweepReviewing's CAS lacked the sameRound(Seq+Head) identity guard the other mutations use, so a supersede between observe and write could apply an old head's Progress to a replacement round. Guarded. TestLoopResumesAwaitingFeedbackWithoutRefiring updated: a round with a pending dynamically-gating Codex now correctly stays active (not completed), still without re-firing. --- internal/crq/feedback.go | 7 ++++++- internal/crq/feedback_test.go | 9 +++++++-- internal/crq/service.go | 18 +++++++++++++++-- internal/crq/service_test.go | 38 +++++++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 41dd881..c7da827 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -360,10 +360,15 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport report.Status = "feedback" if allReviewed(report.ReviewedBy) { report.Reason = "all required reviewers finished; address findings, push once, and resolve threads" + s.completeWaitRound(ctx, repo, pr, head) } else { + // A required reviewer is still pending (e.g. Codex posted a finding + // before CodeRabbit reviewed). Return the findings to work on, but leave + // the round active — completing it would release the slot and stop + // observing the pending bot's review/rate-limit/timeout, losing evidence + // the loop is still obligated to wait for. report.Reason = "hold current head: fix locally, but do not commit or push until every required reviewer finishes" } - s.completeWaitRound(ctx, repo, pr, head) return report, 10, nil } if report.Converged { diff --git a/internal/crq/feedback_test.go b/internal/crq/feedback_test.go index b75989b..c54f47f 100644 --- a/internal/crq/feedback_test.go +++ b/internal/crq/feedback_test.go @@ -1526,8 +1526,13 @@ func TestLoopResumesAwaitingFeedbackWithoutRefiring(t *testing.T) { if err != nil { t.Fatal(err) } - if state.WaitingHead("owner/repo", 12) != "" { - t.Fatalf("feedback wait should clear after findings are collected") + // Codex posted a finding but no review verdict, so it dynamically gates this + // round and is still pending. Hold-the-head keeps the round active (not + // completed) so the daemon keeps observing Codex's review/timeout — the + // co-review wait deadline bounds it. The invariant this test guards is that + // resuming does not re-fire, which still holds. + if state.WaitingHead("owner/repo", 12) != "abcdef123" { + t.Fatalf("round must stay active while a gating reviewer is pending, got %q", state.WaitingHead("owner/repo", 12)) } if state.FiredMarker("owner/repo", 12) != "abcdef123" { t.Fatalf("fired marker should remain for dedupe after collection") diff --git a/internal/crq/service.go b/internal/crq/service.go index 8b88b1e..4c6086a 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -419,7 +419,10 @@ func (s *Service) sweepReviewing(ctx context.Context, st State, now time.Time) ( } updated, err := s.store.Update(ctx, func(st *State) error { r := st.Round(target.Repo, target.PR) - if r == nil || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) { + // Guard on round identity: a supersede/cancel-and-re-enqueue between observe + // and this CAS could otherwise apply the old head's Progress to a replacement + // round for a newer head, deduping or cooling it on stale observations. + if r == nil || !sameRound(r, *target) || (r.Phase != PhaseFired && r.Phase != PhaseReviewing) { return ErrNoChange } return s.applyTransition(st, r, tr, now) @@ -974,12 +977,23 @@ func (s *Service) RefreshQuota(ctx context.Context) (State, error) { // identity over so the engine can still recognise an edited comment it // already accounted for. rlID, rlUpdated := st.Account.RLCommentID, st.Account.RLCommentUpdated + prevBlock, prevRemaining := st.Account.BlockedUntil, st.Account.Remaining st.Account = quota if st.Account.RLCommentID == 0 { st.Account.RLCommentID = rlID st.Account.RLCommentUpdated = rlUpdated } - if st.Warn == warnRateLimited && (quota.BlockedUntil == nil || !quota.BlockedUntil.After(now)) { + // An inconclusive probe (still awaiting a calibration reply) carries no + // BlockedUntil, but it is not evidence the account is clear. Preserve a + // still-active block until a conclusive reply lands — otherwise the block + // vanishes after CalibrationTTL and Pump fires queued reviews inside the + // original window, recreating the duplicate attempts this whole system + // exists to prevent. + if quota.CalibAskedAt != nil && prevBlock != nil && prevBlock.After(now) { + st.Account.BlockedUntil = prevBlock + st.Account.Remaining = prevRemaining + } + if st.Warn == warnRateLimited && (st.Account.BlockedUntil == nil || !st.Account.BlockedUntil.After(now)) { st.Warn = "" } return nil diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go index b6aaca7..ece8318 100644 --- a/internal/crq/service_test.go +++ b/internal/crq/service_test.go @@ -1809,3 +1809,41 @@ func TestParseAvailableInPlainFormatStillWorks(t *testing.T) { t.Fatalf("expected base+90m for compound duration, got %v", got) } } + +// TestRefreshQuotaPreservesBlockOnInconclusiveProbe pins the anti-spam fix for +// the calibration path: when a probe is still pending (no fresh reply), a live +// account block must survive the refresh instead of being wiped after the TTL, +// which would let Pump fire queued reviews inside the blocked window. +func TestRefreshQuotaPreservesBlockOnInconclusiveProbe(t *testing.T) { + ctx := context.Background() + cfg := Config{ + GateRepo: "owner/gate", StateRef: "crq-state-v3", CalibrationPR: 1, + CalibrationMarker: "auto-generated reply by CodeRabbit", + RateLimitCommand: "@coderabbitai rate limit", + CalibrationTTL: 2 * time.Minute, Scope: []string{"owner"}, + } + gh := newFakeGitHub() // no calibration reply on the gate issue → probe stays inconclusive + store := NewMemoryStore(cfg) + now := time.Now().UTC() + block := now.Add(30 * time.Minute) + askedAt := now.Add(-time.Minute) // a pending probe, still within the TTL window + checkedAt := now.Add(-time.Minute) + if _, err := store.Update(ctx, func(st *State) error { + st.Account.BlockedUntil = &block + st.Account.CalibAskedAt = &askedAt + st.Account.CheckedAt = &checkedAt + st.Account.Source = "warning" + return nil + }); err != nil { + t.Fatal(err) + } + svc := NewService(cfg, gh, store, nil) + + updated, err := svc.RefreshQuota(ctx) + if err != nil { + t.Fatal(err) + } + if updated.Account.BlockedUntil == nil || !updated.Account.BlockedUntil.Equal(block) { + t.Fatalf("an inconclusive probe must preserve the live block %v, got %v", block, updated.Account.BlockedUntil) + } +} From 86c0c150770cb068bf90a3af3259498797e364f7 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 18 Jul 2026 00:09:43 +0200 Subject: [PATCH 16/20] Address round-four findings: quota bypass, claim-before-post, and wait anchors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebuttal reader's first live run surfaced Codex's platform boilerplate as a false rebuttal; its 'create an environment' notice is now classified non-actionable in dialect (corpus-pinned) and threadRebuttal skips non-actionable replies generally. Codex's round-four findings, all real: - Codex-only resolutions (dedupe, FireCodexOnly, co-review wait) spend no CodeRabbit quota, so DecideFire resolves them BEFORE the account-block and pacing gates — a block from another PR no longer delays them. - The self-heal Codex post claims the round under CAS (CodexClaimedAt, 2-minute TTL) before the network call, so two unserialized sweepers can no longer double-post; the claim is released on record or failure. Codex flagged this race three times across rounds — it was right that the observation-guard alone left a real window. - A reviewing round that would re-acknowledge now returns KeepWaiting, so a silent co-bot wait no longer writes identical state and re-syncs the dashboard on every pump. - AwaitCoReview anchors at the adopted @codex command's time, not the observation time, so a SHA-less legacy clean summary posted before the pump still counts toward completion. - A PR closed while parked in awaiting_retry is abandoned on the next pump (sweepParkedClosed) instead of surviving its cooldown window. - threadRebuttal only trusts complete threads: the reviewThreads query carries totalCount and a partially-fetched thread is skipped rather than judged on a stale mid-thread reply. --- internal/crq/codex_replay_test.go | 136 ++++++++++++++++++ internal/crq/feedback.go | 14 +- internal/crq/service.go | 90 +++++++++++- internal/dialect/codex.go | 7 + internal/dialect/common.go | 2 +- internal/dialect/golden_test.go | 3 + .../testdata/codex/environment-notice.md | 1 + internal/engine/engine_test.go | 37 ++++- internal/engine/fire.go | 18 ++- internal/engine/progress.go | 5 + internal/state/state.go | 14 +- 11 files changed, 308 insertions(+), 19 deletions(-) create mode 100644 internal/dialect/testdata/codex/environment-notice.md diff --git a/internal/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go index 4f158b6..ed25a27 100644 --- a/internal/crq/codex_replay_test.go +++ b/internal/crq/codex_replay_test.go @@ -431,3 +431,139 @@ func TestFireCodexOnlyPostFailureParks(t *testing.T) { t.Fatal("the round must become eligible once the cooldown passes") } } + +// TestSelfHealCodexClaimPreventsDoublePost pins claim-before-post: two sweepers +// observing the same round with CodexCommandID==0 must produce exactly one +// `@codex review` — the second claim fails under CAS. +func TestSelfHealCodexClaimPreventsDoublePost(t *testing.T) { + base := time.Date(2026, 7, 18, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, func(cfg *Config) { + cfg.RequiredBots = []string{cfg.Bot, codexLogin} + }) + repo, pr, head := "o/r", 21, "aaaabbbbccccdddd" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + + // A fired round whose initial Codex post never happened (CodexCommandID 0). + f.enqueue(repo, pr) + f.gh.mu.Lock() + if f.gh.postErrs == nil { + f.gh.postErrs = map[string]error{} + } + f.gh.postErrs[fakeKey(repo, pr)] = errors.New("boom") + f.gh.mu.Unlock() + res, _ := f.svc.Pump(f.ctx) + if res.Action != "post_failed" { + t.Fatalf("setup expected post_failed, got %+v", res) + } + f.gh.mu.Lock() + delete(f.gh.postErrs, fakeKey(repo, pr)) + f.gh.mu.Unlock() + f.clk.advance(3 * time.Minute) // past the post-failure cooldown + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected retry fire, got %+v", res) + } + if got := f.codexPosted(repo, pr); got != 1 { + t.Fatalf("fire should post codex once, got %d", got) + } + + // Simulate the race: strip the recorded command id and claim, as if the + // sweeper's observation predates the record, then run two sweeps in a row + // within one claim TTL. Only the claimed one may post. + if _, err := f.store.Update(f.ctx, func(st *State) error { + r := st.Round(repo, pr) + r.CodexCommandID = 0 + r.CodexClaimedAt = nil + st.PutRound(*r) + return nil + }); err != nil { + t.Fatal(err) + } + st, _, _ := f.store.Load(f.ctx) + round := st.Round(repo, pr) + obs, err := f.svc.observe(f.ctx, repo, pr, round, f.clk.now()) + 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 + events := obs.eng.Events[:0] + for _, ev := range obs.eng.Events { + if ev.Kind != dialect.EvCodexCommand { + events = append(events, ev) + } + } + obs.eng.Events = events + before := f.codexPosted(repo, pr) + f.svc.selfHealCodex(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()) + if got := f.codexPosted(repo, pr) - before; got != 1 { + t.Fatalf("concurrent self-heals must post exactly once, got %d extra posts", got) + } +} + +// TestPumpAbandonsParkedClosedPR pins the parked-closed sweep: a PR closed +// while its round cools down in awaiting_retry is abandoned on the next pump, +// not after the retry window. +func TestPumpAbandonsParkedClosedPR(t *testing.T) { + base := time.Date(2026, 7, 18, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, nil) + repo, pr, head := "o/r", 22, "aaaabbbbccccdddd" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected fire, got %+v", res) + } + // Account block parks the round for 40 minutes. + f.botComment(repo, pr, 9001, replayFairUsage(t, 40), f.clk.now().Add(5*time.Second)) + if res := f.pump(); res.Action != "requeued" { + t.Fatalf("expected requeue, got %+v", res) + } + // The PR is closed mid-cooldown. + f.gh.mu.Lock() + p := f.gh.pulls[fakeKey(repo, pr)] + p.State = "closed" + f.gh.pulls[fakeKey(repo, pr)] = p + f.gh.mu.Unlock() + f.clk.advance(2 * time.Minute) // well inside the 40m window + if res := f.pump(); res.Action != "skipped" || res.Reason != "pr closed" { + t.Fatalf("a parked closed PR must be abandoned promptly, got %+v", res) + } + if r := f.round(repo, pr); r != nil { + t.Fatalf("round must be archived, got %+v", r) + } +} + +// TestCoReviewWaitCountsPrePumpLegacySummary pins the wait anchor: with an +// adopted `@codex review` command already on the PR, a SHA-less legacy clean +// summary posted BEFORE the pump observes the round must still count — the wait +// anchors at the command time, not the observation time. +func TestCoReviewWaitCountsPrePumpLegacySummary(t *testing.T) { + base := time.Date(2026, 7, 18, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, func(cfg *Config) { + cfg.RequiredBots = []string{cfg.Bot, codexLogin} + }) + repo, pr, head := "o/r", 23, "aaaabbbbccccdddd" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + f.gh.graphQL = noForcePush // the force-push guard must resolve for the command to be adoptable + // 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.codexComment(repo, pr, 601, corpusMessage(t, "codex/clean-summary-legacy.md"), base.Add(-2*time.Minute)) + + f.enqueue(repo, pr) + f.pump() // FireCoReviewWait: anchors at the command time (-5m), not now + f.clk.advance(time.Minute) + f.pump() // sweep: Completion must count the -2m summary (≥ the -5m anchor) + if r := f.round(repo, pr); r == nil || r.Phase != PhaseCompleted { + t.Fatalf("a pre-pump legacy clean summary after the adopted command must complete the round, got %+v", r) + } + if got := f.codexPosted(repo, pr); got != 0 { + t.Fatalf("adopting the human's command must not post another, got %d", got) + } +} diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index c7da827..1366cef 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -738,7 +738,8 @@ type reviewThread struct { Path string `json:"path"` Line int `json:"line"` Comments struct { - Nodes []struct { + TotalCount int `json:"totalCount"` + Nodes []struct { DatabaseID int64 `json:"databaseId"` Body string `json:"body"` URL string `json:"url"` @@ -789,6 +790,7 @@ func (s *Service) reviewThreads(ctx context.Context, repo string, pr int) ([]rev nodes { id isResolved isOutdated path line comments(first:50) { + totalCount nodes { databaseId body url path line originalLine createdAt author { login } @@ -853,6 +855,12 @@ func threadRebuttal(thread reviewThread, bots map[string]struct{}) *dialect.Find if len(nodes) < 2 { return nil } + // Only judge complete threads: comments(first:50) truncates long + // discussions, and the "last word" below would be a stale mid-thread reply. + // Skipping is safe — a thread that long has had human attention. + if thread.Comments.TotalCount > len(nodes) { + return nil + } last := nodes[len(nodes)-1] if !dialect.InBots(bots, last.Author.Login) { return nil // the agent, not the bot, had the last word @@ -870,6 +878,10 @@ func threadRebuttal(thread reviewThread, bots map[string]struct{}) *dialect.Find if dialect.IsReviewFindingWithdrawn(last.Body) { return nil // conceded — the decline stands } + if dialect.IsNonActionableText(last.Body) { + return nil // a platform notice or ack, not a rebuttal (e.g. Codex's + // "create an environment" boilerplate posted as a thread reply) + } // A contested decline deserves attention even when the finding's own severity // is a nitpick, so floor an unknown severity at major. severity := dialect.SeverityOf(last.Body) diff --git a/internal/crq/service.go b/internal/crq/service.go index 4c6086a..2ef07e6 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -211,6 +211,15 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { st = updated } + // 2b. A PR closed during a cooldown parks invisibly (NextEligible skips + // awaiting_retry until RetryAt): sweep the oldest parked round's PR state + // so closure abandons it now instead of after the window. + if res, handled, err := s.sweepParkedClosed(ctx, st); err != nil { + return PumpResult{}, err + } else if handled { + return res, nil + } + // 3. Fire the next eligible round. next := st.NextEligible(now) if next == nil { @@ -791,6 +800,12 @@ func (s *Service) fireCoReviewWait(ctx context.Context, round Round, obs engine. return result, nil } codexID := newestCommandID(obs.CodexCommands) + anchor := now + for _, c := range obs.CodexCommands { + if c.ID == codexID && !c.CreatedAt.IsZero() { + anchor = c.CreatedAt + } + } changed := false updated, err := s.store.Update(ctx, func(st *State) error { changed = false @@ -799,7 +814,7 @@ func (s *Service) fireCoReviewWait(ctx context.Context, round Round, obs engine. return ErrNoChange } deadline := now.Add(s.cfg.FeedbackWaitTimeout) - if err := r.AwaitCoReview(deadline, now); err != nil { + if err := r.AwaitCoReview(deadline, anchor); err != nil { return err } if r.CodexCommandID == 0 && codexID != 0 { @@ -888,15 +903,17 @@ func (s *Service) postCodexReviewComment(ctx context.Context, round Round) int64 // guard (same head, CodexCommandID still unset) makes a concurrent post benign. func (s *Service) fireCodexReview(ctx context.Context, round Round) { codexID := s.postCodexReviewComment(ctx, round) - if codexID == 0 { - return - } updated, err := s.store.Update(ctx, func(st *State) error { r := st.Round(round.Repo, round.PR) - if r == nil || r.Head != round.Head || r.CodexCommandID != 0 { + if r == nil || r.Head != round.Head { return ErrNoChange } - r.CodexCommandID = codexID + // Always release the claim; record the id only on success (a failed post + // retries after the claim TTL). + r.CodexClaimedAt = nil + if codexID != 0 && r.CodexCommandID == 0 { + r.CodexCommandID = codexID + } st.PutRound(*r) return nil }) @@ -922,9 +939,36 @@ func (s *Service) selfHealCodex(ctx context.Context, round Round, obs engine.Obs 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 + } + if r.CodexClaimedAt != nil && now.Sub(r.CodexClaimedAt.UTC()) < codexClaimTTL { + return ErrNoChange + } + t := now.UTC() + r.CodexClaimedAt = &t + st.PutRound(*r) + claimed = true + return nil + }) + if err != nil || !claimed { + return + } + 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 + func (s *Service) Cancel(ctx context.Context, repo string, pr int) error { repo = NormalizeRepo(repo) state, err := s.store.Update(ctx, func(st *State) error { @@ -1389,3 +1433,37 @@ func queuedFeedbackCheckEvery(poll time.Duration) time.Duration { } return 30 * time.Second } + +// sweepParkedClosed abandons the oldest awaiting_retry round whose PR has been +// closed or merged. Parked rounds are invisible to NextEligible until RetryAt, +// so without this a PR closed during an account-block cooldown stays active and +// a waiting loop times out instead of returning skipped. One pull read per +// pump, ETag-cached. +func (s *Service) sweepParkedClosed(ctx context.Context, st State) (PumpResult, bool, error) { + if s.cfg.DryRun { + return PumpResult{}, false, nil + } + var target *Round + for key := range st.Rounds { + r := st.Rounds[key] + if r.Phase != PhaseAwaitingRetry { + continue + } + if target == nil || firedOrEnqueuedAt(r).Before(firedOrEnqueuedAt(*target)) { + c := r + target = &c + } + } + if target == nil { + return PumpResult{}, false, nil + } + _, open, err := s.pullHead(ctx, target.Repo, target.PR) + if err != nil { + return PumpResult{}, false, err + } + if open { + return PumpResult{}, false, nil + } + res, err := s.abandonRound(ctx, *target, "pr closed", "skipped") + return res, true, err +} diff --git a/internal/dialect/codex.go b/internal/dialect/codex.go index 42d5ee1..020804b 100644 --- a/internal/dialect/codex.go +++ b/internal/dialect/codex.go @@ -48,6 +48,13 @@ func IsCodexNoActionReviewCompletion(text string) bool { CodexReviewedCommitSHA(text) != "" } +// IsCodexEnvironmentNotice reports whether a Codex comment is its platform +// boilerplate asking the repo owner to create a Codex cloud environment. It is +// posted as a thread reply and must never read as a finding or a rebuttal. +func IsCodexEnvironmentNotice(text string) bool { + return strings.Contains(NormalizeReviewText(text), "create an environment for this repo") +} + // IsCodexUsageLimit reports whether a Codex comment is its usage-limit // exhaustion notice ("You have reached your Codex usage limits for code // reviews"). It is non-actionable like Codex's other acks, but distinct: it diff --git a/internal/dialect/common.go b/internal/dialect/common.go index 18b0709..a141cc0 100644 --- a/internal/dialect/common.go +++ b/internal/dialect/common.go @@ -121,7 +121,7 @@ func LooksLikePath(summary string) bool { } func IsNonActionableText(text string) bool { - if IsCodexNoActionReviewCompletion(text) || IsCodexUsageLimit(text) { + if IsCodexNoActionReviewCompletion(text) || IsCodexUsageLimit(text) || IsCodexEnvironmentNotice(text) { return true } text = NormalizeReviewText(text) diff --git a/internal/dialect/golden_test.go b/internal/dialect/golden_test.go index bb59b25..1fe94e8 100644 --- a/internal/dialect/golden_test.go +++ b/internal/dialect/golden_test.go @@ -67,6 +67,9 @@ func TestGoldenClassification(t *testing.T) { {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}, + // 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}, } base := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) diff --git a/internal/dialect/testdata/codex/environment-notice.md b/internal/dialect/testdata/codex/environment-notice.md new file mode 100644 index 0000000..1160d87 --- /dev/null +++ b/internal/dialect/testdata/codex/environment-notice.md @@ -0,0 +1 @@ +To use Codex here, [create an environment for this repo](https://chatgpt.com/codex/cloud/settings/environments). diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index ae242bd..8b9aac6 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -209,8 +209,10 @@ func TestReviewingRoundDeadlineBoundsCoReviewWait(t *testing.T) { if tr := Progress(reviewing(), state.AccountQuota{}, noReview, past, codexReq); tr.Outcome != KeepWaiting { t.Fatalf("no primary review past the deadline must keep waiting, not re-fire, got %+v", tr) } - // Before the deadline the bound must not fire: keep waiting on the co-bot. - if tr := Progress(reviewing(), state.AccountQuota{}, crAtHead, t0.Add(30*time.Minute), codexReq); tr.Outcome != OutReviewing { + // Before the deadline the bound must not fire: keep waiting on the co-bot — + // KeepWaiting, not a re-emitted OutReviewing, so the sweep doesn't write the + // same state and re-sync the dashboard on every pump. + 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) } } @@ -582,3 +584,34 @@ func TestCodexGatesCleanSummary(t *testing.T) { t.Fatalf("codex clean summary at head must complete the gated round: %+v", got) } } + +// TestCodexResolutionBypassesAccountBlock pins the quota-bypass reorder: a head +// CodeRabbit already reviewed resolves through codexAwareDedupe even during an +// account block or inside MinInterval — none of those verdicts spend CodeRabbit +// quota, so a block from another PR must not delay them. +func TestCodexResolutionBypassesAccountBlock(t *testing.T) { + gated := policy + gated.RequiredBots = []string{policy.Bot, dialect.CodexBotLogin} + gated.CodexCommand = "@codex review" + now := t0.Add(10 * time.Minute) + blocked := now.Add(30 * time.Minute) + last := now.Add(-time.Second) + g := Global{SlotFree: true, BlockedUntil: &blocked, LastFired: &last} + + queued := state.Round{Repo: "owner/repo", PR: 448, Head: "abcdef123", Phase: state.PhaseQueued, Seq: 1} + 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 { + t.Fatalf("blocked account must not delay a codex-only fire, got %+v", d) + } + // Codex satisfied → plain dedupe, also unblocked. + obs.Reviews = append(obs.Reviews, ReviewSeen{Bot: dialect.CodexBotLogin, ReviewID: 2, Commit: "abcdef1234567890", SubmittedAt: now}) + if d := DecideFire(g, queued, obs, now, gated); d.Verdict != FireDedupe { + t.Fatalf("blocked account must not delay a dedupe, got %+v", d) + } + // A round needing a REAL CodeRabbit fire still respects the block. + if d := DecideFire(g, queued, Observation{Head: "abcdef123", Open: true}, now, gated); d.Verdict != FireNo { + t.Fatalf("a real fire must still respect the account block, got %+v", d) + } +} diff --git a/internal/engine/fire.go b/internal/engine/fire.go index 79803dc..c8cc93c 100644 --- a/internal/engine/fire.go +++ b/internal/engine/fire.go @@ -66,21 +66,25 @@ func DecideFire(g Global, r state.Round, obs Observation, now time.Time, p Polic if !g.SlotFree { return FireDecision{Verdict: FireNo, Reason: "fire slot busy"} } - if g.BlockedUntil != nil && g.BlockedUntil.After(now) { - return FireDecision{Verdict: FireNo, Reason: "account blocked until " + g.BlockedUntil.UTC().Format(time.RFC3339)} - } - if g.LastFired != nil && now.Sub(*g.LastFired) < p.MinInterval { - return FireDecision{Verdict: FireNo, Reason: "min interval"} - } // 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. + // 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 + // block from another PR must not delay them. for _, review := range obs.Reviews { if sameBot(review.Bot, p.Bot) && review.Commit != "" && strings.HasPrefix(review.Commit, obs.Head) { return codexAwareDedupe(r, obs, p) } } + if g.BlockedUntil != nil && g.BlockedUntil.After(now) { + return FireDecision{Verdict: FireNo, Reason: "account blocked until " + g.BlockedUntil.UTC().Format(time.RFC3339)} + } + 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) diff --git a/internal/engine/progress.go b/internal/engine/progress.go index 0143513..05950bd 100644 --- a/internal/engine/progress.go +++ b/internal/engine/progress.go @@ -82,6 +82,11 @@ func Progress(r state.Round, q state.AccountQuota, obs Observation, now time.Tim if completion.Done { return Transition{Outcome: OutComplete, Reason: "review submitted"} } + if r.Phase == state.PhaseReviewing { + // Already acknowledged: re-emitting OutReviewing would write the same + // state and re-sync the dashboard on every sweep of a silent co-bot wait. + return Transition{Outcome: KeepWaiting, Reason: "reviewing; awaiting remaining bots"} + } return Transition{Outcome: OutReviewing, Reason: "review submitted; awaiting remaining bots"} } diff --git a/internal/state/state.go b/internal/state/state.go index 0292b92..821a6ac 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -62,6 +62,12 @@ type Round struct { // RetryAt is the earliest time this head may fire again (awaiting_retry). RetryAt *time.Time `json:"retry_at,omitempty"` + // CodexClaimedAt reserves the self-heal Codex post: it is CAS-set before the + // network post so two unserialized sweepers cannot both post `@codex review` + // for the same round. A stale claim (the poster died mid-flight) expires and + // may be re-claimed. + CodexClaimedAt *time.Time `json:"codex_claimed_at,omitempty"` + // LastAttemptAt is the adoption cutoff: command comments older than the // most recent failed/abandoned attempt must not be adopted as this round's // fire. @@ -256,7 +262,7 @@ func (r *Round) AwaitRetry(retryAt time.Time, reason string, now time.Time) erro // anchor: the primary review already stands in as the fire, so it is set to now // only when no fire was recorded. Token/ReservedAt are cleared (no slot is held); // CodexCommandID is left as-is so an existing Codex command is not re-posted. -func (r *Round) AwaitCoReview(deadline, now time.Time) error { +func (r *Round) AwaitCoReview(deadline, anchor time.Time) error { switch r.Phase { case PhaseQueued, PhaseAwaitingRetry, PhaseFired, PhaseReviewing: default: @@ -266,7 +272,11 @@ func (r *Round) AwaitCoReview(deadline, now time.Time) error { dl := deadline.UTC() r.WaitDeadline = &dl if r.FiredAt == nil { - t := now.UTC() + // The anchor is the wait's evidence floor (Completion ignores SHA-less + // co-bot summaries before FiredAt). Callers pass the adopted co-bot + // command's time when one exists — anchoring at observation time would + // hide an answer posted between that command and this pump. + t := anchor.UTC() r.FiredAt = &t } r.Token = "" From bd2e93fe5336eb6b350739a5e1181e66c466ffa6 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 18 Jul 2026 00:53:12 +0200 Subject: [PATCH 17/20] Address round-five findings: finalize identity, rebuttal shape, sweep rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven findings from the fifth self-hosted round were valid: - fireCodexReview finalizes under the sameRound guard (a same-head replacement round with a new Seq no longer inherits an old post's result), and a FAILED post keeps its claim — the claim TTL is the retry backoff, so clearing it early bypassed codexClaimTTL. - threadRebuttal requires the strict bot-finding → agent-reply → bot last-word shape: a human-started thread a bot merely answered no longer fabricates a contested finding. - The severity floor moved into dialect (FloorSeverity) — no severity literals in orchestration code. - sweepParkedClosed rotates its inspected candidate across pumps so one long-cooldown open PR cannot starve the closed-PR check for every parked round behind it. - latestCodexEvidence keeps prev strictly older than latest, so a review and its clean summary in the same second don't collapse the command window and misclassify a commanded review as automatic. - The parked-closed replay asserts the abandoned round lands in the Archive (never-delete invariant), not merely leaves Rounds. - Codex's second boilerplate variant (create account / connect) joins the dialect notice corpus. --- internal/crq/codex_replay_test.go | 7 ++- internal/crq/feedback.go | 13 +++--- internal/crq/service.go | 45 +++++++++++++------ internal/dialect/codex.go | 4 +- internal/dialect/common.go | 9 ++++ .../dialect/testdata/codex/connect-notice.md | 1 + internal/engine/codex.go | 5 +++ internal/engine/engine_test.go | 7 +++ 8 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 internal/dialect/testdata/codex/connect-notice.md diff --git a/internal/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go index ed25a27..d9840a9 100644 --- a/internal/crq/codex_replay_test.go +++ b/internal/crq/codex_replay_test.go @@ -533,7 +533,12 @@ func TestPumpAbandonsParkedClosedPR(t *testing.T) { t.Fatalf("a parked closed PR must be abandoned promptly, got %+v", res) } if r := f.round(repo, pr); r != nil { - t.Fatalf("round must be archived, got %+v", r) + t.Fatalf("round must leave Rounds, got %+v", r) + } + // Never-delete invariant: the round must land in the archive as abandoned, + // not vanish. + if a := f.archived(repo, pr, head[:9]); a == nil || a.Phase != PhaseAbandoned { + t.Fatalf("closed parked round must be archived abandoned, got %+v", a) } } diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 1366cef..37db7ea 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -865,8 +865,14 @@ func threadRebuttal(thread reviewThread, bots map[string]struct{}) *dialect.Find if !dialect.InBots(bots, last.Author.Login) { return nil // the agent, not the bot, had the last word } + // The rebuttal shape is strictly bot finding → agent reply → bot last word. + // A human-started thread that a bot merely answered is not a declined + // finding, and surfacing it would fabricate a contest. + if !dialect.InBots(bots, nodes[0].Author.Login) { + return nil + } agentReplied := false - for _, c := range nodes[:len(nodes)-1] { + for _, c := range nodes[1 : len(nodes)-1] { if !dialect.InBots(bots, c.Author.Login) { agentReplied = true break @@ -884,10 +890,7 @@ func threadRebuttal(thread reviewThread, bots map[string]struct{}) *dialect.Find } // A contested decline deserves attention even when the finding's own severity // is a nitpick, so floor an unknown severity at major. - severity := dialect.SeverityOf(last.Body) - if severity == "unknown" { - severity = "major" - } + severity := dialect.FloorSeverity(dialect.SeverityOf(last.Body), "major") return &dialect.Finding{ Bot: last.Author.Login, Severity: severity, diff --git a/internal/crq/service.go b/internal/crq/service.go index 2ef07e6..e8c16e3 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "sort" "strconv" "strings" "time" @@ -43,6 +44,9 @@ type Service struct { gh GitHubAPI store StateStore log Logger + // lastParkedSweep rotates sweepParkedClosed's candidate across pumps (see + // there); in-memory only, single-writer (the pump caller). + lastParkedSweep string // 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 @@ -903,15 +907,20 @@ func (s *Service) postCodexReviewComment(ctx context.Context, round Round) int64 // guard (same head, CodexCommandID still unset) makes a concurrent post benign. func (s *Service) fireCodexReview(ctx context.Context, round Round) { codexID := s.postCodexReviewComment(ctx, round) + if codexID == 0 { + // Failed post: KEEP the claim — its TTL is the retry backoff. Clearing it + // here would let the very next pump repost, bypassing codexClaimTTL. + return + } updated, err := s.store.Update(ctx, func(st *State) error { r := st.Round(round.Repo, round.PR) - if r == nil || r.Head != round.Head { + // Identity guard: a same-head replacement round (new Seq) must not + // inherit this post's result. + if !sameRound(r, round) { return ErrNoChange } - // Always release the claim; record the id only on success (a failed post - // retries after the claim TTL). r.CodexClaimedAt = nil - if codexID != 0 && r.CodexCommandID == 0 { + if r.CodexCommandID == 0 { r.CodexCommandID = codexID } st.PutRound(*r) @@ -1443,20 +1452,30 @@ func (s *Service) sweepParkedClosed(ctx context.Context, st State) (PumpResult, if s.cfg.DryRun { return PumpResult{}, false, nil } - var target *Round + var keys []string for key := range st.Rounds { - r := st.Rounds[key] - if r.Phase != PhaseAwaitingRetry { - continue - } - if target == nil || firedOrEnqueuedAt(r).Before(firedOrEnqueuedAt(*target)) { - c := r - target = &c + if st.Rounds[key].Phase == PhaseAwaitingRetry { + keys = append(keys, key) } } - if target == nil { + if len(keys) == 0 { return PumpResult{}, false, nil } + // Rotate the inspected candidate across pumps: always taking the oldest + // would let one long-cooldown open PR starve the closed-PR check for every + // parked round behind it. In-memory rotation is enough — only the leader + // daemon sweeps, and a restart merely restarts the cycle. + sort.Strings(keys) + next := keys[0] + for _, k := range keys { + if k > s.lastParkedSweep { + next = k + break + } + } + s.lastParkedSweep = next + r := st.Rounds[next] + target := &r _, open, err := s.pullHead(ctx, target.Repo, target.PR) if err != nil { return PumpResult{}, false, err diff --git a/internal/dialect/codex.go b/internal/dialect/codex.go index 020804b..0ca66e3 100644 --- a/internal/dialect/codex.go +++ b/internal/dialect/codex.go @@ -52,7 +52,9 @@ func IsCodexNoActionReviewCompletion(text string) bool { // boilerplate asking the repo owner to create a Codex cloud environment. It is // posted as a thread reply and must never read as a finding or a rebuttal. func IsCodexEnvironmentNotice(text string) bool { - return strings.Contains(NormalizeReviewText(text), "create an environment for this repo") + t := NormalizeReviewText(text) + return strings.Contains(t, "create an environment for this repo") || + strings.Contains(t, "create a codex account and connect to github") } // IsCodexUsageLimit reports whether a Codex comment is its usage-limit diff --git a/internal/dialect/common.go b/internal/dialect/common.go index a141cc0..9cb9b25 100644 --- a/internal/dialect/common.go +++ b/internal/dialect/common.go @@ -28,6 +28,15 @@ func SeverityOf(text string) string { } } +// FloorSeverity raises sev to at least floor by rank ("unknown" ranks lowest), +// so callers never compare severity literals themselves. +func FloorSeverity(sev, floor string) string { + if RankSeverity(sev) < RankSeverity(floor) { + return floor + } + return sev +} + func RankSeverity(sev string) int { switch sev { case "critical": diff --git a/internal/dialect/testdata/codex/connect-notice.md b/internal/dialect/testdata/codex/connect-notice.md new file mode 100644 index 0000000..5a8914d --- /dev/null +++ b/internal/dialect/testdata/codex/connect-notice.md @@ -0,0 +1 @@ +To use Codex here, [create a Codex account and connect to github](https://chatgpt.com/codex/cloud/settings/connectors). diff --git a/internal/engine/codex.go b/internal/engine/codex.go index 9a4ebda..35288b6 100644 --- a/internal/engine/codex.go +++ b/internal/engine/codex.go @@ -106,6 +106,11 @@ func latestCodexEvidence(obs Observation) (latest, prev time.Time, ok bool) { 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 } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index 8b9aac6..012497a 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -449,6 +449,13 @@ func TestCodexAutoActive(t *testing.T) { Reviews: []ReviewSeen{codexReview(t0.Add(time.Minute)), codexReview(t1)}, Events: []dialect.BotEvent{codexCommand(t0)}, }, want: true}, + // A review and its clean summary in the SAME second must not collapse the + // command window to a point: the command at that instant still explains + // the evidence, so this is commanded, not automatic. + {name: "co-timestamped evidence keeps the command window open", obs: Observation{ + Reviews: []ReviewSeen{codexReview(t1)}, + Events: []dialect.BotEvent{codexCommand(t1), codexClean(t1)}, + }, want: false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { From a7bd1e78f8d999f5f789de36255c590ffbb46f9d Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 18 Jul 2026 01:24:27 +0200 Subject: [PATCH 18/20] Address round-six findings: quota-free pump path, anchors, adopt claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pump no longer returns blocked/min_interval before observe+DecideFire: the engine resolves quota-free verdicts (dedupe, FireCodexOnly, FireCoReviewWait) ahead of those gates, and mapFireNo still reports the same actions for real fires. The early return was re-adding the gate the engine had deliberately moved. - The co-review wait anchor falls back to the primary bot's head-review time when no @codex command exists, so a pre-pump legacy clean summary from auto-review counts instead of hiding until the deadline. - The adopt path takes the Codex self-heal claim in the same CAS that records the fire — the fired state is never visible unclaimed. - The poisoned-marker repair keeps a completed round whose primary review is real but whose co-bot is pending (a deliberate dedupe), instead of deleting it into ack-and-dedupe churn. --- internal/crq/service.go | 51 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/internal/crq/service.go b/internal/crq/service.go index e8c16e3..1b2d0d6 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -246,12 +246,12 @@ func (s *Service) Pump(ctx context.Context) (PumpResult, error) { } } now = s.clock() - if st.Account.BlockedUntil != nil && st.Account.BlockedUntil.After(now) { - return PumpResult{Action: "blocked", Reason: st.Account.BlockedUntil.Format(time.RFC3339)}, nil - } - if st.LastFired != nil && now.Sub(*st.LastFired) < s.cfg.MinInterval { - return PumpResult{Action: "min_interval", Reason: s.cfg.MinInterval.String()}, nil - } + // No early blocked/min-interval return here: DecideFire owns those gates and + // deliberately resolves the quota-free verdicts (dedupe, FireCodexOnly, + // 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 + // ETag-cached 304s. next = st.NextEligible(now) if next == nil { return PumpResult{Action: "idle"}, nil @@ -612,6 +612,13 @@ 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 + } st.PutRound(*r) recorded = true return nil @@ -804,7 +811,17 @@ func (s *Service) fireCoReviewWait(ctx context.Context, round Round, obs engine. 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. anchor := now + 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) { + anchor = rv.SubmittedAt + } + } for _, c := range obs.CodexCommands { if c.ID == codexID && !c.CreatedAt.IsZero() { anchor = c.CreatedAt @@ -1232,6 +1249,22 @@ func (s *Service) latestCalibrationReply(ctx context.Context, issue int, after t return best, ok, nil } +// isConfiguredBotLogin is isConfiguredBot for callers holding only the config +// value, and reviewedByConfiguredBot checks a ReviewedBy map with the same +// suffix tolerance. +func isConfiguredBotLogin(bot, login string) bool { + return dialect.NormalizeBotName(login) == dialect.NormalizeBotName(bot) +} + +func reviewedByConfiguredBot(reviewedBy map[string]bool, bot string) bool { + for login, ok := range reviewedBy { + if ok && isConfiguredBotLogin(bot, login) { + return true + } + } + return false +} + func (s *Service) isConfiguredBot(login string) bool { return dialect.NormalizeBotName(login) == dialect.NormalizeBotName(s.cfg.Bot) } @@ -1353,6 +1386,12 @@ func (s *Service) Wait(ctx context.Context, repo string, pr int) (PumpResult, in if len(engine.FindingsOnHead(report.Findings, report.Head)) > 0 || allReviewed(report.ReviewedBy) { return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: result.Head}, 3, nil } + // A real primary review at this head is NOT a poisoned marker even with + // a co-bot pending (a deliberate dedupe when Codex is unobtainable). + // Deleting it would requeue the same head into ack-and-dedupe churn. + if reviewedByConfiguredBot(report.ReviewedBy, s.cfg.Bot) { + return PumpResult{Action: "deduped", Repo: repo, PR: pr, Head: result.Head}, 3, nil + } // A completed round at this head with no real head review is a poisoned // dedup marker (a mistaken completion). Drop it and enqueue the real // replacement review. From 9f1105bb595085faab921851c89c55da83d90896 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 18 Jul 2026 01:31:14 +0200 Subject: [PATCH 19/20] Hold a converged loop through a settle window to catch trailing waves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bots deliver in waves: Codex auto-reviews a pushed head minutes after the push, and CodeRabbit's real review can trail its comment shells. The loop exited 0 on the first converged observation, so a wave landing moments later was found by a human re-checking the PR instead of by crq — exactly the failure this tool exists to prevent. The loop now records when it first observes convergence and keeps polling for CRQ_SETTLE (default 90s): anything new inside the window flips the verdict back to the normal findings/pending flow, and only a quiet window exits 0. The wait deadline is suppressed while settling (the settle is its own bound). Replay scenarios pin both directions; scenario configs disable the window where they assert immediate verdicts. --- README.md | 1 + internal/crq/codex_replay_test.go | 55 +++++++++++++++++++++++++++++++ internal/crq/config.go | 6 ++++ internal/crq/feedback.go | 19 +++++++++-- internal/crq/replay_test.go | 1 + 5 files changed, 79 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 88fde12..0adac0b 100644 --- a/README.md +++ b/README.md @@ -433,6 +433,7 @@ Set these in `~/.config/crq/env` (sourced automatically) or as environment varia | `CRQ_POLL` | `15s` | how often `crq loop` checks its place in line | | `CRQ_WAIT_TIMEOUT` | `0` | give up waiting for a slot after this long (`0` = never) | | `CRQ_FEEDBACK_WAIT_TIMEOUT` | `20m` | how long `crq loop` waits for feedback after firing | +| `CRQ_SETTLE` | `90s` | after convergence the loop keeps polling this long before exiting 0, so a trailing review wave (e.g. a Codex auto-review of the pushed head) is caught by crq, not by a human re-checking the PR | | `CRQ_CALIBRATE_TTL` | `2m` | how long to trust a quota reading before re-asking CodeRabbit | | `CRQ_AUTOREVIEW_POLL` | `1m` | how often the `autoreview` daemon scans for PRs to enqueue | | `CRQ_INFLIGHT_TIMEOUT` | `15m` | backstop to release a stuck in-flight review | diff --git a/internal/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go index d9840a9..0e4d165 100644 --- a/internal/crq/codex_replay_test.go +++ b/internal/crq/codex_replay_test.go @@ -572,3 +572,58 @@ func TestCoReviewWaitCountsPrePumpLegacySummary(t *testing.T) { t.Fatalf("adopting the human's command must not post another, got %d", got) } } + +// TestLoopSettleWindowCatchesTrailingWave pins the settle window: a loop that +// observes convergence must NOT exit 0 immediately — a trailing review wave +// (Codex auto-reviewing the pushed head) arriving inside the window flips the +// verdict to findings, and a quiet window exits 0. +func TestLoopSettleWindowCatchesTrailingWave(t *testing.T) { + base := time.Date(2026, 7, 18, 9, 0, 0, 0, time.UTC) + run := func(inject bool) (int, int) { + f := newCodexReplayFixture(t, base, func(cfg *Config) { + cfg.SettleWindow = 10 * time.Minute + }) + repo, pr, head := "o/r", 31, "aaaabbbbccccdddd" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + f.botReview(repo, pr, 500, head, base.Add(-10*time.Minute)) // converged state + // An ACTIVE wait is what the settle protects: without one, Wait's dedupe + // path legitimately short-circuits before the feedback poll. + seedRound(t, f.store, f.cfg, repo, pr, head[:9], PhaseReviewing, base.Add(-11*time.Minute), 0) + type out struct { + code int + n int + } + done := make(chan out, 1) + go func() { + rep, code, _ := f.svc.Loop(f.ctx, repo, pr) + done <- out{code, len(rep.Findings)} + }() + time.Sleep(50 * time.Millisecond) // loop is now settling on real 1ms polls + if inject { + // The trailing wave: a fresh CodeRabbit review whose body carries an + // outside-diff finding (corpus shape), as after a push. + f.gh.mu.Lock() + r := ghapi.Review{ID: 501, CommitID: head, State: "COMMENTED", SubmittedAt: f.clk.now(), + Body: corpusMessage(t, "coderabbit/findings-outside-diff.md")} + r.User.Login = f.bot + key := fakeKey(repo, pr) + f.gh.reviews[key] = append(f.gh.reviews[key], r) + f.gh.mu.Unlock() + } + f.clk.advance(11 * time.Minute) // past the settle window either way + select { + case o := <-done: + return o.code, o.n + case <-time.After(5 * time.Second): + t.Fatal("loop did not return") + return -1, -1 + } + } + if code, n := run(true); code != 10 || n == 0 { + t.Fatalf("a wave inside the settle window must surface findings, got code=%d n=%d", code, n) + } + if code, _ := run(false); code != 0 { + t.Fatalf("a quiet settle window must converge, got code=%d", code) + } +} diff --git a/internal/crq/config.go b/internal/crq/config.go index 1424202..8c237fe 100644 --- a/internal/crq/config.go +++ b/internal/crq/config.go @@ -64,6 +64,11 @@ type Config struct { NoOpen bool DryRun bool FeedbackWaitTimeout time.Duration + // SettleWindow keeps a converged loop polling briefly before it exits 0, so + // a trailing review wave (a Codex auto-review of the just-pushed head, a + // CodeRabbit review following its comment shells) is caught by crq instead + // of by a human re-checking the PR. 0 disables. + SettleWindow time.Duration } func LoadConfig() (Config, error) { @@ -129,6 +134,7 @@ func LoadConfig() (Config, error) { NoOpen: env["CRQ_NO_OPEN"] != "", DryRun: env["CRQ_DRY_RUN"] == "1", FeedbackWaitTimeout: durationEnv(env, "CRQ_FEEDBACK_WAIT_TIMEOUT", 20*time.Minute), + SettleWindow: durationEnv(env, "CRQ_SETTLE", 90*time.Second), } if len(cfg.Scope) == 0 && cfg.GateRepo != "" { cfg.Scope = []string{ownerOf(cfg.GateRepo)} diff --git a/internal/crq/feedback.go b/internal/crq/feedback.go index 37db7ea..66d06a2 100644 --- a/internal/crq/feedback.go +++ b/internal/crq/feedback.go @@ -325,6 +325,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport return FeedbackReport{}, 1, err } var lastLog time.Time + var convergedAt time.Time // Pump keeps the queue moving while we wait, but once a minute is plenty (the // autoreview daemon pumps too); pumping on every tick just burns REST quota. var lastPump time.Time @@ -372,8 +373,20 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport return report, 10, nil } if report.Converged { - s.completeWaitRound(ctx, repo, pr, head) - return report, 0, nil + // Don't trust the first converged observation: bots deliver in waves + // (Codex auto-reviews a pushed head minutes later; CodeRabbit's real + // review can trail its comment shells). Hold the verdict for the settle + // window and only exit 0 if nothing new lands; any finding or pending + // reviewer resets the normal flow above. + if convergedAt.IsZero() { + convergedAt = s.clock() + } + if s.cfg.SettleWindow <= 0 || s.clock().Sub(convergedAt) >= s.cfg.SettleWindow { + s.completeWaitRound(ctx, repo, pr, head) + return report, 0, nil + } + } else { + convergedAt = time.Time{} } // Keep the queue moving (re-fire once an account-block window clears) and pick up // the Blocked state it leaves behind. Pumping every poll tick is redundant — @@ -406,7 +419,7 @@ func (s *Service) Loop(ctx context.Context, repo string, pr int) (FeedbackReport } poll = blockedPollInterval(*blockedUntil, now, s.cfg.PollInterval) } - if now.After(deadline) { + if now.After(deadline) && convergedAt.IsZero() { s.completeWaitRound(ctx, repo, pr, head) if len(report.Findings) > 0 { report.Status = "feedback" diff --git a/internal/crq/replay_test.go b/internal/crq/replay_test.go index 7d2bd9a..ac4a3c9 100644 --- a/internal/crq/replay_test.go +++ b/internal/crq/replay_test.go @@ -63,6 +63,7 @@ func replayConfig() Config { cfg.InflightTimeout = time.Hour cfg.FeedbackWaitTimeout = time.Hour cfg.RateLimitFallback = 15 * time.Minute + cfg.SettleWindow = 0 // scenarios assert immediate verdicts; the settle test opts in return cfg } From 21d1ff5774ce46d3710bc7130371ec8d27ef9178 Mon Sep 17 00:00:00 2001 From: kristofferR Date: Sat, 18 Jul 2026 02:44:09 +0200 Subject: [PATCH 20/20] Address the final round-seven review findings - Update the README and llms.txt setup snippets to the v3 default state ref crq-state-v3, so following the docs no longer bypasses the protective default and risks a v3 client overwriting a v2 payload on the old crq-state ref. - Ignore a `@codex review` command Codex already answered with a review when gathering adoptable Codex commands: on a regular push whose commit date predates that consumed command, the command survives the cutoff, and treating it as live suppressed the Codex command the new head still needed. - Record the adopted Codex command id in the adopt fire's CAS write when crq is not posting Codex, so the self-heal scan (anchored on FiredAt) never re-posts a Codex command that was posted before the adopted CodeRabbit command. --- README.md | 6 +-- internal/crq/codex_replay_test.go | 77 +++++++++++++++++++++++++++++++ internal/crq/observe.go | 26 ++++++++++- internal/crq/service.go | 6 +++ llms.txt | 2 +- 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0adac0b..71ffe24 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Everything lives in one small **gate repo** (private is fine): | Piece | What it is | |-------|-----------| -| 🔒 **State ref** | The typed queue state is JSON stored in a git ref (`CRQ_STATE_REF`, default `crq-state`), updated with optimistic **compare-and-swap** — a new commit is written only if the ref hasn't moved, so concurrent callers across machines never corrupt the queue. No database, service account, or always-on server. | +| 🔒 **State ref** | The typed queue state is JSON stored in a git ref (`CRQ_STATE_REF`, default `crq-state-v3`), updated with optimistic **compare-and-swap** — a new commit is written only if the ref hasn't moved, so concurrent callers across machines never corrupt the queue. No database, service account, or always-on server. | | 📊 **Dashboard issue** | A tracking **issue** renders the live state below a hidden machine-readable block: status, the queue, in-flight review, recently requested review commands, and the current quota — every PR linked. The issue **title** is a one-glance status (`🐰 crq — 2 queued`). | | 🐰 **Calibration PR** | A throwaway draft PR where crq asks `@coderabbitai rate limit` to read your real quota *without spending a review*. crq prunes its own probe comments so the PR never hits GitHub's 2500-comment cap. | @@ -154,7 +154,7 @@ export CRQ_REPO=YOURUSER/crq-state export CRQ_ISSUE=2 export CRQ_CAL_PR=1 export CRQ_SCOPE=YOURUSER -export CRQ_STATE_REF=crq-state +export CRQ_STATE_REF=crq-state-v3 EOF ``` @@ -420,7 +420,7 @@ Set these in `~/.config/crq/env` (sourced automatically) or as environment varia | `CRQ_ISSUE` | from `init` | dashboard issue number | | `CRQ_CAL_PR` | from `init` | calibration PR number | | `CRQ_SCOPE` | owner of `CRQ_REPO` | which owners/orgs share this quota (comma-separated) | -| `CRQ_STATE_REF` | `crq-state` | git ref that stores the typed CAS state | +| `CRQ_STATE_REF` | `crq-state-v3` | git ref that stores the typed CAS state | | `CRQ_REPOS` | _(all in scope)_ | `autoreview` allowlist — only these `owner/name` repos (comma-separated) | | `CRQ_EXCLUDE` | _(none)_ | `autoreview` denylist — never these `owner/name` repos (comma-separated) | | `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 | diff --git a/internal/crq/codex_replay_test.go b/internal/crq/codex_replay_test.go index 0e4d165..858e7b6 100644 --- a/internal/crq/codex_replay_test.go +++ b/internal/crq/codex_replay_test.go @@ -627,3 +627,80 @@ func TestLoopSettleWindowCatchesTrailingWave(t *testing.T) { t.Fatalf("a quiet settle window must converge, got code=%d", code) } } + +// TestCodexReplayIgnoresAnsweredCommandForNewHead pins the observe fix: an old +// `@codex review` command Codex already answered with a review must not read as +// live for a later head. On a regular push whose commit date predates that +// consumed command, the command survives the adoption cutoff — but treating it as +// present would make DecideCodexPost see the head as already-asked and suppress +// the Codex command the new head still needs. crq must post a fresh @codex review. +func TestCodexReplayIgnoresAnsweredCommandForNewHead(t *testing.T) { + base := time.Date(2026, 7, 17, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, func(cfg *Config) { + cfg.RequiredBots = []string{cfg.Bot, codexLogin} + }) + f.gh.graphQL = noForcePush // resolve the force-push cutoff so a surviving command would be adoptable + repo, pr := "o/r", 21 + oldHead, head := "1111222233334444", "5555666677778888" + f.openPull(repo, pr, head) + // The new head is an ordinary push whose commit date predates the old command, + // 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.codexReview(repo, pr, 400, oldHead, base.Add(-30*time.Minute)) + + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected fire, got %+v", res) + } + // The consumed command is ignored, so crq commands Codex for the new head. + if got := f.codexPosted(repo, pr); got != 1 { + t.Fatalf("codex must be commanded for the new head, got %d posts", got) + } + if r := f.round(repo, pr); r == nil || r.CodexCommandID == 0 { + t.Fatalf("round must record the fresh codex command, got %+v", r) + } +} + +// TestCodexReplayAdoptRecordsExistingCodexCommand pins the adopt-path fix: when a +// round adopts an already-posted `@coderabbitai review` command and a live `@codex +// review` command already answers the head, crq is not posting Codex — but it must +// still record that command's id. Otherwise the self-heal scan (which anchors on +// FiredAt) misses a Codex command posted before the adopted CodeRabbit one and +// posts a duplicate. +func TestCodexReplayAdoptRecordsExistingCodexCommand(t *testing.T) { + base := time.Date(2026, 7, 17, 9, 0, 0, 0, time.UTC) + f := newCodexReplayFixture(t, base, func(cfg *Config) { + cfg.RequiredBots = []string{cfg.Bot, codexLogin} + }) + f.gh.graphQL = noForcePush // resolve the cutoff so both commands are adoptable + repo, pr, head := "o/r", 22, "aaaabbbbccccdddd" + f.openPull(repo, pr, head) + f.setCommitDate(head, base.Add(-time.Hour)) + // 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, 602, f.cfg.ReviewCommand, base.Add(-time.Minute)) + + f.enqueue(repo, pr) + if res := f.pump(); res.Action != "fired" { + t.Fatalf("expected an adopt fire, got %+v", res) + } + // The adopt path posts neither command; it records the existing Codex command. + if got := f.codexPosted(repo, pr); got != 0 { + t.Fatalf("adopt must not post a codex command, got %d", got) + } + if r := f.round(repo, pr); r == nil || r.CodexCommandID != codexCmdID { + t.Fatalf("adopt must record the existing codex command id %d, got %+v", codexCmdID, r) + } + + // A later pump's self-heal must not repost the Codex command now that it is + // recorded as the round's CodexCommandID. + f.clk.advance(2 * time.Minute) + f.pump() + if got := f.codexPosted(repo, pr); got != 0 { + t.Fatalf("self-heal must not repost the recorded codex command, got %d", got) + } +} diff --git a/internal/crq/observe.go b/internal/crq/observe.go index fb6b42e..53703f6 100644 --- a/internal/crq/observe.go +++ b/internal/crq/observe.go @@ -213,11 +213,35 @@ func (s *Service) reviewCommands(ctx context.Context, repo string, pr int, obs e cr = s.adoptableCR(obs, cutoff, command, comments, reviews) } if hasCodex { - codex = newestCommandSince(codexCommand, cutoff, comments) + codex = adoptableCodex(cutoff, codexCommand, comments, reviews) } return cr, codex, nil } +// 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) + 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) { + return nil + } + } + return best +} + // adoptableCR returns the newest CodeRabbit command comment safe to adopt as an // already-posted fire, or none. A command the bot already answered with a review // or a completion reply belongs to a finished round for an earlier head and is diff --git a/internal/crq/service.go b/internal/crq/service.go index 1b2d0d6..704a1a5 100644 --- a/internal/crq/service.go +++ b/internal/crq/service.go @@ -618,6 +618,12 @@ func (s *Service) fireRound(ctx context.Context, round Round, obs engine.Observa 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". + r.CodexCommandID = id } st.PutRound(*r) recorded = true diff --git a/llms.txt b/llms.txt index 58551b4..5d2fb62 100644 --- a/llms.txt +++ b/llms.txt @@ -30,7 +30,7 @@ export CRQ_REPO=OWNER/crq-state export CRQ_ISSUE=1 export CRQ_CAL_PR=1 export CRQ_SCOPE=OWNER -export CRQ_STATE_REF=crq-state +export CRQ_STATE_REF=crq-state-v3 ``` CodeRabbit native auto-review must be off.