Skip to content

fix(cursor-review): keep the -i status line and headers when a review POST fails - #323

Open
mattmillerai wants to merge 2 commits into
mainfrom
matt/be-15634-cursor-review-post-failure-diagnostic
Open

mattmillerai wants to merge 2 commits into
mainfrom
matt/be-15634-cursor-review-post-failure-diagnostic

Conversation

@mattmillerai

@mattmillerai mattmillerai commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

ELI-5

When our review bot tries to post its findings to a PR and the post fails, it used to print only the error message and throw away the receipt — the HTTP status line and the rate-limit headers that GitHub's CLI (gh -i) had already captured. So when a real 6-model panel's findings failed to post twice with the cryptic unexpected end of JSON input, nobody could tell from the log whether the request even reached GitHub. This change keeps the receipt in the failure message, so the next failure is diagnosable instead of a mystery.

What changed

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

  • New format_post_failure(context, result, payload) builds the <context> POST failed: log line and now also includes:
    • the stdout status line gh -i wrote (HTTP/2.0 nnn …), or an explicit response status: no status line captured on stdout … when none arrived — the distinction the old stderr-only line could not express;
    • the Retry-After / X-RateLimit-Remaining / X-RateLimit-Reset response headers when present;
    • a json.loads self-check of the request body, which settles whether an unexpected end of JSON input is request-side (our payload) or response-side (GitHub's empty error body) in one line.
  • Both POST-failure sites now route through it: the primary review POST, and post_or_degrade (used by the inline-less fallback) — the two POSTs that failed identically in the reported run.
  • Headers are read via the existing gh_response_headers, which stops at the blank line before the response body, so a finding body that quotes a Retry-After: header can never forge one into the diagnostic (covered by a test).

Tests: +7 cases (plus +5 from the review pass below) (PostFailureDiagnosticTest, plus a status-less transport-error regression test that reproduces the reported scenario end-to-end and asserts the findings still reach the job summary). Full suite: 607 passed. Lint parity: CI runs unittest + shellcheck; no shell touched.

Review pass (panel findings, commit 2)

The 6-model panel raised three ways the new diagnostic could mislead — or crash — on the very paths it exists to serve. All three were valid and are fixed:

  • (High) The timeout path could throw away the findings entirely. TimeoutExpired.stdout is the raw bytes the pipe had buffered even under text=True — CPython decodes only on the normal-completion path. gh_post_review's timeout handler stored it verbatim, so a POST that timed out after gh wrote anything handed a bytes-bearing CompletedProcess to gh_status_line / gh_response_headers, whose .split("\n") raises TypeError. Since format_post_failure now runs on every POST failure, that traceback would have skipped the landed-review read, the fallback POST and the job-summary write — losing the findings on the path built to preserve them. Reproduced on CPython 3.12 before fixing. Decoded once at the source (_as_text, errors="replace", since a kill can sever a multi-byte character); the two stdout readers go through it as well so a future construction site can't reintroduce it. The pre-existing timeout tests built TimeoutExpired with no output, which is why this was uncovered.
  • (Low) no reply reached gh asserted more than an absent status line proves. _GH_STATUS_LINE_RE's own comment names the counter-cases — a gh predating -i, or one invoked without it, writes a bare JSON body — and a timeout can kill gh after GitHub served the write. On the timeout path it contradicted the stderr beside it, and a maintainer who believed it would re-trigger into the duplicate review the landed-review read exists to avoid. Now reports no status line captured on stdout and cross-checks gh_http_status, so a status on stderr says plainly that a reply arrived and only a genuine silence reads as UNKNOWN.
  • (Low) the decode error is response-side fired unconditionally. Both call sites pass a json.dumps result, so the valid-JSON branch always won — announcing a decode error on 403 throttles, 422 rejections and 500s that involve no decode at all. Now gated on gh actually reporting one of Go's encoding/json wordings; absent that it states what it checked and stops.

Acceptance / artifacts exercised

  • post-review.py gh_post_review docstring + the stderr-only failure print — read and fixed; the -i contract is that "the status line + headers + body all go to STDOUT", which was being discarded.
  • The reported symptom unexpected end of JSON input on both the primary and fallback POST — reproduced as a unit + end-to-end test (test_a_status_less_failure_still_lands_the_findings_in_the_summary, PostFailureDiagnosticTest).

Artifacts named in the ticket that I could not exercise (no access from this environment; named per policy): the CI run and its cursor-review-consolidated artifact, the caller PR, and the two check-run ids / head SHA cited for the gate-hole — all live in a caller repo / expired artifact storage. No secrets or private detail from them are reproduced here.

Residual

  • Item 2 (the merge-gate hole) is deliberately NOT implemented here — the ticket itself scopes it out: "Item 2 needs a call on skip semantics before anyone codes it… Decision needed (not agent-scoped)." The open question is whether a Post review that recorded delivered=false should be sticky (re-emitted as failure by any later run at the same SHA, or recorded where the branch-protection rollup — which takes the latest check run per name — cannot supersede it), so a later SKIPPED run can't erase the red check and let a PR merge with undelivered findings. This needs a human decision on skip semantics and is left for follow-up.
  • Item 3 (write consolidated findings to the job summary on delivery failure) required no production code change — it is already satisfied on the current default branch by the throttle/landed work that landed after the reported 2026‑09‑15 run. I swept every delivery-failure exit: all 7 emit_delivery(False) sites are paired with a write_step_summary, so no delivered=false path drops the findings. Rather than a redundant edit, I added a regression test pinning the ticket's exact status-less transport-error scenario so this can't silently regress. If reviewers consider the ticket's item 3 to require an explicit unconditional write at a single choke point (rather than the current per-path pairing), that is a refactor worth its own discussion.

Provenance

  • Authored by: agent-work loop
  • Verified: python3 -m unittest discover -s .github/cursor-review/tests -p 'test_*.py'611 passed; shellcheck -x .github/cursor-review/install-cursor-cli.sh .github/cursor-review/slack-notify.sh clean (CI parity for this path; no shell changed). Repo-wide lints also green: check_workflow_pins.py, check_agents_md.py --root ., check-org-repo-literals.sh. The two bytes-capture regressions were confirmed to fail with the source decode reverted, so they are not vacuous.
  • Deviations: none. The commit-1 message and the original body text both described the status-absence wording as no reply reached gh; commit 2 narrows that claim, and this body is updated to match — the commit-1 message itself cannot be amended without a force-push.

…en a review POST fails

A failed review POST reported result.stderr alone — gh's error line — and
threw away the stdout that -i exists to capture: the response status line and
the Retry-After / X-RateLimit-* headers. When both the primary and inline-less
fallback POST fail with `unexpected end of JSON input`, stderr alone cannot
tell a request GitHub never received from a response whose empty error body
failed to decode, so the one artifact that would say which was discarded at the
moment it was needed.

Add format_post_failure(): it keeps the existing stderr line and appends the
stdout status line (or an explicit "no reply reached gh" when none arrived), any
rate-limit headers, and a json.loads self-check of the request body that pins
the decode error as response-side vs request-side. Both POST-failure sites (the
primary review POST and post_or_degrade, which the fallback uses) now route
through it. Headers are read via gh_response_headers, which stops at the blank
line, so a finding body that quotes a Retry-After header can never forge one.
@mattmillerai mattmillerai added cursor-review Multi-model cursor review agent-coded Authored by the agent-work loop labels Sep 18, 2026
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for the next 21 days.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for the next 21 days. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 47 minutes for your next included review.

Check out review usage here.

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: 98ed6899-0234-4e50-9bc1-e03cb5528aae

📥 Commits

Reviewing files that changed from the base of the PR and between aa72381 and 465bd25.

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: 912c9ee2-cdf5-4ca1-96b9-0d79089fedc1

📥 Commits

Reviewing files that changed from the base of the PR and between 5d76700 and aa72381.

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

Included review availability: 0 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour.


📝 Walkthrough

Walkthrough

The POST review flow now reports response status, rate-limit headers, stderr, and request JSON validity when posting fails. Both posting paths use the diagnostic. Tests cover response parsing, header spoofing, status-less failures, job summaries, delivery state, and exit status.

Changes

POST Failure Diagnostics

Layer / File(s) Summary
Diagnostic formatting and wiring
.github/cursor-review/post-review.py, .github/cursor-review/tests/test_post_review.py
The script formats POST failures with response details and request JSON validation. General and inline posting paths use the diagnostic. Tests cover status, headers, stderr, missing replies, JSON validity, and response-body header spoofing.
Failure-path regression coverage
.github/cursor-review/tests/test_post_review.py
Tests cover status-less failures from both POST attempts. The review writes the POST-failed note, reports undelivered findings, confirms the review is absent, and exits with status 1.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to aa723

The POST failure diagnostics retain the intended status and rate-limit information, with no identified merge-blocking regression.

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
✨ Simplify code
  • Commit to this branch
  • Create a new PR

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 3 finding(s).

Severity Count
🟠 High 1
🟢 Low 2

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
… diagnostic overclaiming

Panel review of the BE-15634 diagnostic found three ways it could mislead — or
crash — on the exact paths it exists to serve.

1. (High) `TimeoutExpired.stdout` is the RAW BYTES the pipe had buffered even
   under `text=True`: CPython decodes only on the normal-completion path.
   `gh_post_review`'s timeout handler stored it verbatim, so a POST that timed
   out AFTER `gh` wrote anything handed a bytes-bearing CompletedProcess to
   `gh_status_line` and `gh_response_headers`, whose `.split("\n")` raises
   TypeError. Because format_post_failure now runs on EVERY POST failure, that
   traceback would skip the landed-review read, the fallback POST and the
   job-summary write — losing the findings from both channels on the path built
   to preserve them. Verified on CPython 3.12: the bytes and the TypeError both
   reproduce. Decoded once at the source via `_as_text` (errors="replace", since
   a kill can sever a multi-byte character), and the two stdout readers go
   through it too so no future construction site can reintroduce the crash.

2. (Low) "no reply reached gh" asserted more than an absent status line proves.
   `_GH_STATUS_LINE_RE`'s own comment names the counter-cases — a `gh` predating
   `-i`, or one invoked without it, writes a bare JSON body — and a timeout can
   kill `gh` after GitHub served the write. On the timeout path it contradicted
   the stderr beside it, which says the outcome is unknown; a maintainer who
   believed it would re-trigger and produce the duplicate review the
   landed-review read exists to avoid. Now reports "no status line captured on
   stdout" and cross-checks `gh_http_status`, so a status on stderr says plainly
   that a reply arrived and only a genuine silence reads as UNKNOWN.

3. (Low) "the decode error is response-side" fired unconditionally: both call
   sites pass a `json.dumps` result, so the valid-JSON branch always wins and
   announced a decode error on 403 throttles, 422 rejections and 500s that
   involve no decode at all. Gated on `gh` actually reporting one of Go's
   `encoding/json` wordings; absent that it reports what it checked and stops.

Tests: both bytes-capture regressions fail without the source decode, plus the
stderr cross-check, the UNKNOWN wording, and the no-decode-error attribution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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