mirror of
https://github.com/Comfy-Org/github-workflows.git
synced 2026-11-05 00:17:37 +00:00
[PR #277] [MERGED] fix(cursor-review): match the read-only-token 403 by message and let a throttled 403 reach the landed-review check #279
Labels
No labels
cursor-review-catalog-drift
groom
groom
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Comfy-Org/github-workflows#279
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/Comfy-Org/github-workflows/pull/277
Author: @mattmillerai
Created: 9/9/2026
Status: ✅ Merged
Merged: 9/9/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-12612-narrow-read-only-403-guard📝 Commits (4)
f770127fix(cursor-review): match the read-only-token 403 by message, not by status (BE-12612)486af72fix(cursor-review): key the read-only 403 guard on status + a throttle allowlist (BE-12612)87c274ffix(cursor-review): read every gh status rendering, and ask the PR on all three failure paths (BE-12612)5180568docs(cursor-review): refresh two test docstrings the round-2 refactor left stale (BE-12612)📊 Changes
4 files changed (+750 additions, -51 deletions)
View changed files
📝
.github/cursor-review/post-review.py(+218 -39)📝
.github/cursor-review/tests/test_post_review.py(+468 -2)📝
.github/cursor-review/tests/test_post_review_delivery.py(+53 -5)📝
.github/workflows/cursor-review.yml(+11 -5)📄 Description
ELI-5
When the review bot fails to post a review, GitHub tells it why with an HTTP status. A
403used to mean one thing to this script — "your token can't write here" — so it quietly wrote the review to the job summary and exited green. But GitHub also answers403when it is throttling you, and a throttle isn't a permissions problem: the write may well have gone through. This teaches the script to recognise the throttle wordings specifically, so a throttled403now goes and asks the PR what actually happened instead of being mislabelled as a read-only token — while every other403, which really is a standing refusal, keeps degrading quietly exactly as before.Description
is_read_only_token_errormatched the bareHTTP 403substring.main()returns from that branch before the landed-review check, so a rate-limit or abuse-detection403was reported as a read-only token, written to the job summary, and exited 0 — with the PR never asked whether the review had landed.Two halves, because narrowing the guard alone would not have fixed it:
The guard keys on status AND message.
gh_http_status(result) == 403 and not is_throttled_403(result). The status conjunction matters because the permission wording also travels inside a422'serrors[].messagelist, whichghjoins into the same stderr blob — reading that as a read-only token would be the same silent skip this PR removes, reached from the other direction. The message test is an allowlist of throttle wordings (THROTTLE_403_MESSAGES = ("rate limit", "abuse detection", "submitted too quickly"), matched case-insensitively), not a denylist of the permission phrase; see "Why an allowlist" below.Conjoining the status made
_GH_HTTP_STATUS_REload-bearing, and it matched onlygh's trailing(HTTP 403). go-gh also leads with the status —HTTP 403 (<url>)when the body carried no message,HTTP 422: <msg> (<url>)\n<rest>when it carriederrors[]— so those rendered as "no status at all" and a standing permission or SSO403fell through to a doomed read, a doomed fallback andSystemExit(1)on every run. The regex now matches both renderings, andgh_http_statustakes the last match so aGH_DEBUG=apitrace quotingHTTP 403in the review body it echoes cannot supply the status for its own failure.is_throttled_403likewise reads the newgh_error_line(result)—gh's own error line — rather than all of stderr, so a review that discusses rate limiting cannot turn its own permission403into a throttle.403 joins
RETRYABLE_4XX_STATUSES({403, 408, 425, 429}). Without this, a403that gets past the guard lands inpre_write_rejection, which treats any 4xx outside that set as absent-by-construction and skips the read anyway. Any403reaching that decision is now a throttle and nothing else, and a throttle can be raised on a request the API went on to serve — so it takes the read, exactly like the429beside it.Why an allowlist of throttles, not a denylist of the permission phrase
The first cut of this change matched the permission message and sent everything else
403into the landed-review read. That is not true of a403: an SSO/SAML block, an org IP-allowlist refusal, an archived repo, a non-JSON403from a proxy or GHES, and any future rewording of the permission message itself are all standing refusals that wrote nothing and that no retry fixes. Routing them into a doomed read plus a doomed fallback POST ends inSystemExit(1)on every run — a permanently red check in exactly the orgs least able to change it, where the caller previously got its review in the job summary and a green step.Inverting to an allowlist keeps every one of those on the old green degrade, and lets through only the case the ticket is actually about.
Behaviour change worth knowing at review time
A throttled
403used to exit 0 with the review in the job summary. It now takes one review-list read and, if the review is genuinely absent, posts the fallback — and if that is throttled too, the step goes red with the summary underPOST_FAILED_SUMMARY_NOTE. That is the intended correction: a throttle is a real failure, not an environment constraint. Every other403is byte-for-byte unchanged at both call sites (main()andpost_or_degrade): same log line,emit_delivery(False),write_step_summary(prose_body)underREAD_ONLY_SUMMARY_NOTE, exit 0.The fall-through reaches the landed-review check on all three failure paths —
main()'s inline branch, its no-inline-comments branch, andpost_or_degrade— gated by a sharedpost_may_have_landed(result)so the same question is answered the same way on each. Narrowing which403s reach the two read-less paths did not change what happened when one did: a throttle raised on a request GitHub went on to serve exited 1 withposted=falsewhile the review sat on the PR, so the fresh-review gate held the check red and the job summary published a second copy. Those two paths now read; they still never repost, and only a PRESENT answer changes anything.The PRESENT / ABSENT / UNKNOWN branches,
post_or_degrade's return contract andemit_deliveryare untouched —post_or_degrade's returncode-0 block is factored into areport_posted()closure so its PRESENT answer reports identically.cursor-review.ymlchanges only in comment andDETAILprose (see below).Test coverage
ReadOnlyGuardExcludesThrottlesTestin.github/cursor-review/tests/test_post_review.py, driven throughEndToEndPostTest.run_main:403unchanged — no list read, exactly one POST,delivered=false,posted=false, one summary write taking the default banner (pinned to beREAD_ONLY_SUMMARY_NOTEvia the parameter default), exit 0; driven through both ofgh's renderings, since the guard now depends on parsing either. (The old lower-cased subtest is gone: the guard never inspects the permission wording, so it exercised an identical path.)403unchanged (test_a_policy_403_still_degrades_to_the_summary) — the regression guard on the allowlist, driving the OAuth-App-restriction wording, which shares nothing with the permission phrase;test_the_guard_needs_the_status_as_well_as_the_message) — the permission phrase under a422is not a read-only token; a transport error with no status is not one either; an archived-repo403is;test_every_throttle_wording_falls_through_the_guard) — the four wordings GitHub actually sends, each as-sent and lower-cased, asserted to fail the guard and keep a parseable403;403with the review PRESENT — read once, one POST,delivered=true, no summary;403with the review ABSENT and the fallback throttled too — two POSTs,delivered=false, summary underPOST_FAILED_SUMMARY_NOTE,SystemExit(1);403with the list UNKNOWN — fallback posted, nothing taggedlost_to_fallback;gh_http_status("… secondary rate limit (HTTP 403)") == 403alongside the existing 422/502 cases;test_gh_status_is_read_out_of_every_rendering_gh_uses) — trailing(HTTP 422), leadingHTTP 403 (<url>), leading-with-errors[]HTTP 422: … (<url>)\n<rest>, and aGH_DEBUG=apitrace whose quoted body must lose to the real error line;test_a_debug_trace_quoting_a_throttle_does_not_make_one) — a permission403under a trace echoing a body that says "a rate limit is answered with 403, see abuse detection" is still read-only and still degrades green;test_a_throttled_403_with_no_inline_half_reports_the_landed_review/…_and_nothing_landed_stays_red, andtest_a_throttled_403_on_the_no_findings_review_reports_the_landed_review/test_a_standing_403_on_the_no_findings_review_never_reaches_the_read.In
test_post_review_delivery.py, the bare-403 case previously usedgh: HTTP 403: Forbidden, which_GH_HTTP_STATUS_REdoes not parse — it reached the read through the "no status at all" branch and would have passed with either half of the change reverted. Rewritten aroundgh's real shape astest_a_throttled_403_is_no_longer_read_as_read_only, paired withtest_an_unworded_403_still_degrades_green.That file's
MainDriverMixinalso drovemain()end to end with nogh_list_reviewsstub — harmless while every failure path short-circuited on its status, but the moment a403stopped doing so the unit suite shelled out to a realgh. It now stubs the read the same waytest_post_review.py's driver does, defaulting to an empty page. Test infrastructure, not a product change.cursor-review.ymlproseThe notify-complete comment at ~line 2823 and the user-facing
DETAILstring beneath it both described the bare-403 rule ("treats any HTTP 403 as read-only, so an SSO/IP-allowlist, secondary-rate-limit or archived-repo rejection lands here too"). Both are updated: SSO/IP-allowlist and archived-repo still reach that green branch, and a403GitHub words as a rate limit no longer does. TheDETAILno longer claims a rate limit never reaches it — an unworded403(an edge, a WAF or a GHES proxy with no JSON body) carries nothing to tell a throttle from a standing refusal by, so it is read as standing and does land there. This was the PR's own recorded residual and is now closed.Residual
403reachability claim is inherited, not re-verified here. That GitHub answers primary and secondary rate limits with403(or429), and thatgh apirenders it asgh: <message> (HTTP 403), comes from the investigation that produced this plan; this PR did not issue a live throttled request against GitHub to reproduce it, and doing so would mean deliberately exhausting a rate limit, which is out of bounds for verification. The tests exercise the parsing and branch selection against the documented stderr shapes rather than against a live throttle.Retry-Afterbackoff before the landed-review read. A throttled POST is answered by an immediate paginated read plus, on ABSENT/UNKNOWN, a second POST on the same token GitHub just asked to slow down — and now, sincepost_or_degradereads too, a second read when that fallback POST is throttled as well. This shape is pre-existing —429has been inRETRYABLE_4XX_STATUSESall along — and this PR widens which statuses reach it. Fixing it means capturing response headers (gh apidoes not surface them on the default path), so it is filed as a follow-up (BE-12679) rather than done here.403is classified as a standing refusal. A secondary-limit or abuse refusal served by an edge with no JSONmessagerenders asgh: Forbidden (HTTP 403)and keeps the green degrade withdelivered=false. Routing it into the read instead would restore the permanently-red check for anyone behind a WAF or GHES proxy, which is the regression the allowlist exists to avoid; the header capture in BE-12679 is the discriminator that closes it properly. TheDETAILprose now says so rather than asserting the opposite.Provenance
python3 -m unittest discover -s .github/cursor-review/tests -p 'test_*.py': 476 tests, OK;python3 -m unittest discover -s .github/agents-md-integrity/tests -p 'test_*.py': 46 tests, OK;python3 -m unittest discover -s .github/groom/tests -p 'test_*.py': 374 tests, OK (1 skipped);python3 .github/agents-md-integrity/check_agents_md.py --root .: passed (2 pre-existing warnings, unrelated);.github/workflows/cursor-review.ymlre-parsed as YAML and the editedDETAILline re-parsed bybash -nafter the prose edit. Mutation check on the round-2 commit: with.github/cursor-review/post-review.pyreverted to the previous commit, 11 cases fail — includingtest_the_permission_403_still_degrades_to_the_summaryon itsgo-gh's leading-status renderingsubtest, which is the permanently-red regression the round-2 panel raised. Earlier mutation checks on the first commit still hold: reverting the status conjunction alone gave 5 failures, reverting403out ofRETRYABLE_4XX_STATUSESalone gave 3._GH_HTTP_STATUS_REwas widened to go-gh's leading-status renderings (the conjunction had made an unparseable rendering a fall-through, the exact regression the allowlist was protecting against), and the landed-review read was extended tomain()'s no-inline-comments branch andpost_or_degrade— which the round-1 reply had explicitly declined in favour of scoping the docstring, and which the panel re-raised as documenting the gap rather than closing it. The panel's remaining suggestion on this round, routing an unworded403into the read, was declined for the reason the allowlist exists and left to BE-12679. The plan also scoped.github/workflows/cursor-review.ymlout; its notify-complete comment andDETAILstring are edited here anyway, since the panel flagged them as stale in the same commit that made them stale..github/cursor-review/README.mdanddocs/callers/cursor-review.mddescribe only "a read-only token", which is still accurate, so neither was changed.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.