mirror of
https://github.com/Comfy-Org/github-workflows.git
synced 2026-11-05 00:17:37 +00:00
[PR #345] [MERGED] feat(cursor-review): approve_gate output and per-PR max_rounds cap #347
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#347
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/345
Author: @mattmillerai
Created: 10/6/2026
Status: ✅ Merged
Merged: 10/6/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-19321-cursor-review-approve-gate📝 Commits (5)
c6accd4feat(cursor-review): approve_gate output and per-PR max_rounds cap799ade1Merge branch 'main' into matt/be-19321-cursor-review-approve-gate8df69abfix(cursor-review): round cap fails open on a label it cannot apply, and counts only real rounds8a1b67ffix(cursor-review): a failed review POST withdraws, and the blocking gate reads the cap label liveb155655feat(cursor-review): expose round and max_rounds workflow outputs📊 Changes
8 files changed (+1145 additions, -31 deletions)
View changed files
📝
.github/cursor-review/README.md(+2 -1)📝
.github/cursor-review/auto-approve.py(+406 -19)📝
.github/cursor-review/tests/test_auto_approve.py(+444 -0)📝
.github/cursor-review/tests/test_workflow_job_isolation.py(+1 -1)📝
.github/cursor-review/tests/test_workflow_ref_guard_parity.py(+1 -1)📝
.github/workflow-pins/tests/test_check_workflow_pins.py(+5 -2)📝
.github/workflows/cursor-review.yml(+222 -6)📝
docs/callers/cursor-review.md(+64 -1)📄 Description
ELI-5
The cursor-review bot reviews a PR in rounds. This PR adds two hooks to it. First, a new output says whether the latest round passed the severity gate, so a later workflow can wait for a pass before it runs. Second, a cap on rounds: once a PR has had
max_roundsrounds (default 5), the bot stops reviewing it, adds theneeds-human-reviewlabel, and posts one comment listing what is still open. Removing the label lets the bot start counting again.What changed
approve_gateoutput (on.workflow_call.outputs). It always takes exactly one of five values:passdecide()returned APPROVEfailuntrustedcappedneeds-human-review(whilemax_roundsorapprove_max_severityis set)offapprove_max_severityis empty (and the cap was not hit)auto-approve.py: the newdecide_gate()returns(event, gate, reasons, blocking).decide()is now a thin wrapper with the same 3-tuple, so existing callers and tests are unchanged.cmd_decidewritesapprove_gateto$GITHUB_OUTPUTbefore any check can fail, then overwrites it with the decision. If the step dies early, the value therefore readsuntrusted.post-reviewgets job outputapprove_gatefrom theAuto-approve decisionstep, which now hasid: approve. The workflow-level expression addscapped,offand the no-rounduntrusted.roundandmax_roundsoutputs (scope addition requested on the PR, for cursor-approve's "round R of M" status card).roundis the 1-based number of the round this run delivered, counted the same way the cap counts. When this run hit the cap it is the last round instead. It is empty when no round landed, the count could not be read, ormax_roundsis 0.max_roundsis the effective cap:0for no cap, including a value the cap step rejects.round-capwritesnext_roundand the applied cap, androundusesnext_roundonly whenpost-reviewreportsdelivered. A delivered review is exactly what the counter counts, so a "Review failed" or all-cells-failed review gets no number.max_roundsinput (number, default5,0= no cap):round-cap(needs: gate, sameif:asledger). It checks out no PR code, holdspull-requests: write, runs onubuntu-latest, and uses a byte-identical copy of theworkflows_refguard. It counts reviews whose body starts withpost-review.py'sCONSOLIDATED_MARKERand whose author is the posting identity (<app-slug>[bot]orgithub-actions[bot]). Rounds that reviewed nothing (the "Review failed" error review and the all-cells-failed review) do not count. Only reviews after the most recentunlabeledtimeline event forneeds-human-reviewcount. At or over the cap it:needs-human-review, creating the label if it is missing (the create is best-effort: it needsissues: write, so a failed create still attempts the add);approve_max_severity(every open finding when that is empty). The comment is keyed on<!-- cursor-review-round-cap -->from the poster after the last reset, so a re-trigger does not post it twice for the same cap;capped=true(andlabelled=true, which is also set when the PR already carries the label).diff-sizenowneeds: round-capand skips whencapped == 'true'. Every job that spends money hangs offdiff-size, so a capped PR runs no panel.max_roundsis not a finite whole number, or the label cannot be applied, the cap fails open: the panel runs, as it did before this change. The label goes on before the PR is committed tocapped, because it is the only reset. If the comment write fails after the label landed, the PR stays capped and the job goes red so someone sees it.BOT_CONFIGUREDguard asover-cap-commentand iscontinue-on-error, so an app id without a key degrades togithub-actions[bot]instead of skipping the panel.approve_gatebecomesuntrustedand the bot's earlier approvals are withdrawn, so a REQUEST_CHANGES that was lost cannot leave the last round's approval standing.dismiss-staletreats a PR read that has a head but no base as "no base", so it skips the base check instead of dismissing every approval.decide()refuses to approve when the PR carriesneeds-human-review. It returns NONE, which also withdraws earlier bot approvals, and the gate value iscapped. The post-write re-read also checks the label, so an APPROVE that races a label being applied is dismissed.needs: round-capand holds red on a capped head, with its own message. This matches how the over-cap path is treated: no panel looked at that head. Whenmax_rounds > 0it also reads the PR's live labels on every event and fails whileneeds-human-reviewis present, so a thread-resolve event (which skipsround-cap) cannot turn a capped head green. A failed label read fails closed.Docs: new
max_roundsrow indocs/callers/cursor-review.mdand in the README knob table, plus a "Round cap and theapprove_gateoutput" section with a consumer example and a table for theround/max_roundsoutputs. Test counts bumped:EXPECTED_GUARDS9→10, workflow-pins guarded sites 19→20, andround-capadded to the isolation test's bot-key holders.Judgment calls
5vs. "unchanged when unset" (please decide). The spec saysmax_roundsdefaults to 5. The acceptance criteria say that withmax_roundsunset, behaviour is unchanged apart from the new output. Those two conflict: with a default of 5, a PR's 6th round on any existing caller now gets capped. I followed the explicit default, kept the cap fail-open on any read error, and left everything else untouched. If you want existing callers unchanged, it is a one-line change todefault: 0plus the two doc rows.cappedwins overoff. The cap applies whether or not auto-approve is on, so a capped run reportscappedeven whenapprove_max_severityis empty.untrusted, so the output is never empty. "No round was delivered" is the closest of the five values.issues: writewould have broken callers that grant onlypull-requests: write. Creating a repo label does needissues: write, so the docs tell callers to pre-createneeds-human-reviewunless the bot App has Issues write. Without it the cap fails open rather than capping a PR it cannot label.needs-human-review, as specified. Making it an input was raised in review and left to the owner.Residual
unlabeledread, and the issue-comment post are covered only by unit tests withghstubbed by endpoint. Applying a label to a PR withpull-requests: writehas not been confirmed against a realGITHUB_TOKENor App token. If it turns out not to be enough, the cap fails open (the panel keeps running) and theRound capjob logs a warning.max_roundsdefault conflict (see Judgment calls). The maintainer has to choose between 5 and 0.cursor-approve.ymlconsumer is out of scope and does not exist yet, so nothing readsapprove_gateend to end.origin/main(job.workflow_shaunknown to this version, and SC2018/SC2019 info in the guard). After normalising, the only difference is one extra instance of each, all from the byte-identical guard copy. There are no new findings. CI does not run actionlint.Provenance
approve_gatevalue, a failed POST givinguntrustedand withdrawing an earlier approval, a refused self-approval keeping its gate, and a base-less PR read not dismissing anything.round/max_roundsoutputs are covered under the cap, at the cap, after a reset, with the cap off or invalid, and on an unreadable count, and their workflow expressions are pinned.check_workflow_pins.py,check_agents_md.pyand the org-repo-literal lint are clean.cursor-review.ymlparses as YAML.max_roundsdefault becomes 0. The spec's default of 5 ships (see Judgment calls), and the maintainer still has to decide.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.