Skip to content

fix(cursor-review): confirm the first review is absent before tagging findings lost_to_fallback - #272

Merged
mattmillerai merged 3 commits into
mainfrom
matt/be-12528-confirm-review-absent
Sep 8, 2026
Merged

mattmillerai merged 3 commits into
mainfrom
matt/be-12528-confirm-review-absent

Conversation

@mattmillerai

@mattmillerai mattmillerai commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

ELI-5

When the reviewer bot posts its review, GitHub sometimes answers with an error even
though it already saved the review. Until now the script believed the error, posted a
second review, and labelled every finding "lost — the post failed". Next round then
saw each finding twice: once with its real comment thread, once with a note saying
nobody could possibly have replied to it.

Now the script checks before it decides. If GitHub rejected the request (a 4xx), the
review really is absent and nothing changes. If the error left the outcome genuinely
open (a 5xx, or the connection dropped), it reads the PR's reviews and looks: review
there → report it delivered and post nothing more; review absent → behave exactly as
before; couldn't read the list → still post the fallback, but label nothing, because a
read that failed is not the same as an answer of "no".

What changed

.github/cursor-review/post-review.py

  • New helpers beside gh_post_review / is_read_only_token_error:
    • CONSOLIDATED_MARKER — the panel discriminator, mirroring gate-unresolved.py's
      constant (and the inline jq in the workflow's already_reviewed dup-check). A
      test pins the two equal, the same way test_build_ledger.py pins the ledger's copy.
    • gh_http_status(result) — the status gh prints on stderr ((HTTP 422)), or
      None when it printed none.
    • gh_list_reviews(repo, pr)gh api --paginate --slurp /repos/…/pulls/N/reviews.
      Paginated because the review being asked about is the newest one, so on a busy PR it
      sits on the last page of an oldest-first list; the workflow's dup-check is the
      same discriminator without pagination, which it can afford because it only has to
      notice a review that already exists.
    • review_already_posted(...) -> True | False | None — three-valued on purpose.
      None (list unreadable/unparseable) must never collapse into False; a guard that
      cannot read its input must not answer a zero.
  • main()'s wholesale-fallback branch now resolves landed before building anything:
    a 4xx short-circuits to False with no API call, everything else asks the PR.
    landed is Truefinish_posted_review() and return. landed is None → log it and
    continue untagged. lost_ids gains the landed is False conjunct.
  • The success tail (emit_delivery(...) + the clamp's summary write) is extracted into
    a local finish_posted_review() closure, so the two paths that leave this body on
    the PR report it through one implementation rather than two that can drift.
  • The in-code Residual (BE-10002) paragraph is replaced by a note describing the three
    outcomes; the Every finding of the round is OFFERED to it comment now says the tag
    is applied only when the first review is confirmed absent.

.github/workflows/cursor-review.yml — comment only. The post-review job header
now reads "one POST, plus at most one paginated GET of the PR's reviews on a non-4xx
POST failure". No permission change: pull-requests: write already implies the read,
and timeout-minutes: 10 is ample.

TestsEndToEndPostTest.run_main gains existing_reviews / list_returncode and
always patches gh_list_reviews (default [[]], i.e. confirmed-absent), so no case
in this suite can shell out to a real gh. It also resets _DELIVERY_EMITTED and
captures $GITHUB_OUTPUT via a temp file, with list_calls / outputs / notes
out-parameters. Eight new cases in FirstReviewConfirmationTest cover: 4xx tags without
reading; 5xx-absent tags; 5xx-present posts once and reports delivered=true,
gated_findings=2, exit 0; a human author / another commit / a DISMISSED review each
fail the discriminator; an unreadable list posts untagged with
post_failed_count == 0; a status-less transport error goes through the read; the
marker matches the gate's; and gh_http_status parses gh stderr.

Acceptance criteria

Criterion Status
A 422 first POST behaves byte-for-byte as before ✅ verified empirically, see below
A 5xx whose review is on the PR → one review, delivered=true, no fallback, exit 0 test_a_5xx_with_the_review_present_skips_the_fallback
A 5xx with the review absent behaves as the 422 case test_a_5xx_with_the_review_confirmed_absent_tags_post_failed
A failed list read still posts the fallback, every finding [unanchorable], post_failed_count == 0 test_an_unreadable_review_list_posts_the_fallback_untagged

The byte-for-byte claim is measured, not asserted. I drove origin/main's
post-review.py and this branch's over the same fixture (one anchored + one demoted
finding, 422 stderr) with a stubbed POST, and diffed the JSON payloads: identical. The
same harness on a 502 shows old main posting 2 reviews where this branch posts 1.

The GET was exercised against the real API, not only against my own stubs. Running
gh_list_reviews / review_already_posted read-only against this repo's public PR #263
confirmed: --slurp returns a list-of-pages that flattens as written (12 reviews, 1
page); real payloads carry commit_id, state, user.type and body exactly as the
discriminator reads them; and the function answered True for the SHA that genuinely
carries a marked Bot panel review (a57f1e5) and False for that PR's actual head
SHA, which does not. That is the one part of this change a unit test could only assert
against its own premise.

Residual

  • A prior round's review is no longer mistaken for this one — the residual is now
    narrower.
    The panel (6 of 6 reviewers) was right that
    body.startswith(CONSOLIDATED_MARKER) identifies "some bot panel review exists at
    this SHA", not this run's: a previous round's body-only fallback and any
    post_error_review body both open with the marker, are Bot-authored and carry the
    same commit_id. Matching one returned True, suppressing the fallback and reporting
    delivered=true over another round's threads — trading a duplicate review for a
    silent loss of this round's findings. The discriminator now requires the stored
    body to BE posted_body, up to the CRLF/trailing-whitespace normalization GitHub
    applies. What remains is "a prior round posted a byte-identical BODY at the same head
    SHA". Round 2 correctly pushed back on my first wording of this: that is not the
    same as "the same findings". The body carries the header, the counts, the severity
    table and the body-only section; each inline finding's path, line and text lives in
    payload["comments"], which this comparison never sees — so two rounds sharing a
    count and severity mix could match while differing inline. Narrower than the marker
    match by a wide margin, but not nil. Closing it properly means identifying the review
    by something the POST would have to return (the review id it created), which it
    cannot when it errors; a body digest is the workaround, and it changes the body every
    reader and the ledger parser sees, so it wants its own round.
  • The check errs toward the pre-change behaviour, not past it. If GitHub ever
    stored a body this normalization does not reconcile, the answer is False, which is
    exactly what main does today (duplicate posted, findings tagged lost). A strictness
    miss costs the fix's benefit; it cannot cost more than the status quo.
  • gh_list_reviews is now bounded (GH_LIST_REVIEWS_TIMEOUT_SECONDS = 60,
    degrading to UNKNOWN). It was left unbounded to match gh_post_review; the panel
    pointed out the asymmetry that matters — this read sits AHEAD of the fallback POST and
    write_step_summary, so a hang bounded only by timeout-minutes: 10 takes the round
    out of both channels, where the pre-BE-12528 code posted the fallback immediately.
    gh_post_review keeps its own behaviour: nothing runs after it that a hang could cost.
  • The 4xx short-circuit is now restricted to pre-write rejections. 408/425/429 are
    4xx only in the sense that an edge or a proxy said so, possibly about a request the
    API went on to serve, so they take the read like a 5xx (RETRYABLE_4XX_STATUSES).
    What still rests on a documented property of GitHub's API rather than on an
    observation I could make from here is the remaining premise: that a review payload is
    validated before anything is committed. It is the same premise BE-10002's
    not comments branch already relies on, and it fails in the safe direction (a 4xx
    that had written would produce today's duplicate, not something worse). 403 never
    reaches it: is_read_only_token_error intercepts that earlier.
  • UNKNOWN still posts the fallback, and one snapshot can miss a late write. Raised
    by the panel; answered rather than changed. Declining to repost on UNKNOWN would lose
    the round from the PR altogether, which is worse than the duplicate it avoids, and a
    retry loop cannot close a window that has no bound. The lost_to_fallback tag is
    already withheld on that branch, so what remains possible is an extra review — never
    a false claim about one.
  • Not exercised: a live cursor-review run. Every assertion here comes from the unit
    suite, the payload diff against origin/main, and read-only calls to the reviews API.
    A real 5xx from GitHub's review POST is not reproducible on demand, so the
    PRESENT/UNKNOWN branches have never executed against a genuine failure — only against
    faithful stubs and real API response shapes.

Provenance

  • Authored by: agent-work loop
  • Verified: python3 -m unittest discover -s .github/cursor-review/tests -p 'test_*.py': 440 passed, 0 failed; .github/agents-md-integrity/tests: 46 passed; .github/groom/tests: 374 passed, 1 skipped; shellcheck -x .github/bump-callers/bump-callers.sh .github/bump-callers/tests/test_bump_callers.sh: clean; check_agents_md.py --root .: passed (2 pre-existing warnings); cursor-review.yml re-parsed as YAML. The 422 payload-diff harness was re-run against origin/main after the review fixes: 2 payloads each, byte-identical, zero list reads on that path. All ten fixes across both review rounds are mutation-checked — reverting any one of them turns a test red.
  • Deviations: four round-2 findings were answered on their threads rather than in code, each with the reasoning recorded there: the not comments branch not consulting the landed-check (a gate-behaviour change, and a fix-the-fix at low severity); --jq/page-cap byte bounding (the MemoryError it guards is not reachable at any PR size this runs on, and --jq would change the one payload shape verified against the real API); the body-vs-inline-comments gap in the residual (wording corrected above instead); and the throttled-403 path, which is a change to the read-only degradation guard rather than to this one and is filed as a follow-up.

… findings lost_to_fallback (BE-12528)

A nonzero `gh` exit on the review POST is not proof the review was never
committed server-side. BE-10002 tagged every anchored finding
`lost_to_fallback` on that exit alone and wrote the hole down as a residual:
when the write HAD landed, the fallback posted a second review and the next
round's ledger carried each anchored finding twice — once with its real
thread, once as a cap-exempt [post-failed] entry claiming nobody could have
answered it.

The failure path now establishes the answer, cheapest sufficient evidence
first. A 4xx is GitHub validating and rejecting before writing (every firing
observed in the field is a 422 over an inline position), so the review is
absent by construction and no read is spent. Anything else — a 5xx, or a
transport error carrying no status at all — takes one paginated GET of the
PR's reviews, matched on the same four-part discriminator the blocking gate
and the workflow's dup-check use. Paginated because the newest review sits on
the last page of an oldest-first list.

Three outcomes: PRESENT reports the review delivered and posts nothing more
(the success tail is now a shared `finish_posted_review` closure, so both
paths that leave a body on the PR report it through one implementation);
ABSENT is this path exactly as before; UNKNOWN posts the fallback but tags
nothing, because an unreadable list is not a confirmed absence (BE-4785).

The 422 path is byte-for-byte unchanged — verified by driving both the
pre-change and post-change modules over the same fixture and diffing the
POST payloads.
@mattmillerai mattmillerai added cursor-review Multi-model cursor review agent-coded Authored by the agent-work loop labels Sep 8, 2026
@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 4 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 103 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: 1c822002-a9a6-4b36-ae33-b4775666d5b9

📥 Commits

Reviewing files that changed from the base of the PR and between 52b4fe6 and fbcbb7f.

📒 Files selected for processing (3)
  • .github/cursor-review/post-review.py
  • .github/cursor-review/tests/test_post_review.py
  • .github/workflows/cursor-review.yml

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 6 finding(s).

Severity Count
🟠 High 1
🟡 Medium 1
🟢 Low 4

Panel: 6/6 reviewers contributed findings.

Comment thread .github/cursor-review/post-review.py Outdated
Comment thread .github/cursor-review/post-review.py Outdated
Comment thread .github/cursor-review/post-review.py Outdated
Comment thread .github/cursor-review/post-review.py Outdated
Comment thread .github/cursor-review/post-review.py
Comment thread .github/cursor-review/post-review.py Outdated
…nel marker (BE-12528)

Review-panel findings on #272, all against `review_already_posted`.

- Identity, not family (6/6 reviewers). `body.startswith(CONSOLIDATED_MARKER)`
  answers "some bot panel review exists at this SHA" — which a previous round's
  body-only fallback and any `post_error_review` body both satisfy, being
  Bot-authored with the same `commit_id`. Matching one returned True, so the
  caller skipped the fallback and reported `delivered=true` while THIS round's
  findings reached neither the PR nor the job summary: a loose match traded a
  duplicate review for a silent loss. Now the stored body must BE `posted_body`,
  up to CRLF/trailing-whitespace normalization; the marker survives as a cheap
  prefix reject ahead of that (pinned by a test, since every head variant —
  attribution, `--notice`, `--ledger-note` — appends rather than prepends).

- PENDING is not landed. `GET /pulls/{n}/reviews` returns the authenticated
  identity's own unsubmitted reviews, and that identity is the bot whose POST
  just errored — so the half-committed write this path detects could surface as
  PENDING, invisible to everyone and publishing no resolvable thread. Matched
  against a submitted-state allowlist instead of excluding DISMISSED alone.

- A read that inspected nothing may not answer False (BE-4785). Exit-0 empty
  stdout no longer defaults to `[]`, and a payload that is not a list of pages
  is UNKNOWN rather than silently flattened to no reviews.

- 408/425/429 take the read. The no-read short-circuit assumes a 4xx means
  "validated and refused before writing", which does not hold for statuses an
  edge or proxy can return for a request GitHub went on to serve.

- The list read is bounded (60s) and degrades to UNKNOWN. It sits ahead of the
  fallback POST and the summary write, so an unbounded hang lost the round from
  both channels when `timeout-minutes: 10` fired.

The 422 path is unchanged and re-verified byte-for-byte against origin/main,
still spending no extra API call. Each fix is mutation-checked: reverting any
one of them turns a test red.
@mattmillerai mattmillerai added cursor-review Multi-model cursor review and removed cursor-review Multi-model cursor review labels Sep 8, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Round 2 — ledger: 6 prior finding(s) across 1 round(s) (0 never answered).

Found 8 finding(s).

Severity Count
🟡 Medium 2
🟢 Low 5
⚪ Nit 1

Panel: 5/6 reviewers contributed findings.

Reviewers that did not contribute: gpt-5.6-sol-max:adversarial (error)

Comment thread .github/cursor-review/post-review.py Outdated
Comment thread .github/cursor-review/post-review.py
Comment thread .github/cursor-review/post-review.py Outdated
Comment thread .github/cursor-review/post-review.py
Comment thread .github/cursor-review/post-review.py Outdated
Comment thread .github/cursor-review/post-review.py
Comment thread .github/cursor-review/post-review.py Outdated
Comment thread .github/cursor-review/post-review.py
…view check (BE-12528)

- `[]` no longer answers "confirmed absent". `all()` is vacuously true over it,
  so a zero-page payload fell through to `reviews = []` and tagged every anchored
  finding lost on a read that inspected no page — the same laundering the
  empty-stdout guard rejects. `[[]]`, what `--slurp` really returns for a PR with
  no reviews, remains the genuine absence.

- The cheap prefix reject now runs on the NORMALIZED body. It ran on the raw one
  while the equality it guards normalized both sides, making the guard stricter
  than the check: a stored body differing only by a leading newline passed the
  equality but never reached it, answering "absent" for the run's own landed
  review.

- Field types are trusted no further than payload shapes. A `user` that is a
  string, or a non-string `body`, raised AttributeError out of
  `review_already_posted` and killed the process ahead of both the fallback POST
  and `write_step_summary` — the both-channel loss the timeout exists to prevent.

- A failed list read logs its exit code and stderr. UNKNOWN reposts the fallback
  and withholds the tag without explanation, so a 60s timeout, an auth failure
  and an older `gh` without `--slurp` were indistinguishable to an operator.

- Comment correction: 403 is absent from RETRYABLE_4XX_STATUSES because
  `is_read_only_token_error` returns from `main()` before this decision is
  reached, not because a throttled 403 cannot happen. Listing it would be dead
  code that reads as coverage.

Each fix is mutation-checked; reverting any one turns a test red.
@mattmillerai

Copy link
Copy Markdown
Contributor Author

🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:

  • BE-12594 — Stop is_read_only_token_error swallowing a throttled 403 in cursor-review's post-review.py — filed as agent-spike (premise unverified)

The following carry agent-spike instead of agent-ok because their reachability claim was not backed by evidence (BE-5378) — the claim is investigated before any code is written, and "the premise does not hold" is a valid, successful outcome:

  • Stop is_read_only_token_error swallowing a throttled 403 in cursor-review's post-review.py — no reachability block in the proposal

@mattmillerai
mattmillerai merged commit 045b3e0 into main Sep 8, 2026
6 checks passed
@mattmillerai
mattmillerai deleted the matt/be-12528-confirm-review-absent branch September 8, 2026 22:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-coded Authored by the agent-work loop cursor-review Multi-model cursor review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants