Repository navigation
[CXH-2780] Let CI submit an approval, not only a block (verdict_mode) - #147
FeliLucero1 wants to merge 15 commits into
Conversation
publish-review-report.py hardcoded VERDICT_MODE = "baseline", whose mapping is count-driven and has no APPROVE path: N > 0 submits REQUEST_CHANGES and N == 0 submits COMMENT. On GitHub a COMMENT review never clears a reviewer's earlier CHANGES_REQUESTED — only a later APPROVE from that reviewer, or a human dismissal, does. So a blocking finding keeps a PR blocked after the author fixes it, until someone dismisses the review by hand. Make the mode an action input (judge | baseline | none), defaulting to baseline so an un-upgraded caller is unchanged: - judge reads an explicit "**Verdict: APPROVE | REQUEST CHANGES | DEFER**" row and submits APPROVE / REQUEST_CHANGES / COMMENT from it. DEFER is what makes approval safe: an APPROVE satisfies the PR's review requirement for the whole change, so a review that found nothing blocking but could not cover the whole diff defers to a human instead of approving by default. An APPROVE over a positive blocking count is a contradiction between two rows of the same summary and fails the run rather than picking a side. - none publishes the report and submits no review. With nothing to wait for, its report is composed as publication="completed" directly. ductone/github-workflows ships the same three modes with a separate 21KB judge prompt file. Here the judge instructions are a mixin appended to the shared base prompt, which is also why the verdict row goes after the count row instead of before it: leaving the base template's first row alone lets one template serve both modes. The verdict row gets the same anchoring the count row already had — one canonical top-level row, fences stripped, no value smuggled in through the PR title — and _rows_after_heading is factored out so both parsers locate their rows the same way. The pr-review script tests were in no Makefile target and no workflow, so they have never run anywhere; add them to test-scripts. Nothing in this repo runs `make test` in CI yet — that is still open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects in the first commit, plus the prose it left behind. "none" skipped the summary parse entirely, so a run whose agent was cut off mid-sentence published that fragment as a completed report, advanced last_reviewed_sha, and exited 0 — retiring commits nobody reviewed from the next run's incremental diff. The count row is the only structural proof the agent finished, so every mode now requires it; "none" just maps it to a NO_REVIEW sentinel instead of an event. That also makes verdict_to_review total over VERDICT_MODES, so neither caller can reach PARSE_REQUIREMENT with a key it lacks. Judge mode required the verdict row at exactly the second row after the heading. The base prompt — which judge mode appends to rather than replacing — teaches a template whose second row is **Criteria:**, so the agent had to be talked out of a position it was just told to use, and the cost of it complying with the base was a red run that threw away a completed Opus review. The row is now accepted anywhere at top level below the heading. Position was never what made this safe: the row must be a whole canonical line outside any fence, and exactly one may exist in the summary. The count row keeps its prescribed slot and is now read through parse_blocking_count itself, so judge and baseline cannot drift about the same summary. An explicitly-empty verdict_mode hard-failed the action while the Python read it as baseline. A composite action's default does not apply to an input passed as "", so any caller forwarding its own optional input hit that — the review_prompt case two blocks up already handles this; verdict_mode now does too. The module docstring, the action.yml step description and the README still said the reviewer "never approves" and "validates the baseline count row", which is what a maintainer reads before touching the idempotency machinery. All three now describe the three modes. README also contradicted itself twice in prose this branch added, and said nothing about what enabling judge costs: a bot APPROVE counts toward required approving reviews, nothing here dismisses a stale approval when the head moves, and because publication picks the summary comment by author and freshness rather than by a pre-bound id, a write-access user editing the bot's comment goes from suppressing a block (baseline) to manufacturing an approval (judge). That is now written down as the precondition for turning judge on anywhere. DEFER's criteria told the agent to defer whenever repo-local criteria failed to load; for most repos no criteria file is the normal state, so judge would have deferred on every PR and cleared nothing. from __future__ import annotations so the suite the Makefile now runs can be imported by the 3.9 still shipping as system python3 on macOS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Superseded — see the current review report for commit
|
Superseded — see the current review report for commit
|
DEFER mapped to COMMENT at any blocking count, so a judge run could list confirmed blockers and still leave the PR unblocked — strictly weaker than baseline on the same summary, and backwards as an incentive: the hedging word was also the permissive one. DEFER is a statement about coverage, not severity. It now declines to override rather than declining to decide: it falls back to the baseline mapping of its own count row, COMMENT at zero and REQUEST_CHANGES above. Judge mode's only addition over baseline is APPROVE; everything else is baseline's answer or stricter. Resolving it this way rather than rejecting the combination as a contradiction keeps a merely inconsistent-looking summary from costing a finished review — the count row is already unambiguous about what to do, so there is no side to pick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Superseded — see the current review report for commit
|
Superseded — see the current review report for commit
|
The paragraph explaining DEFER's fallback landed between the judge and none rows, which ends a Markdown table: none rendered as a stray line of text rather than a row. Move it below the table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Superseded — see the current review report for commit
|
Superseded — see the current review report for commit
|
The mechanism was in place but nothing used it, so the measured problem — 4 of
4 bot blocks needed a manual dismiss, the worst sitting two days — was
untouched. Point the connector profile at judge.
Measured first, on a PR against a protected main with a plain GITHUB_TOKEN and
no extra configuration: github-actions[bot] can submit APPROVE, and can
dismiss a review it authored itself. The protected-branch caveat in GitHub's
docs ("you must be a repository administrator or be included in the list of
people or teams who can dismiss") only bites when a dismissal allow-list is
configured, and none of our repos configure one.
Scope is the reason this is safe. 60 of 60 connector repos sampled require 0
approving reviews, so a bot APPROVE satisfies nothing there; its only effect is
clearing the bot's own CHANGES_REQUESTED, which is the entire point. The one
repo that does require an approval is github-workflows, and it is reviewed by
general-pr-review.yaml, which passes no verdict_mode and so keeps the action's
conservative default. The asymmetry between the two workflows is now carrying
weight, so it is written down rather than left to be rediscovered: a repo on
the connector profile that starts requiring approvals has to move off it or
pin baseline.
Both defaults move, because they cover different callers: the workflow_call
input for repos that call this reusably, and the `|| ` fallback for the direct
pull_request trigger, where inputs is empty and the input default never
applies — which is how every connector repo actually runs it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Superseded — see the current review report for commit
|
Superseded — see the current review report for commit
|
The case for defaulting the connector profile to judge was that connector
repos require 0 approving reviews, so a bot APPROVE there clears the bot's own
block and nothing else. That was true of all 60 repos sampled, and nothing
kept it true: a repo that quietly starts requiring an approval would hand the
bot a vote nobody decided to give it, and the only control was a sentence in
the README asking people to notice and reconfigure.
Check it instead. Asking for judge is now a request: the action reads the base
branch's required_approving_review_count at the start of the run and grants
judge only on a confirmed zero. A branch that requires approvals runs
baseline, and so does a requirement that could not be read at all — an
unreadable answer is not evidence of a zero. Either way it says so in the job
log and the review still happens; only the ability to approve is withheld.
The endpoint matters: /repos/{repo}/branches/{branch}/protection answers 403
"Resource not accessible by integration" for GITHUB_TOKEN, while
/repos/{repo}/rules/branches/{branch} answers it. Both measured. A branch with
no pull_request rule requires no approvals and correctly reads as zero.
This lands in the shell step rather than the Python because it has to run
before the model does — withholding the mode after a review has already been
paid for would be worth nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Superseded — see the current review report for commit
|
The safety argument was stated as "connector repos require 0 approving reviews, so a bot approval satisfies nothing". The conclusion holds; the reason was wrong, and wrong in a way that reads as "connector PRs have no human gate at all" — which is false and alarming in its own right. They do have one. required_approving_review_count is 0, but the same ruleset carries a required_reviewers entry naming a team with minimum_approvals: 1 over every file but docs/connector.mdx. Checked on okta, datadog, jira, aws and slack; all five identical. A bot is not a member of that team, so its approval cannot satisfy that gate. Judge mode lets the reviewer clear its own block and nothing else — a person from the team still has to approve. That makes the argument stronger than the one it replaces, and it explains why the run-time check reads required_approving_review_count and not required_reviewers: a named-team requirement is one the bot cannot satisfy anyway, so there is nothing to withhold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Superseded — see the current review report for commit
|
Superseded — see the current review report for commit
|
Five documentation defects from review, all cases of prose describing an
earlier version of the code.
"none submits nothing and stops after step 3" — in the module docstring and
again in the action's step description. It does not stop: _run() still calls
_collapse_superseded, which is step 6, and
test_none_mode_still_collapses_the_consumed_working_comment pins that. It
skips steps 4 and 5. Step 6's own text already said this correctly ("in the
modes that submit one"), so the file contradicted itself.
parse_judge_verdict's "when either row is absent, malformed, out of position,
or ambiguous" — "out of position" stopped applying to the verdict row when
that row was relaxed to anywhere at top level below the heading. The paragraph
directly above it in the same docstring explains the relaxation; the sentence
below still described the old rule.
Both verdict_mode input descriptions presented judge as a setting that simply
takes effect, while the README says it is conditional. They now say it is
granted only on a confirmed zero and downgraded otherwise.
And the empty-string asymmetry between the two layers is now written down
where it lives: `|| 'judge'` makes an explicit "" mean judge here, while the
action normalizes "" to baseline. That is right for this workflow — "" and
"omitted" both mean "whatever the connector profile runs" — but a
workflow_call caller forwarding its own empty optional input gets judge rather
than the conservative default, and should pass baseline explicitly to opt out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
General PR Review:⏳ Provisional — deeper review still in progress. Review in progress; this comment will be replaced with the full summary. |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Superseded — see the current review report for commit
|
Superseded — see the current review report for commit
|
luisina-santos
left a comment
There was a problem hiding this comment.
Approving. All five inline points are fixed in cd0f681, and cebd026 gets the merge gate right in the README: connector repos require a connector-approvers approval through required_reviewers, and the bot can't satisfy that. Thanks!
The PR description still lags the code. Could you update it before merging?
- "Scope is what makes it safe" still says "60 of 60 connector repos sampled require 0 approving reviews… a bot
APPROVEthere satisfies nothing" and "there is no vote to get". The README now says it correctly: the count is 0, but the team'srequired_reviewersrule is the actual human gate. - "…has to move off it or pin
baseline" contradicts the run-time fallback and the README, which says connector repos can't pin via an input. - The commit sections stop at the fifth. The run-time ruleset check (350579b onward) and the later doc commits aren't described.
- Tests: "163 → 166" is stale; the diff adds ~30 cases.
| review heading. Custom headings keep summary/state selection separate; they do not | ||
| isolate all inline review feedback or grant additional permissions. | ||
|
|
||
| ### Verdict Modes |
There was a problem hiding this comment.
can we move this to a separate docs file for the pr-review workflow details, so we can keep the readme streamlined please?
There was a problem hiding this comment.
Done in fbed5f9 — and you are right that it had gotten out of hand: PR Review was 258 of the README's 562 lines, nearly half of it, and it is the section this branch grew the most.
Followed the convention the other workflows already use — docs/release-workflow.md, docs/verify-workflow.md, docs/connector-docs-verify.md hold the internals while the README keeps a paragraph, a pointer and how to call the thing. PR Review was the only one carrying all its detail inline, so this moves it to docs/pr-review.md rather than inventing a new layout.
I moved the whole section rather than only the part this PR added, since "streamlined" is not really achieved by leaving 100 lines of the old prose behind. The moved text is byte-identical after demoting its headings one level — nothing reworded, nothing dropped — so the diff should read as a move. I verified that programmatically rather than by eye.
The one genuinely new thing is the input table in the README, which replaces prose that explained summary_marker in a paragraph and verdict_mode not at all.
README is now 329 lines; docs/pr-review.md is 261. Shout if you would rather the README kept more than the three inputs, or if the table belongs in the doc too.
Requested in review. PR Review was 258 of the README's 562 lines — nearly half, and the section this branch grew the most. Follows the convention the other workflows already use: docs/release-workflow.md, docs/verify-workflow.md and docs/connector-docs-verify.md hold the internals while the README keeps a paragraph, a pointer, and how to call the thing. PR Review was the one workflow carrying all of its detail inline. The moved text is byte-identical after demoting its headings one level — nothing reworded, nothing dropped — so the diff reads as a move. What is new is the README's input table, which replaces prose that described `summary_marker` in a paragraph and `verdict_mode` not at all. README is now 329 lines, docs/pr-review.md 261. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Superseded — see the current review report for commit
|
Superseded — see the current review report for commit
|
The classic-protection comment in action.yml deferred to "the README" for the limit it was describing, and the previous commit moved that explanation to docs/pr-review.md. It now names the section it means, so the pointer resolves. Swept the rest of .github/actions/pr-review and the two review workflows for the same breakage; this was the only one. The moved doc's own reference to the README is still accurate — the README does carry the short version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Superseded — see the current review report for commit
|
General PR Review: [CXH-2780] Let CI submit an approval, not only a block (verdict_mode)Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0 Review SummaryThe PR adds a Security IssuesNone found. Correctness IssuesNone found. Suggestions
Resolved prior findings
Prompt for AI agentsReviewed commit: |
gontzess
left a comment
There was a problem hiding this comment.
seeing a few issues with this approach, and in general i think its a wider change than we need in order to fix the "self-dismissal" bug that's causing pain
HIGH / blocking: the guard knowingly allows an approval into an unread classic human-review requirement
The code takes the maximum generic approving-review count from applicable rulesets. If that maximum is zero, it keeps judge. It consults the branch's protected flag only when there is no ruleset pull-request rule. Thus a ruleset requiring zero approvals is incorrectly accepted as evidence that all applicable policy requires zero approvals. Classic branch protection is neither read nor excluded in this branch.
let me draft an alternative approach
The problem
publish-review-report.pyhardcodedVERDICT_MODE = "baseline". Baseline maps the blocking count:N > 0→REQUEST_CHANGES,N == 0→COMMENT. It has noAPPROVEpath, deliberately.On GitHub a
COMMENTreview never clears a reviewer's earlierCHANGES_REQUESTED. Only a laterAPPROVEfrom that reviewer, or a human dismissal, does. So once the bot blocks, it stays blocked after the author fixes the finding — until a person clicks dismiss.flowchart TD B["The bot has submitted REQUEST_CHANGES.<br/>GitHub now records CHANGES_REQUESTED — the PR is blocked."] B --> Q{"What clears it?"} Q --> C["Another COMMENT review<br/><b>No effect.</b> GitHub keeps the block.<br/>This is the only other event baseline can send."] Q --> H["A human clicks Dismiss<br/><b>Works.</b> 4 of our 4 blocked PRs needed this."] Q --> A["The bot sends APPROVE<br/><b>Works.</b> Only judge mode can send it."]Baseline's whole vocabulary is
REQUEST_CHANGESandCOMMENT, and neither ofthem clears a block. So once it blocks, a person has to come and undo it by
hand.
Measured across 54 recently-merged connector PRs (okta, jira, aws, datadog, github):
CHANGES_REQUESTEDDISMISSED— none cleared itselfbaton-okta#214: blocked 2026-09-16 22:57, dismissed 2026-09-18 19:00 (~2 days)baton-datadog#40was blocked four separate times and accumulated 71 bot reviewsWhat this does
Makes the mode an action input —
judge|baseline|none— and points the connector profile atjudge. The action's own default staysbaseline, so anything that doesn't ask for a mode keeps today's behaviour.baseline(default)**Blocking Issues: N**countREQUEST_CHANGES/COMMENT, never approvesjudge**Verdict: ...**row, withDEFERfalling back to the countAPPROVE/REQUEST_CHANGES/COMMENTnoneflowchart LR S["review summary"] --> M{"verdict_mode"} M -->|baseline| B{"blocking<br/>count N"} B -->|"N > 0"| RC["REQUEST_CHANGES"] B -->|"N = 0"| C["COMMENT"] M -->|judge| V{"verdict row"} V -->|"APPROVE"| A["APPROVE"] V -->|"REQUEST CHANGES"| RC V -->|"DEFER"| B M -->|none| X["no review submitted"]DEFERrouting back into the count is the point: deferring is a statementabout coverage, not severity, so it declines to override baseline rather
than declining to decide. Judge mode's only addition over baseline is
APPROVE— it can never leave a PR unblocked that baseline would haveblocked.
ductone/github-workflowsalready ships these three modes; this is a port, not a new design. Two deliberate divergences, both because their judge prompt is a separate 21KB file and ours is a mixin appended to the shared base: the verdict row goes after the count row rather than before, and the judge instructions are ~70 lines rather than a second copy of the whole prompt.DEFERis the part that makes approving safe. AnAPPROVEsatisfies the PR's review requirement for the whole change regardless of how much of the diff was actually read, so a review that found nothing blocking but couldn't cover everything defers to a human instead of approving by default. AnAPPROVEwritten over a positive blocking count is a contradiction between two rows of the same summary — CI refuses to pick a side and fails the run.Why turning it on is safe here
The connector profile (
pr-review.yaml) now defaults tojudge. That is abehaviour change, so here is the evidence it rests on.
Measured, not assumed. I ran a throwaway probe (#148, closed) on a PR
targeting protected
main, with a plainGITHUB_TOKENand no extra config:REQUEST_CHANGESDISMISSEDAPPROVEAPPROVEDThe protected-branch caveat in the docs ("you must be a repository
administrator or be included in the list of people or teams who can dismiss")
only applies when a dismissal allow-list is configured, and none of our repos
configure one.
Scope is what makes it safe. 60 of 60 connector repos sampled require 0
approving reviews. So a bot
APPROVEthere satisfies nothing — its onlyeffect is clearing the bot's own
CHANGES_REQUESTED, which is the entirepoint. The three costs of judge mode mostly evaporate at 0 required approvals:
dismiss_stale_reviews_on_pushisfalseacross these repos.)The one repo where it would matter is excluded by construction.
github-workflowsis the only one requiring an approving review, and it isreviewed by
general-pr-review.yaml— which passes noverdict_modeand sokeeps the action's conservative
baselinedefault. The bot never gets a votethat could satisfy a human review requirement.
That asymmetry between the two workflows is now load-bearing rather than
incidental, so the README says so: a repo on the connector profile that
starts requiring approvals has to move off it or pin
baseline.What this still costs. Judge adds a row the model has to write. If it
writes it wrong the run goes red and a finished Opus review is lost. I relaxed
the row's required position to the minimum that stays safe — one canonical
line, top level, exactly once — but the risk isn't zero. Closing (3) properly
still wants a check that the summary's last editor was the bot; GraphQL
exposes
editorandlastEditedAt. That's a separate PR.Second commit
A deep review of the first commit found two real defects, both fixed in
cb78605:noneskipped the summary parse entirely. A run whose agent got cut off mid-sentence published the fragment as a completed report, advancedlast_reviewed_shaand exited 0 — permanently retiring commits nobody reviewed from the next run's incremental diff. Every mode now requires the count row; it's the only structural proof the agent finished.**Criteria:**, so the agent had to be argued out of a position it had just been given, and complying with the base meant a red run that threw away a completed Opus review. The row is now accepted anywhere at top level below the heading; position was never the safety property (whole canonical line, outside any fence, exactly one per summary).Also in that commit: an explicitly-empty
verdict_modehard-failed the action while the Python read it asbaseline(a composite action'sdefault:doesn't apply to an input passed as""); the module docstring, theaction.ymlstep description and the README all still said the reviewer "never approves"; andDEFER's criteria told the agent to defer whenever repo-local criteria failed to load — for most repos "no criteria file" is the normal state, so judge would have deferred on every PR and cleared nothing.Third commit
The bot's own review of this PR caught one more, and was right:
DEFERmappedto
COMMENTat any count, so a judge run could list confirmed blockers andstill leave the PR unblocked — strictly weaker than baseline on the same
summary, and backwards as an incentive, since the hedging word was also the
permissive one.
c9b7741makesDEFERfall back to the baseline mapping ofits own count row.
It suggested either that or rejecting the combination as a contradiction the
way
APPROVEover a positive count is rejected. Falling back is better here:unlike
APPROVE, where the two rows genuinely disagree and there is no safeside to pick,
DEFERleaves the count row as the only claim about severity —so there is something to fall back to, and no reason to spend a finished
review on it.
Fifth commit
Points
pr-review.yaml— the connector profile — atjudge. Both defaults move, because they cover different callers: theworkflow_callinput for repos calling this reusably, and the||fallback for the directpull_requesttrigger, whereinputsis empty and the input default never applies. The second one is how every connector repo actually runs it.Tests
163 → 166 tests, all passing. These tests had never run anywhere — they were in no Makefile target and no workflow. They're now in
make test-scripts.That said: nothing in this repo runs
make testin CI.verify.yamlis a reusable workflow for caller repos;go testthere runs against the caller's checkout, not this repo's scripts. So the suite still only runs when someone runsmakelocally. Wiring up a self-test workflow is a separate PR — happy to open it.Also left alone:
general-pr-review.yamlexposes no inputs at all, so it has noverdict_modeand keeps thebaselinedefault. Pre-existing asymmetry, same assummary_marker.🤖 Generated with Claude Code