diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..2cbe851
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,98 @@
+# 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; 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".
+- `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
+
+```text
+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/README.md b/README.md
index a68dc46..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
```
@@ -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
@@ -414,18 +420,20 @@ 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 |
| `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 |
| `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 |
@@ -442,6 +450,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/cmd/crq/main.go b/cmd/crq/main.go
index cdc8161..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,13 +63,13 @@ 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
}
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 +242,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 88be41e..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():
@@ -217,7 +220,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
@@ -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) {
@@ -257,12 +260,12 @@ 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
+ // 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 IsRateLimited(nerr) {
+ if ghapi.IsThrottled(nerr) {
return false, nerr
}
if s.log != nil {
@@ -271,7 +274,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,40 +286,30 @@ 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)
+ 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,29 +317,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
+ if dialect.NormalizeBotName(comment.User.Login) == bot && strings.Contains(comment.Body, s.cfg.ReviewDoneMarker) {
+ 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/codex_replay_test.go b/internal/crq/codex_replay_test.go
new file mode 100644
index 0000000..858e7b6
--- /dev/null
+++ b/internal/crq/codex_replay_test.go
@@ -0,0 +1,706 @@
+package crq
+
+import (
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kristofferR/coderabbit-queue/internal/dialect"
+ 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.
+
+// codexLogin is the canonical Codex app login, owned by internal/dialect.
+const codexLogin = dialect.CodexBotLogin
+
+// 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
+}
+
+// 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()
+ 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(),
+ 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)
+}
+
+// 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(t, 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(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 {
+ 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(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)
+ }
+}
+
+// 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
+// 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")
+ }
+}
+
+// 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 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)
+ }
+}
+
+// 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)
+ }
+}
+
+// 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)
+ }
+}
+
+// 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/config.go b/internal/crq/config.go
index 0ef7c56..8c237fe 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
@@ -25,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
@@ -54,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) {
@@ -93,13 +108,14 @@ 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), ",")),
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"),
+ 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"),
ReviewDoneMarker: stringEnv(env, "CRQ_REVIEW_DONE_MARKER", "summarize by coderabbit.ai"),
CompletionMarker: stringEnvAllowEmpty(env, "CRQ_COMPLETION_MARKER", "Review finished"),
@@ -118,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)}
@@ -237,7 +254,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
@@ -251,7 +268,7 @@ func unionBots(lists ...[]string) []string {
if item == "" {
continue
}
- key := normalizeBotName(item)
+ key := dialect.NormalizeBotName(item)
if seen[key] {
continue
}
@@ -279,7 +296,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/feedback.go b/internal/crq/feedback.go
index 41f1341..66d06a2 100644
--- a/internal/crq/feedback.go
+++ b/internal/crq/feedback.go
@@ -6,49 +6,47 @@ 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"`
-}
+ "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 := s.clock()
+ 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]
@@ -59,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(),
+ Findings: []dialect.Finding{},
+ CheckedAt: now,
}
- // 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
- }
- 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
@@ -88,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
}
@@ -133,10 +132,16 @@ 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 IsRateLimited(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.
@@ -147,19 +152,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,
@@ -171,13 +176,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
@@ -195,61 +193,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,
@@ -258,22 +227,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 {
@@ -282,131 +246,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{}
- }
- 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}
- }
- }
- 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
@@ -426,22 +265,22 @@ 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 state.WaitingHead(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"
@@ -469,9 +308,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 == "" {
@@ -481,12 +320,12 @@ 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
+ 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
@@ -494,20 +333,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
@@ -522,17 +361,34 @@ 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.clearFeedbackWait(ctx, repo, pr, head)
return report, 10, nil
}
if report.Converged {
- s.clearFeedbackWait(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 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.
@@ -542,16 +398,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()
+ now := s.clock()
if st, _, lerr := s.store.Load(ctx); lerr == nil {
- if until, ok := feedbackBlockedUntil(st, repo, pr, head, now); ok {
+ if until, ok := st.AccountBlockedUntil(repo, pr, head, now); ok {
blockedUntil = &until
}
}
@@ -559,12 +415,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)
+ if now.After(deadline) && convergedAt.IsZero() {
+ 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"
@@ -576,7 +432,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)
}
@@ -590,110 +446,68 @@ 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()
+// 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 time.Time{}, err
}
- return FeedbackWait{
- Repo: NormalizeRepo(repo),
- PR: pr,
- Head: head,
- StartedAt: started,
- Deadline: started.Add(s.cfg.FeedbackWaitTimeout),
- FiredCommentID: firedCommentID,
- ByHost: s.cfg.Host,
+ if dl, ok := st.RoundWaitDeadline(repo, pr, head); ok {
+ return dl, nil
}
-}
-
-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
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 := s.clock()
+ if r.FiredAt != nil {
+ start = r.FiredAt.UTC()
+ }
+ dl := start.Add(s.cfg.FeedbackWaitTimeout)
+ r.WaitDeadline = &dl
+ st.PutRound(*r)
changed = true
return nil
})
if err != nil {
- return FeedbackWait{}, err
+ return time.Time{}, err
}
if changed {
- s.sync(ctx, state)
+ s.sync(ctx, updated)
}
- 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()
+ if dl, ok := updated.RoundWaitDeadline(repo, pr, head); ok {
+ return dl, nil
}
- for _, item := range st.History {
- if NormalizeRepo(item.Repo) == repo && item.PR == pr && item.Commit == head {
- return item.At.UTC()
- }
- }
- return fallback.UTC()
+ // The round is no longer a wait (completed/none): synthesize a transient
+ // deadline so the loop still bounds its poll.
+ return s.clock().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)
- 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
})
@@ -708,20 +522,26 @@ func (s *Service) extendFeedbackWaitDeadline(ctx context.Context, repo string, p
}
}
-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)
- 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
})
@@ -746,7 +566,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
}
@@ -754,15 +574,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.
@@ -790,7 +610,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.
@@ -804,23 +624,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 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.
-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()
-}
-
-// 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 {
@@ -947,7 +751,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"`
@@ -998,6 +803,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 }
@@ -1023,224 +829,94 @@ 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)?sub>`)
- crCommentRE = regexp.MustCompile(``)
- htmlCommentRE = regexp.MustCompile(`(?s)`)
-)
+// 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)
}
return a.ID > b.ID
}
-func parseReviewBodyFindings(review Review, bot string) []Finding {
- body := strings.TrimSpace(review.Body)
- if body == "" {
+// 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
}
- 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) {
+ nodes := thread.Comments.Nodes
+ if len(nodes) < 2 {
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)
- }
+ // 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
}
- 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)
+ last := nodes[len(nodes)-1]
+ if !dialect.InBots(bots, last.Author.Login) {
+ return nil // the agent, not the bot, had the last word
}
-}
-
-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)
- }
+ // 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
}
- 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)
- }
+ agentReplied := false
+ for _, c := range nodes[1 : len(nodes)-1] {
+ if !dialect.InBots(bots, c.Author.Login) {
+ agentReplied = true
+ break
}
}
- return out
+ 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
+ }
+ 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.FloorSeverity(dialect.SeverityOf(last.Body), "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,
@@ -1249,25 +925,25 @@ func parsePromptReviewFindings(body string, review Review, bot string) []Finding
// 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,
@@ -1286,7 +962,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)
@@ -1294,33 +970,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] {
@@ -1332,560 +1008,13 @@ 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")
-}
-
-// 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 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 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))
-}
-
-// 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 := 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() {
- 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 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" {
+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 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()
- }
- 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/feedback_test.go b/internal/crq/feedback_test.go
index 6556ca8..c54f47f 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,31 +94,26 @@ 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), Body: "**Actionable comments posted: 2**"}
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 {
- 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)
}
@@ -199,31 +197,26 @@ 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), Body: "**Actionable comments posted: 2**"}
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)
- 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)
@@ -236,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)
@@ -258,31 +251,26 @@ 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), Body: "**Actionable comments posted: 2**"}
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)
- 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)
@@ -315,30 +303,25 @@ 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), Body: "**Actionable comments posted: 2**"}
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 _, 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)
}
@@ -346,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)
}
@@ -355,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)
}
@@ -377,31 +360,26 @@ 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)
- 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)
@@ -425,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)
@@ -449,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 {
@@ -466,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.
@@ -507,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" +
@@ -554,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" +
@@ -579,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
@@ -613,7 +591,7 @@ In @README.md:
}
func TestParseReviewBodyFindingsExtractsCodexOutsideDiffItem(t *testing.T) {
- review := Review{
+ review := ghapi.Review{
ID: 101,
Body: `
### 💡 Codex Review
@@ -663,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**
@@ -688,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)
@@ -718,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 ",
@@ -731,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)
@@ -758,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 ",
@@ -771,31 +749,18 @@ 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)
- 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 {
@@ -819,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
** Query learning history by topic before taking**
@@ -836,23 +801,10 @@ 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)
- 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 {
@@ -864,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"
@@ -915,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)
@@ -983,33 +908,20 @@ 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)
- 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)
@@ -1035,33 +947,20 @@ 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)
- 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)
@@ -1081,33 +980,20 @@ 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)
- 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)
@@ -1130,37 +1016,23 @@ 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)
- 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)
@@ -1182,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)
@@ -1222,36 +1094,23 @@ 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)
- 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)
@@ -1277,36 +1136,23 @@ 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)
- 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)
@@ -1338,33 +1184,20 @@ 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)
- 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)
@@ -1377,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"},
@@ -1390,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"
@@ -1433,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")
}
@@ -1463,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)
}
@@ -1500,36 +1333,50 @@ func TestExtendDeadlineForBlock(t *testing.T) {
}
}
-func TestFeedbackBlockedUntilHonorsPerHeadCooldownAfterGlobalBlockClears(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 TestAccountBlockedUntilHonorsPerHeadCooldownAfterGlobalBlockClears(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)
+ got, ok := st.AccountBlockedUntil("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 the 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")
+ 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")
}
}
-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 := 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)
+ 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)
}
@@ -1583,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"
@@ -1612,20 +1459,15 @@ 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)
- 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)
@@ -1655,33 +1497,19 @@ 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)
- 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 +1526,16 @@ 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)
+ // 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.Fired[QueueKey("owner/repo", 12)] != "abcdef123" {
- t.Fatalf("fired marker should remain for dedupe after collection: %#v", state.Fired)
+ if state.FiredMarker("owner/repo", 12) != "abcdef123" {
+ t.Fatalf("fired marker should remain for dedupe after collection")
}
}
@@ -1721,38 +1554,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()
- 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)
- 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)
@@ -1789,25 +1609,25 @@ 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 {
- st.Blocked.BlockedUntil = &blockedUntil
+ st.Account.BlockedUntil = &blockedUntil
return nil
}); err != nil {
t.Fatal(err)
@@ -1848,27 +1668,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**\nPrompt 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)
@@ -1903,33 +1723,20 @@ 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)
- 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)
@@ -1960,37 +1767,24 @@ 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()
- 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)
@@ -2019,27 +1813,13 @@ 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
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 +1840,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 state.WaitingHead("owner/repo", 12) != "" {
+ t.Fatalf("expired feedback wait should clear after timeout")
}
}
@@ -2078,27 +1858,110 @@ 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")
}
}
+
+// 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/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
new file mode 100644
index 0000000..53703f6
--- /dev/null
+++ b/internal/crq/observe.go
@@ -0,0 +1,346 @@
+package crq
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "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 (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) (observation, error) {
+ pull, err := s.gh.GetPull(ctx, repo, pr)
+ if err != nil {
+ return observation{}, err
+ }
+ 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 observation{}, err
+ }
+ 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. 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{
+ Bot: review.User.Login,
+ ReviewID: review.ID,
+ Commit: dialect.ShortOID(review.CommitID),
+ SubmittedAt: review.SubmittedAt,
+ })
+ }
+
+ comments, err := s.gh.ListIssueComments(ctx, repo, pr)
+ if err != nil {
+ return observation{}, err
+ }
+ o.comments = comments
+ 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))
+ }
+
+ // 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 observation{}, err
+ }
+ for _, reaction := range reactions {
+ if s.isConfiguredBot(reaction.User.Login) {
+ o.eng.Reacted = true
+ }
+ if isCurrentCodexThumbsUp(reaction, cutoff) {
+ o.eng.CodexThumbsUp = true
+ }
+ }
+ }
+ if !o.eng.CodexThumbsUp && s.codexRelevant(o.eng) {
+ reactions, err := s.gh.ListIssueReactions(ctx, repo, pr)
+ if err != nil {
+ return observation{}, err
+ }
+ for _, reaction := range reactions {
+ if isCurrentCodexThumbsUp(reaction, cutoff) {
+ o.eng.CodexThumbsUp = true
+ break
+ }
+ }
+ }
+ }
+
+ // 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) {
+ cr, codex, err := s.reviewCommands(ctx, repo, pr, o.eng, adoptCutoff(*round), pull, comments, reviews)
+ if err != nil {
+ return observation{}, err
+ }
+ o.eng.Commands = cr
+ o.eng.CodexCommands = codex
+ }
+ return o, 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
+}
+
+// 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)
+ 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) != obs.Head {
+ return nil, nil, nil
+ }
+ 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, 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. 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 {
+ cr = s.adoptableCR(obs, cutoff, command, comments, reviews)
+ }
+ if hasCodex {
+ 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
+// 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
+ 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
+ }
+ 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
+ }
+ }
+ return false
+}
+
+// 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{}, nil
+ }
+ 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{}, err
+ }
+ nodes := result.Repository.PullRequest.TimelineItems.Nodes
+ if len(nodes) == 0 {
+ return time.Time{}, nil
+ }
+ return nodes[len(nodes)-1].CreatedAt.UTC(), 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/replay_test.go b/internal/crq/replay_test.go
new file mode 100644
index 0000000..ac4a3c9
--- /dev/null
+++ b/internal/crq/replay_test.go
@@ -0,0 +1,708 @@
+package crq
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "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
+ cfg.SettleWindow = 0 // scenarios assert immediate verdicts; the settle test opts in
+ return cfg
+}
+
+// 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 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.
+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()
+ // 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: "[review body]"}
+ 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(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
+ // 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, corpusMessage(t, "coderabbit/completion-reply.md"), 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, corpusMessage(t, "coderabbit/review-in-progress.md"), 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, 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 ||
+ 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(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)) {
+ 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))
+ }
+}
+
+// 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.go b/internal/crq/service.go
index 8a9b90f..704a1a5 100644
--- a/internal/crq/service.go
+++ b/internal/crq/service.go
@@ -11,6 +11,10 @@ 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 Logger interface {
@@ -18,33 +22,64 @@ 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
}
type Service struct {
cfg Config
+ cr dialect.CodeRabbit
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
+ // 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 {
- 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}
+}
+
+// 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.
+const warnRateLimited = dialect.ReasonRateLimited
+
type EnqueueResult struct {
Repo string `json:"repo"`
PR int `json:"pr"`
@@ -55,48 +90,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 := s.clock()
+ 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 {
@@ -106,31 +134,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 := s.clock()
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 {
@@ -153,673 +190,825 @@ 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
+ now := s.clock()
+ st, _, err := s.store.Load(ctx)
+ if err != nil {
+ return PumpResult{}, err
}
- state, _, err := s.store.Load(ctx)
- if err != nil {
+ // 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)
+ }
+
+ // 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
}
- if pruned := s.pruneExpiredWaits(ctx, state); pruned != nil {
- state = *pruned
+
+ // 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
}
- state = s.sweepFeedbackWaits(ctx, state)
- queue := state.SortedQueue()
- if len(queue) == 0 {
+
+ // 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")
+ }
+ // 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
+ }
}
- if refreshed, err := s.RefreshQuota(ctx); err == nil {
- state = refreshed
- } else {
+ now = s.clock()
+ // 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
+ }
+ obs, err := s.observe(ctx, next.Repo, next.PR, next, now)
+ if err != nil {
+ return PumpResult{}, err
+ }
+ 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 {
+ return engine.Global{
+ SlotFree: st.SlotRound() == nil,
+ BlockedUntil: st.Account.BlockedUntil,
+ LastFired: st.LastFired,
+ }
+}
+
+// progressSlotRound observes and progresses the round holding the fire slot.
+func (s *Service) progressSlotRound(ctx context.Context, slot Round) (PumpResult, error) {
+ now := s.clock()
+ st, _, err := s.store.Load(ctx)
+ if err != nil {
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
+ obs, err := s.observe(ctx, slot.Repo, slot.PR, &slot, now)
+ if err != nil {
+ return PumpResult{}, err
}
- if state.LastFired != nil && now.Sub(*state.LastFired) < s.cfg.MinInterval {
- return PumpResult{Action: "min_interval", Reason: s.cfg.MinInterval.String()}, nil
+ 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
}
- queue = state.SortedQueue()
- if len(queue) == 0 {
- return PumpResult{Action: "idle"}, nil
+ if s.cfg.DryRun {
+ return slotResult(slot, tr), nil
}
- item = queue[0]
- head, open, err := s.pullHead(ctx, item.Repo, item.PR)
+ 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
+ }
+ return s.applyTransition(st, r, tr, now)
+ })
if err != nil {
return PumpResult{}, err
}
+ 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)
+ }
+ 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
+}
+
+// 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
+ }
+}
+
+// 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.
+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 {
- // 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 st, nil
+ }
+ var target *Round
+ for key := range st.Rounds {
+ r := st.Rounds[key]
+ if r.Phase != PhaseFired && r.Phase != PhaseReviewing {
+ continue
+ }
+ if target == nil || firedOrEnqueuedAt(r).Before(firedOrEnqueuedAt(*target)) {
+ c := r
+ target = &c
}
- return PumpResult{Action: "dry_run", Repo: item.Repo, PR: item.PR, Head: head}, nil
}
- if !open {
- return s.dropClosedQueueItem(ctx, item)
+ if target == nil {
+ return st, nil
}
- if !isShortSHA(head) {
- return PumpResult{Action: "skipped", Repo: item.Repo, PR: item.PR, Reason: "could not read head"}, nil
+ obs, err := s.observe(ctx, target.Repo, target.PR, target, now)
+ if err != nil {
+ if s.log != nil {
+ s.log.Printf("warning: reviewing-round sweep for %s#%d failed: %v", target.Repo, target.PR, err)
+ }
+ return st, nil
}
- key := QueueKey(item.Repo, item.PR)
- pending := state.AwaitingFeedback[key]
- if state.Fired[key] == head || pending.Head == head {
- deduped := false
+ 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
+ }
+ updated, err := s.store.Update(ctx, func(st *State) error {
+ r := st.Round(target.Repo, target.PR)
+ // 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)
+ })
+ if err != nil {
+ return st, err
+ }
+ s.sync(ctx, updated)
+ 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.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:
+ 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{}, "", d.PostCodex, now)
+ default: // FireNo
+ return PumpResult{Action: mapFireNo(d.Reason), Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: d.Reason}, nil
+ }
+}
+
+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 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 {
+ return result, nil
+ }
+ ended := false
+ updated, err := s.store.Update(ctx, func(st *State) error {
+ if !sameRound(st.Round(round.Repo, round.PR), round) {
+ 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 !ended {
+ return PumpResult{Action: "lost_race"}, nil
+ }
+ s.sync(ctx, updated)
+ return result, nil
+}
+
+// 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 !sameRound(r, round) || !r.FireEligible(now) {
+ return ErrNoChange
+ }
+ if err := r.Dedupe(now); err != nil {
+ return err
+ }
+ st.PutRound(*r)
+ deduped = true
+ return nil
+ })
+ if err != nil {
+ return PumpResult{}, err
+ }
+ if !deduped {
+ return PumpResult{Action: "lost_race"}, nil
+ }
+ s.sync(ctx, updated)
+ return result, 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
+ }
+ updated, err := s.store.Update(ctx, func(st *State) error {
+ if !sameRound(st.Round(round.Repo, round.PR), round) {
+ return ErrNoChange
+ }
+ _, err := st.Supersede(round.Repo, round.PR, head, now)
+ return err
+ })
+ if err != nil {
+ return PumpResult{}, err
+ }
+ 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. 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
+ }
+ 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
+ }
+ recorded := 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 {
+ recorded = false
+ if st.FireSlot != nil {
return ErrNoChange
}
- currentPending := st.AwaitingFeedback[key]
- if st.Fired[key] != head && currentPending.Head != head {
+ r := st.Round(round.Repo, round.PR)
+ if !sameRound(r, round) || !r.FireEligible(now) {
return ErrNoChange
}
- removeQueued(st, item.Seq)
- if currentPending.Head == head {
- st.Fired[key] = head
+ if err := r.Reserve(token, s.cfg.Host, now); err != nil {
+ return err
}
- deduped = true
+ 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}
+ // Claim the Codex post in the SAME write: the fired state must never be
+ // visible with CodexCommandID == 0 and no claim, or another daemon can
+ // self-heal-post in the gap before fireCodexReview below runs.
+ if postCodex {
+ t := now.UTC()
+ r.CodexClaimedAt = &t
+ } else if id := newestCommandID(obs.CodexCommands); id != 0 && r.CodexCommandID == 0 {
+ // Not posting because a live `@codex review` command already answers
+ // this head — record its id now. The self-heal scan anchors on FiredAt
+ // and would miss a command posted before the adopted CodeRabbit command,
+ // posting a duplicate; recording it here keeps the round "asked".
+ r.CodexCommandID = id
+ }
+ st.PutRound(*r)
+ recorded = true
return nil
})
if err != nil {
return PumpResult{}, err
}
- if !deduped {
+ if !recorded {
return PumpResult{Action: "lost_race"}, nil
}
s.sync(ctx, updated)
- 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 {
- 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
- }
- 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 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.log.Printf("fire %s@%s (adopted existing review command)", key, round.Head)
+ }
+ if postCodex {
+ s.fireCodexReview(ctx, round)
}
- return PumpResult{Action: "fired", Repo: item.Repo, PR: item.PR, Head: head, Reason: "review command already posted"}, nil
+ return PumpResult{Action: "fired", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil
}
- token := randomToken()
+ // Reserve the slot, then post the command.
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 {
+ 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 !sameRound(r, round) || !r.FireEligible(now) {
return ErrNoChange
}
- 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 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
}
- s.sync(ctx, reserved)
- if reserved.InFlight == nil || reserved.InFlight.Token != token {
+ if reserved.FireSlot == nil || reserved.FireSlot.Token != token {
return PumpResult{Action: "lost_race"}, nil
}
- comment, err := s.gh.PostIssueComment(ctx, item.Repo, item.PR, s.cfg.ReviewCommand)
+ s.sync(ctx, reserved)
+
+ 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 {
- if st.InFlight == nil || st.InFlight.Token != token {
- return nil
+ r := st.Round(round.Repo, round.PR)
+ if r == nil || r.Token != token {
+ return ErrNoChange
}
- st.Queue = append(st.Queue, item)
- st.InFlight = nil
+ if rerr := r.AwaitRetry(now.Add(postFailureBackoff), "failed to post review command: "+err.Error(), now); rerr != nil {
+ return rerr
+ }
+ releaseSlot(st, key)
st.Warn = "failed to post review command: " + err.Error()
+ st.PutRound(*r)
return nil
})
if uerr == nil {
s.sync(ctx, updated)
}
- return PumpResult{Action: "post_failed", Repo: item.Repo, PR: item.PR, Head: head, Reason: err.Error()}, err
+ return PumpResult{Action: "post_failed", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: err.Error()}, err
}
- // 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.
+ // 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 = time.Now().UTC()
+ 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.markReviewPosted(ctx, token, item, head, comment.ID, firedAt)
+ updated, err := s.recordFire(ctx, round, token, comment.ID, codexID, firedAt, now)
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
+ 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, head, strings.TrimSpace(s.cfg.ReviewCommand))
+ s.log.Printf("fire %s@%s (posted %s)", key, round.Head, strings.TrimSpace(s.cfg.ReviewCommand))
}
- return PumpResult{Action: "fired", Repo: item.Repo, PR: item.PR, Head: head}, nil
+ return PumpResult{Action: "fired", Repo: round.Repo, PR: round.PR, Head: round.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"}
+// 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 result, nil
+ return PumpResult{Action: "dry_run", Repo: round.Repo, PR: round.PR, Head: round.Head, Reason: reason}, nil
}
- removed := 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
- }
- }
- return ErrNoChange
- })
- if err != nil {
- return PumpResult{}, err
- }
- if !removed {
- return PumpResult{Action: "lost_race"}, nil
- }
- s.sync(ctx, updated)
- return result, nil
-}
+ token := randomToken()
-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 {
+ 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
}
- 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.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)
-// 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)
+ comment, err := s.gh.PostIssueComment(ctx, round.Repo, round.PR, s.cfg.CodexCommand)
if err != nil {
- return IssueComment{}, false, err
- }
- command := strings.TrimSpace(s.cfg.ReviewCommand)
- if command == "" {
- return IssueComment{}, false, 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, 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.AwaitRetry(now.Add(postFailureBackoff), "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
}
- if !hasCandidate {
- return IssueComment{}, false, nil
+ firedAt := comment.CreatedAt.UTC()
+ if firedAt.IsZero() {
+ firedAt = now
}
- cutoff := notBefore
- pull, err := s.gh.GetPull(ctx, repo, pr)
+ // 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 {
- 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
+ if errors.Is(err, ErrNoChange) {
+ return PumpResult{Action: "lost_race"}, nil
}
+ 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)
+ if s.log != nil {
+ s.log.Printf("fire %s@%s (coderabbit already reviewed; posted %s)", key, round.Head, strings.TrimSpace(s.cfg.CodexCommand))
}
- 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
+ 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
}
}
- if !ok {
- return IssueComment{}, false, nil
+ 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
}
- // 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
+ 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
}
- return IssueComment{}, false, nil
}
- for _, review := range reviews {
- if s.isConfiguredBot(review.User.Login) && !review.SubmittedAt.Before(bestAt) {
- return IssueComment{}, false, nil
+ for _, c := range obs.CodexCommands {
+ if c.ID == codexID && !c.CreatedAt.IsZero() {
+ anchor = c.CreatedAt
}
}
- 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 {
+ 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
}
- q := st.SortedQueue()
- if len(q) == 0 || q[0].Seq != item.Seq {
- return ErrNoChange
+ deadline := now.Add(s.cfg.FeedbackWaitTimeout)
+ if err := r.AwaitCoReview(deadline, anchor); err != nil {
+ return err
}
- 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 r.CodexCommandID == 0 && codexID != 0 {
+ r.CodexCommandID = codexID
}
+ st.PutRound(*r)
+ changed = true
return nil
})
if err != nil {
- return State{}, err
+ return PumpResult{}, err
}
- if !recorded {
- return state, ErrNoChange
+ if !changed {
+ return PumpResult{Action: "lost_race"}, nil
}
- return state, nil
+ s.sync(ctx, updated)
+ return result, nil
}
-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
+// recordFire records the posted command on the reserved round, with a 30s retry
+// on a transient state-write failure so a fired command is never lost. codexID
+// is the Codex command comment posted alongside (0 when none), recorded in the
+// same write.
+func (s *Service) recordFire(ctx context.Context, round Round, token string, commandID, codexID int64, firedAt, now time.Time) (State, error) {
+ 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 !sameRound(r, round) || st.FireSlot == nil || st.FireSlot.Token != token || r.Token != token {
+ return ErrNoChange
}
- }
- if lastFeedbackCheck.IsZero() || time.Since(lastFeedbackCheck) >= feedbackCheckEvery {
- report, err := s.Feedback(ctx, repo, pr)
- if err != nil {
- return PumpResult{}, 1, err
+ if err := r.Fire(commandID, firedAt); err != nil {
+ return 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
+ if codexID != 0 {
+ r.CodexCommandID = codexID
}
+ 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.WithoutCancel(ctx), 30*time.Second)
+ defer cancel()
+ st, recorded, err = record(retryCtx)
+ }
+ if err != nil {
+ return State{}, err
+ }
+ if !recorded {
+ return st, ErrNoChange
+ }
+ 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)
}
- 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
- }
- enqueued = false
- continue
+ 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 {
+ // 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)
+ // Identity guard: a same-head replacement round (new Seq) must not
+ // inherit this post's result.
+ if !sameRound(r, round) {
+ return ErrNoChange
}
- 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()
+ r.CodexClaimedAt = nil
+ if r.CodexCommandID == 0 {
+ r.CodexCommandID = codexID
}
- select {
- case <-ctx.Done():
- return PumpResult{}, 1, ctx.Err()
- case <-time.After(s.cfg.PollInterval):
+ 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)
}
-func queuedFeedbackCheckEvery(poll time.Duration) time.Duration {
- if poll <= 0 {
- return 30 * time.Second
+// 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
}
- if poll < 30*time.Second {
- return poll
+ commandPresent := engine.CodexCommandSince(obs, round.FiredAt.UTC())
+ if !engine.DecideCodexPost(round, obs, s.policy(), commandPresent) {
+ return
}
- return 30 * time.Second
+ // 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 {
- 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 {
@@ -837,10 +1026,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 {
@@ -849,34 +1034,42 @@ 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 "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 "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
}
- 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 && now.Sub(*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
- }
- // 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)) {
+ // 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
+ prevBlock, prevRemaining := st.Account.BlockedUntil, st.Account.Remaining
+ st.Account = quota
+ if st.Account.RLCommentID == 0 {
+ st.Account.RLCommentID = rlID
+ st.Account.RLCommentUpdated = rlUpdated
+ }
+ // 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
@@ -888,7 +1081,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 {
@@ -897,16 +1090,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 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)
+ issue, err := s.gh.CreateIssue(ctx, s.cfg.GateRepo, "crq account-quota calibration", calibrationIssueBody)
if err != nil {
return 0, err
}
@@ -928,32 +1118,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
+ remaining, reset := dialect.ParseQuota(reply.Body, reply.UpdatedAt)
+ 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)
@@ -971,37 +1160,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
+ remaining, reset := dialect.ParseQuota(reply.Body, reply.UpdatedAt)
+ 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
@@ -1039,20 +1225,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) {
@@ -1069,95 +1255,31 @@ func (s *Service) latestCalibrationReply(ctx context.Context, issue int, after t
return best, ok, nil
}
-func (s *Service) isConfiguredBot(login string) bool {
- return normalizeBotName(login) == normalizeBotName(s.cfg.Bot)
+// 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)
}
-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
+func reviewedByConfiguredBot(reviewedBy map[string]bool, bot string) bool {
+ for login, ok := range reviewedBy {
+ if ok && isConfiguredBotLogin(bot, login) {
+ return true
+ }
+ }
+ return false
}
-// 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"
-)
+func (s *Service) isConfiguredBot(login string) bool {
+ return dialect.NormalizeBotName(login) == dialect.NormalizeBotName(s.cfg.Bot)
+}
// 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 {
@@ -1167,298 +1289,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 {
@@ -1471,9 +1301,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 {
@@ -1489,19 +1318,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
@@ -1511,27 +1327,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 {
@@ -1540,126 +1335,199 @@ 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")
-}
-
-// 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") {
+// 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 *ghapi.APIError
+ if !errors.As(err, &api) {
return false
}
- return !s.isRateLimited(l)
+ b := strings.ToLower(api.Body)
+ return strings.Contains(b, "commenting is disabled") || strings.Contains(b, "more than 2500 comments")
}
-// 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])
+// 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). 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.
+// 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
+ // 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 && s.clock().Sub(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 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)
+ if err != nil {
+ return PumpResult{}, 1, err
+ }
+ 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.
+ 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(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)
+ }
+ 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 || 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) {
+ 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 != "" && 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 {
+ return result, 0, nil
+ }
+ enqueued = false
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 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, s.clock().Sub(start).Round(time.Second))
+ lastLog = time.Now()
+ }
+ select {
+ case <-ctx.Done():
+ return PumpResult{}, 1, ctx.Err()
+ case <-time.After(s.cfg.PollInterval):
}
}
- 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 queuedFeedbackCheckEvery(poll time.Duration) time.Duration {
+ if poll <= 0 {
+ return 30 * time.Second
+ }
+ if poll < 30*time.Second {
+ return poll
+ }
+ return 30 * time.Second
}
-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
+// 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 keys []string
+ for key := range st.Rounds {
+ if st.Rounds[key].Phase == PhaseAwaitingRetry {
+ keys = append(keys, key)
+ }
+ }
+ 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
}
}
- return nil
+ 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
+ }
+ if open {
+ return PumpResult{}, false, nil
+ }
+ res, err := s.abandonRound(ctx, *target, "pr closed", "skipped")
+ return res, true, err
}
diff --git a/internal/crq/service_test.go b/internal/crq/service_test.go
index 385bfd6..ece8318 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,177 +32,99 @@ type fakeGitHub struct {
nextIssueNumber int
postErrs map[string]error
graphQL func(query string, vars map[string]any, out any) error
- 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
+ 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 (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
+func (f *fakeGitHub) clock() time.Time {
+ if f.now != nil {
+ return f.now().UTC()
}
- return state, nil
+ return time.Now().UTC()
}
-func (s *staleDedupeStore) SyncDashboard(context.Context, State) error { return nil }
-
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()}
+ now := f.clock()
+ comment := ghapi.IssueComment{ID: f.commentID, Body: body, CreatedAt: now, UpdatedAt: now}
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 {
@@ -206,10 +132,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)]
@@ -221,7 +147,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 {
@@ -239,13 +165,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)
@@ -269,6 +195,153 @@ 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 {
+ 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{
@@ -282,17 +355,17 @@ 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{
- 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
+ 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),
+ mkc(4, "kristofferR", "@coderabbitai rate limit", now),
}
deleted := svc.pruneCalibration(context.Background(), 1, now.Add(-2*time.Minute), 80)
@@ -335,46 +408,39 @@ 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]"),
}
gh := newFakeGitHub()
- gh.searchPRs = []SearchPR{
+ gh.searchPRs = []ghapi.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
+ var pull ghapi.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 st.FiredMarker("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,35 +450,38 @@ 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: "",
}
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
}
- 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 st.FiredMarker("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 +489,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())))
}
}
@@ -483,9 +519,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 {
@@ -496,74 +532,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,23 +547,29 @@ 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
+ var pull ghapi.Pull
pull.State = "open"
pull.Head.SHA = "abcdef1234567890"
gh.pulls["owner/repo#12"] = pull
@@ -638,20 +612,9 @@ 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
+ var pull ghapi.Pull
pull.State = "open"
pull.Head.SHA = "abcdef1234567890"
gh.pulls["owner/repo#12"] = pull
@@ -673,51 +636,37 @@ 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 state.FiredMarker("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 r.FiredAt == nil || r.WaitDeadline == nil {
+ t.Fatalf("feedback wait should be set on the fired round: %#v", r)
}
- 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)
- }
- 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))
}
}
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
+ 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}
+ gh.graphQL = noForcePush
store := NewMemoryStore(cfg)
service := NewService(cfg, gh, store, nil)
@@ -738,77 +687,68 @@ 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 state.FiredMarker("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) {
- 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)
}
}
-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
+ 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)
- 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.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)
}
}
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
+ 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)
- 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,49 +756,39 @@ 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
+ 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)
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,44 +803,28 @@ 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)
- 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)
@@ -935,36 +849,23 @@ 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
+ 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)
@@ -985,37 +886,22 @@ 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
+ 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)
@@ -1036,42 +922,30 @@ 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)
- 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}
+ gh.graphQL = noForcePush
store := NewMemoryStore(cfg)
service := NewService(cfg, gh, store, nil)
@@ -1092,36 +966,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
+ 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 := ghapi.Review{CommitID: "abcdef1234567890", SubmittedAt: time.Now().UTC()}
+ review.User.Login = cfg.Bot
+ gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.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,37 +989,23 @@ 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
+ 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)
@@ -1178,515 +1024,263 @@ 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)
- review := Review{CommitID: "abcdef1234567890", SubmittedAt: firedAt.Add(time.Minute)}
+ var pull ghapi.Pull
+ pull.State = "open"
+ pull.Head.SHA = "abcdef1234567890"
+ gh.pulls[fakeKey("owner/repo", 12)] = pull
+ 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)
- 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)
- command := IssueComment{ID: 5, Body: cfg.ReviewCommand, CreatedAt: firedAt, UpdatedAt: firedAt}
+ var pull ghapi.Pull
+ pull.State = "open"
+ pull.Head.SHA = "abcdef1234567890"
+ gh.pulls[fakeKey("owner/repo", 12)] = pull
+ 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}
- // 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.
- prior := Review{ID: 9, CommitID: "0123456fedcba", State: "COMMENTED", SubmittedAt: firedAt.Add(-time.Hour)}
+ 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), Body: "**Actionable comments posted: 2**"}
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)
- 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)
- command := IssueComment{ID: 5, Body: cfg.ReviewCommand, CreatedAt: firedAt, UpdatedAt: firedAt}
+ var pull ghapi.Pull
+ pull.State = "open"
+ pull.Head.SHA = "abcdef1234567890"
+ gh.pulls[fakeKey("owner/repo", 12)] = pull
+ 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}
- // No reviews on the PR at all — the bot has never reviewed it.
+ gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{command, reply}
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 st.WaitingHead("owner/repo", 12) != "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)
- reaction := Reaction{}
+ var pull ghapi.Pull
+ pull.State = "open"
+ pull.Head.SHA = "abcdef1234567890"
+ gh.pulls[fakeKey("owner/repo", 12)] = pull
+ 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)
- 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)
- }
- state, _, err := store.Load(ctx)
- if err != nil {
- t.Fatal(err)
+ if pumped.Action != "cleared" {
+ t.Fatalf("expected the reaction to release the slot, got %#v", pumped)
}
- 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)
- review := Review{SubmittedAt: firedAt.Add(time.Minute), CommitID: "abcdef1234567890"}
+ var pull ghapi.Pull
+ pull.State = "open"
+ pull.Head.SHA = "abcdef1234567890"
+ gh.pulls[fakeKey("owner/repo", 12)] = pull
+ 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)
- 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)
}
- staleCodex := Review{SubmittedAt: firedAt.Add(2 * time.Minute), CommitID: "0123456789abcdef"}
+ // A required-bot review for a different commit must not complete it.
+ 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}
- 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)
- }
+ gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{review, staleCodex}
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)
}
- codex := Review{SubmittedAt: firedAt.Add(3 * time.Minute), CommitID: "abcdef1234567890"}
+ // The head review completes it.
+ 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}
- 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)
- }
+ gh.reviews[fakeKey("owner/repo", 12)] = []ghapi.Review{review, staleCodex, codex}
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 ghapi.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 := 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)
}
- 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 ghapi.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 {
- t.Fatal(err)
- }
+ seedRound(t, store, cfg, "owner/repo", 12, "abcdef123", PhaseReviewing, time.Now().UTC().Add(-2*time.Hour), 5)
- 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 {
+ if _, err := service.Pump(ctx); err != nil {
t.Fatal(err)
}
- if len(state.AwaitingFeedback) != 0 {
- t.Fatalf("the sweep must clear a completion-backed wait, 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 TestPumpDryRunDoesNotPruneWaits(t *testing.T) {
+func TestPumpTreatsExistingReviewAdoptionRaceAsLostRace(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)
- }
-}
-
-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
+ 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}
- 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)
+ gh.comments[fakeKey("owner/repo", 12)] = []ghapi.IssueComment{comment}
+ 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,46 +1294,33 @@ 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, 0, 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
+ 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)
- 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 +1328,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,37 +1336,20 @@ 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
+ 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
- // 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,36 +1378,22 @@ 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
+ 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)
@@ -1859,42 +1409,29 @@ 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
+ 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)
@@ -1910,47 +1447,33 @@ 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
+ 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)
- 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,45 +1481,26 @@ 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
+ 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(), State{}, "o/repo", 5, true)
+ need, _, err := svc.needsReview(context.Background(), DefaultState(cfg), "o/repo", 5, true)
if err != nil {
t.Fatal(err)
}
@@ -2005,10 +1509,10 @@ 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}
- need, err = svc.needsReview(context.Background(), State{}, "o/repo", 5, false)
+ 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)
}
@@ -2017,22 +1521,57 @@ 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 := ghapi.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
+ var pull ghapi.Pull
pull.State = "closed"
pull.Merged = true
pull.Head.SHA = "abcdef1234567890"
@@ -2053,44 +1592,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
+ var pull ghapi.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 +1626,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 +1634,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)
+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 != "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)
- 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 st.ContainsActive(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
+ var pull ghapi.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,122 +1664,14 @@ func TestEnqueueDedupesAlreadyFiredHead(t *testing.T) {
}
}
-func TestMemoryStoreConcurrentUpdatesDoNotLoseMutations(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) {
+func TestRateLimitedRoundParksAndBlocksAccount(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"}
+ pull := ghapi.Pull{State: "open"}
pull.Head.SHA = head + "abcdef0"
gh.pulls[fakeKey("o/carrier", 82)] = pull
store := NewMemoryStore(cfg)
@@ -2379,13 +1689,13 @@ 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 := 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 {
@@ -2395,14 +1705,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 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 !st.Contains("o/carrier", 82) {
- t.Fatal("the unreviewed PR must remain queued")
+ if !st.ContainsActive("o/carrier", 82) {
+ t.Fatal("the unreviewed PR must remain active")
}
if _, err := svc.Pump(ctx); err != nil {
@@ -2419,126 +1732,118 @@ func TestRateLimitedAlreadyReviewedAckWithoutReviewRequeues(t *testing.T) {
}
}
-func TestNeedsReviewSkipsCooledDownHead(t *testing.T) {
+func TestRotateCalibrationPersistsNewIssue(t *testing.T) {
ctx := context.Background()
- cfg := Config{Bot: "coderabbitai", Host: "h"}
+ cfg := Config{GateRepo: "o/gate", CalibrationPR: 1, Scope: []string{"o"}}
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)}
+ store := NewMemoryStore(cfg)
+ svc := NewService(cfg, gh, store, nil)
- need, err := svc.needsReview(ctx, st, "o/carrier", 82, true)
+ n, err := svc.rotateCalibration(ctx, 1)
if err != nil {
t.Fatal(err)
}
- if need {
- t.Fatal("needsReview must skip a head under an active fire cooldown")
+ if n <= 0 {
+ t.Fatalf("expected a fresh issue number, got %d", n)
}
-
- // 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)
+ st, _, _ := store.Load(ctx)
+ if st.CalibrationIssue != n {
+ t.Fatalf("expected state.CalibrationIssue=%d, got %d", n, st.CalibrationIssue)
}
- if !need {
- t.Fatal("a new head must not be blocked by a prior head's cooldown")
+ if svc.calibrationIssue(st) != n {
+ t.Fatalf("calibrationIssue should return the rotated issue %d, got %d", n, svc.calibrationIssue(st))
+ }
+ if len(gh.createdIssues) != 1 {
+ t.Fatalf("expected exactly one issue created, got %d", len(gh.createdIssues))
}
}
-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{},
+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)
+ }
+ }()
}
- 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)
+ wg.Wait()
+ state, _, err := store.Load(ctx)
+ if err != nil {
+ t.Fatal(err)
}
- if st.Blocked.RLCommentID != 77 {
- t.Fatalf("expected rate-limit comment id tracked, got %d", st.Blocked.RLCommentID)
+ if state.NextSeq != 50 {
+ t.Fatalf("lost updates: got %d want 50", state.NextSeq)
}
- 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 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 := dialect.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 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")
+func TestParseAvailableInPlainFormatStillWorks(t *testing.T) {
+ base := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC)
+ 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)
}
- 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)
+ 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)
}
}
-func TestRotateCalibrationPersistsNewIssue(t *testing.T) {
+// 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: "o/gate", CalibrationPR: 1, Scope: []string{"o"}}
- gh := newFakeGitHub()
+ 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)
- n, err := svc.rotateCalibration(ctx, 1)
+ updated, err := svc.RefreshQuota(ctx)
if err != nil {
t.Fatal(err)
}
- if n <= 0 {
- t.Fatalf("expected a fresh issue number, got %d", n)
- }
- st, _, _ := store.Load(ctx)
- if st.CalibrationIssue != n {
- t.Fatalf("expected state.CalibrationIssue=%d, got %d", n, st.CalibrationIssue)
- }
- if svc.calibrationIssue(st) != n {
- t.Fatalf("calibrationIssue should return the rotated issue %d, got %d", n, svc.calibrationIssue(st))
- }
- if len(gh.createdIssues) != 1 {
- t.Fatalf("expected exactly one issue created, got %d", len(gh.createdIssues))
- }
-}
-
-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")
+ 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)
}
}
diff --git a/internal/crq/state.go b/internal/crq/state.go
index 76dc3bc..b3364fd 100644
--- a/internal/crq/state.go
+++ b/internal/crq/state.go
@@ -1,297 +1,93 @@
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"
+ ghapi "github.com/kristofferR/coderabbit-queue/internal/gh"
+ 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"`
-}
-
-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"`
-}
-
-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"`
}
-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"`
+// NewGitStateStore builds the git-ref-backed store. The logger surfaces the
+// loud auto-reinit line when a stale-schema payload is loaded.
+func NewGitStateStore(cfg Config, gh *ghapi.GitHub, log Logger) *crqstate.GitStateStore {
+ return crqstate.NewGitStateStore(cfg.storeConfig(), gh, log)
}
-type LeaderLease struct {
- Owner string `json:"owner"`
- Token string `json:"token"`
- ExpiresAt time.Time `json:"expires_at"`
- UpdatedAt time.Time `json:"updated_at"`
+func NewMemoryStore(cfg Config) *crqstate.MemoryStore {
+ return crqstate.NewMemoryStore(cfg.storeConfig())
}
+// DefaultState returns a fresh v3 state seeded with the configured scope, used
+// by tests and init.
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"},
- }
+ st := crqstate.New()
+ st.Account.Scope = strings.Join(cfg.Scope, ",")
+ st.Account.Source = "init"
+ return st
}
-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 renderDashboard(st State, cfg Config) string {
+ return crqstate.RenderDashboard(st, cfg.storeConfig())
+}
+func renderTitle(st State) string { return crqstate.RenderTitle(st) }
+func issueBody(st State, cfg Config) (string, error) {
+ return crqstate.IssueBody(st, cfg.storeConfig())
}
-func splitQueueKey(key string) (string, int) {
- repo, prText, ok := strings.Cut(strings.ToLower(key), "#")
- if !ok {
- return "", 0
+// policy assembles the engine Policy from config.
+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,
}
- pr, err := strconv.Atoi(prText)
- if err != nil {
- return "", 0
- }
- return NormalizeRepo(repo), pr
}
func NormalizeRepo(repo string) string {
@@ -303,190 +99,3 @@ func NormalizeRepo(repo string) string {
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)
-}
-
-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
-}
-
-func (s State) SortedAwaitingFeedback() []FeedbackWait {
- out := make([]FeedbackWait, 0, len(s.AwaitingFeedback))
- for _, wait := range s.AwaitingFeedback {
- out = append(out, wait)
- }
- 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
- }
- }
- 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
-}
-
-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)
- }
-
- 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)
- }
- }
-
- 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)
- }
- }
-
- 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()
-}
-
-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"
- }
-}
-
-func issueBody(state State, cfg Config) (string, error) {
- machine, err := json.Marshal(state)
- if err != nil {
- return "", err
- }
- 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[:])
-}
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/dialect/coderabbit.go b/internal/dialect/coderabbit.go
new file mode 100644
index 0000000..6405421
--- /dev/null
+++ b/internal/dialect/coderabbit.go
@@ -0,0 +1,321 @@
+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
+}
+
+// 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.
+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*(.*)$")
+)
+
+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..0ca66e3
--- /dev/null
+++ b/internal/dialect/codex.go
@@ -0,0 +1,165 @@
+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)?sub>`)
+ // 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})`")
+)
+
+// 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) == NormalizeBotName(CodexBotLogin)
+}
+
+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) != ""
+}
+
+// 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 {
+ 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
+// 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 {
+ 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..9cb9b25
--- /dev/null
+++ b/internal/dialect/common.go
@@ -0,0 +1,238 @@
+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"
+ }
+}
+
+// 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":
+ 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 {
+ if IsCodexNoActionReviewCompletion(text) || IsCodexUsageLimit(text) || IsCodexEnvironmentNotice(text) {
+ 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",
+ "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",
+ }
+ 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]")
+}
+
+// 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 != "" {
+ return value
+ }
+ }
+ return ""
+}
diff --git a/internal/dialect/event.go b/internal/dialect/event.go
new file mode 100644
index 0000000..eb5961e
--- /dev/null
+++ b/internal/dialect/event.go
@@ -0,0 +1,128 @@
+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
+ 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
+ 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
+ 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
+// 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; 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
+// (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 codexCmd := strings.TrimSpace(c.CodexCommand); codexCmd != "" && trimmed == codexCmd && !IsCodexBot(author) {
+ ev.Kind = EvCodexCommand
+ return ev
+ }
+ if IsCodexBot(author) {
+ switch {
+ case IsCodexNoActionReviewCompletion(body):
+ ev.Kind = EvCodexClean
+ ev.SHA = CodexReviewedCommitSHA(body)
+ case IsCodexUsageLimit(body):
+ // The usage-limit exhaustion notice is distinct from other Codex acks:
+ // the dynamic completion gate reads it to stop waiting on a Codex that
+ // cannot finish this round.
+ ev.Kind = EvCodexUsageLimit
+ case IsNonActionableText(body):
+ ev.Kind = EvCodexNotice
+ }
+ 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/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..1fe94e8
--- /dev/null
+++ b/internal/dialect/golden_test.go
@@ -0,0 +1,227 @@
+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
+ 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 —
+ // 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},
+ {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},
+ // 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)
+ 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)
+ 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},
+ {"IsCodexUsageLimit", IsCodexUsageLimit(body), tc.codexUsageLimit},
+ {"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)
+ }
+ 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)
+ }
+ }
+ })
+ }
+}
+
+// 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)
+ }
+ }
+ })
+ }
+}
+
+// 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/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/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/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/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/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/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/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
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/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/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/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
+** 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/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/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.
diff --git a/internal/engine/codex.go b/internal/engine/codex.go
new file mode 100644
index 0000000..35288b6
--- /dev/null
+++ b/internal/engine/codex.go
@@ -0,0 +1,199 @@
+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 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.
+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 right now: its
+// most recent evidence — a submitted review or a clean summary — was not preceded
+// by an `@codex review` command. When true, crq must never post the Codex command
+// (Codex reviews unprompted). Only the LATEST evidence decides, so an old
+// unprompted review from an epoch when auto-review was on no longer suppresses
+// posting once a later commanded review lands; conversely a command posted before
+// the latest evidence marks that evidence as commanded, not automatic.
+func CodexAutoActive(obs Observation) bool {
+ latest, prev, ok := latestCodexEvidence(obs)
+ if !ok {
+ return false
+ }
+ // The latest evidence is automatic unless a command plausibly triggered it:
+ // one posted in (prev, latest]. A command older than the previous evidence
+ // belongs to an earlier round and does not explain this review — otherwise a
+ // single manual `@codex review` from three heads ago would suppress posting
+ // forever even after Codex went back to reviewing on its own.
+ return !codexCommandInWindow(obs, prev, latest)
+}
+
+// latestCodexEvidence returns the timestamps of the most recent and second-most
+// recent Codex review-or-clean-summary events, and whether any exists. prev is
+// zero when there is only one evidence item.
+func latestCodexEvidence(obs Observation) (latest, prev time.Time, ok bool) {
+ consider := func(at time.Time) {
+ if at.IsZero() {
+ return
+ }
+ switch {
+ case !ok || at.After(latest):
+ prev, latest, ok = latest, at, true
+ case at.Equal(latest):
+ // prev must stay strictly older: co-timestamped evidence (a review and
+ // its clean summary in the same second) must not close the command
+ // window to a point, or a command at that instant reads as absent and
+ // a commanded review misclassifies as automatic.
+ case at.After(prev):
+ prev = at
+ }
+ }
+ for _, review := range obs.Reviews {
+ if dialect.IsCodexBot(review.Bot) {
+ consider(review.SubmittedAt)
+ }
+ }
+ for _, ev := range obs.Events {
+ if ev.Kind == dialect.EvCodexClean {
+ consider(ev.PairTime())
+ }
+ }
+ return latest, prev, ok
+}
+
+// codexCommandInWindow reports whether an `@codex review` command was posted
+// after `after` and at or before `atOrBefore`. A zero `after` means no lower
+// bound (the latest evidence is also the first — any command up to it counts).
+func codexCommandInWindow(obs Observation, after, atOrBefore time.Time) bool {
+ for _, ev := range obs.Events {
+ if ev.Kind != dialect.EvCodexCommand {
+ continue
+ }
+ at := ev.PairTime()
+ if at.After(atOrBefore) {
+ continue
+ }
+ if !after.IsZero() && !at.After(after) {
+ continue
+ }
+ return true
+ }
+ return false
+}
+
+// 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
new file mode 100644
index 0000000..f8cfe64
--- /dev/null
+++ b/internal/engine/completion.go
@@ -0,0 +1,300 @@
+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()
+ }
+
+ // 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 != "" {
+ 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)
+ }
+ }
+
+ // 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 {
+ 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 {
+ if reviewedByBot(reviewedBy, codexBot) || codexReviewedRound(r, obs, cutoff) {
+ return true
+ }
+ // Codex either gates by configuration, or participates via a round-window
+ // comment/clean summary; its notices (usage limits, acks) do not count.
+ if !dialect.HasCodexBot(p.RequiredBots) && !codexCommentedRound(obs, cutoff) {
+ return true
+ }
+ if obs.CodexThumbsUp {
+ markReviewed(reviewedBy, codexBot)
+ return true
+ }
+ 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
+}
+
+// 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) {
+ 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..287a293
--- /dev/null
+++ b/internal/engine/engine.go
@@ -0,0 +1,113 @@
+// 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
+ 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
+ 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
+ // 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
+// 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..012497a
--- /dev/null
+++ b/internal/engine/engine_test.go
@@ -0,0 +1,624 @@
+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)
+ }
+}
+
+// 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 —
+ // 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)
+ }
+}
+
+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)
+ }
+}
+
+// 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)
+ }
+}
+
+// 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")
+ }
+}
+
+// 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]", dialect.CodexBotLogin},
+ CodexCommand: "@codex review",
+ }
+ head := "abcdef123"
+ base := Observation{Head: head, Open: true}
+ codexReviewHead := ReviewSeen{Bot: dialect.CodexBotLogin, 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)
+ }
+ })
+ }
+}
+
+// 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},
+ // 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},
+ // 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) {
+ 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,
+ // 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 != 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}}
+ 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
+// 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: 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
+ // 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]", 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)
+ }
+}
+
+// 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: 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 {
+ 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]", 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 {
+ 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/findings.go b/internal/engine/findings.go
new file mode 100644
index 0000000..417a19a
--- /dev/null
+++ b/internal/engine/findings.go
@@ -0,0 +1,39 @@
+package engine
+
+import (
+ "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 == "" || dialect.SHAPrefixMatch(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 == "" || dialect.SHAPrefixMatch(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..c8cc93c
--- /dev/null
+++ b/internal/engine/fire.go
@@ -0,0 +1,136 @@
+package engine
+
+import (
+ "strings"
+ "time"
+
+ "github.com/kristofferR/coderabbit-queue/internal/dialect"
+ "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
+ 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 {
+ Verdict FireVerdict
+ Reason string
+ // 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.
+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"}
+ }
+ // Belt-and-braces live check: even with a fresh round, never fire at a
+ // head the bot has already reviewed (e.g. state was reinitialized). But a
+ // CodeRabbit review does not finish a round that a required Codex still
+ // gates — command (or wait for) Codex instead of deduping it away. This
+ // resolution runs BEFORE the account-block and pacing gates: none of its
+ // verdicts spend CodeRabbit quota (dedupe completes, FireCodexOnly posts
+ // only the Codex command, a co-review wait posts nothing), so an account
+ // 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)
+ // 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, PostCodex: postCodex}
+ }
+ 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 — wait for it, bounded, without
+// posting or holding the slot (FireCoReviewWait); leaving the round queued with no
+// deadline is the bug that hangs the loop forever. Only when Codex gates purely by
+// configuration with no way to obtain its review (no command configured/on the PR
+// and no auto-review) fall back to completing on CodeRabbit's review; the feedback
+// gate then surfaces Codex as still pending rather than the round wedging in an
+// un-timed fire loop. Completion counts the existing CodeRabbit review, so a
+// FireCodexOnly round waits on Codex alone.
+func codexAwareDedupe(r state.Round, obs Observation, p Policy) FireDecision {
+ codexGates := dialect.HasCodexBot(p.RequiredBots) || obs.CodexAutoActive
+ if !codexGates || codexReviewedHead(obs) {
+ return FireDecision{Verdict: FireDedupe, Reason: "bot already reviewed head"}
+ }
+ if DecideCodexPost(r, obs, p, len(obs.CodexCommands) > 0) {
+ return FireDecision{Verdict: FireCodexOnly, Reason: "coderabbit reviewed head; codex still required"}
+ }
+ if obs.CodexAutoActive || len(obs.CodexCommands) > 0 || r.CodexCommandID != 0 {
+ return FireDecision{Verdict: FireCoReviewWait, Reason: "awaiting codex co-review"}
+ }
+ return FireDecision{Verdict: FireDedupe, Reason: "bot already reviewed head"}
+}
diff --git a/internal/engine/progress.go b/internal/engine/progress.go
new file mode 100644
index 0000000..05950bd
--- /dev/null
+++ b/internal/engine/progress.go
@@ -0,0 +1,190 @@
+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 (account block, timeout, failure)
+ OutReleaseSlot // reserved but never posted → back to queued
+ OutAbandon // PR closed/merged
+)
+
+// AccountBlock is an account-quota update observed from an EvRateLimited 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 // account-wide CodeRabbit quota 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 → 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
+// (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)
+
+ // 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 {
+ if !sameBot(review.Bot, p.Bot) || !reviewMatchesRound(review, r.Head, firedAt) {
+ continue
+ }
+ 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"}
+ }
+
+ // 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
+ }
+ until := resolveBlockWindow(ev, q, now, p)
+ return Transition{
+ Outcome: OutRetry,
+ Reason: dialect.ReasonRateLimited,
+ 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 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.
+ 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 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"}
+}
+
+// resolveBlockWindow ports v2's requeueInflight window logic: reuse the
+// 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.
+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()
+}
+
+// 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.
+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/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"
diff --git a/internal/state/dashboard.go b/internal/state/dashboard.go
new file mode 100644
index 0000000..f5a0838
--- /dev/null
+++ b/internal/state/dashboard.go
@@ -0,0 +1,212 @@
+package state
+
+import (
+ "crypto/sha256"
+ "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 st.Account.Remaining != nil {
+ remaining = fmt.Sprintf("%d", *st.Account.Remaining)
+ }
+ if blocked {
+ 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, "| **CodeRabbit quota** | ⚠️ account blocked |\n")
+ } else {
+ fmt.Fprintf(&b, "| **CodeRabbit quota** | ✅ not currently blocked |\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 := sha256.Sum256([]byte(value))
+ return hex.EncodeToString(sum[:])
+}
diff --git a/internal/state/state.go b/internal/state/state.go
new file mode 100644
index 0000000..821a6ac
--- /dev/null
+++ b/internal/state/state.go
@@ -0,0 +1,531 @@
+// 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"`
+
+ // 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"`
+
+ // 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.
+ 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
+}
+
+// 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, anchor 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 {
+ // 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 = ""
+ 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.
+func (r *Round) Complete() error {
+ if r.Phase != PhaseFired && r.Phase != PhaseReviewing {
+ return r.illegal(PhaseCompleted)
+ }
+ r.Phase = PhaseCompleted
+ r.Note = ""
+ 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) {
+ 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:]
+ }
+}
+
+// --- 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()
+}
diff --git a/internal/state/state_test.go b/internal/state/state_test.go
new file mode 100644
index 0000000..27d5052
--- /dev/null
+++ b/internal/state/state_test.go
@@ -0,0 +1,268 @@
+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)
+ }
+}
+
+// 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)
+ 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)
+ }
+}
+
+// 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 {
+ 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)
+ }
+}
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
+}
diff --git a/llms.txt b/llms.txt
index de04c08..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.
@@ -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
@@ -89,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