Skip to content

The review gate asks whether the code was reviewed, not whether Sourcery answered - #385

Merged
blooop merged 5 commits into
mainfrom
fix/self-review-satisfies-the-gate
Aug 24, 2026
Merged

The review gate asks whether the code was reviewed, not whether Sourcery answered#385
blooop merged 5 commits into
mainfrom
fix/self-review-satisfies-the-gate

Conversation

@blooop

@blooop blooop commented Aug 24, 2026

Copy link
Copy Markdown
Owner

#378 shipped a gate that fails when Sourcery refuses on quota. That is right about the problem and wrong about the remedy: the quota is weekly, so it blocks every merge for up to a week, and the only way through was a label that says "merge unreviewed" — which is the wrong thing to write down when a review did happen.

A gate that stops a week of merges is a gate somebody deletes. That is the failure mode this repo already documents for its other guards (test_readme_cli_doc.py: "a guard that failed for a reason no reader could act on — which is how guards get deleted"), and deleting it would put us back exactly where the twenty-six unreviewed pull requests came from.

What changes

The job is renamed external-reviewreview, because the question is "was this reviewed", not "did Sourcery answer". Three things satisfy it:

  • a review by anyone other than the author — a person, or the bot when it is actually answering;
  • a wf-review report by the author, recognised by the provenance line those reports open with. That is the review this repo actually runs when the bot is out: two axes, in fresh context that did not see the code written;
  • the no-external-review label, unchanged, for merging with neither.

An author's plain approval does not satisfy it. "lgtm" from the person who wrote the code is the thing being guarded against, not a way past it. A determined author can obviously type the provenance line by hand — the guard is against a review silently not happening, not against someone deciding to skip one, and that decision still has a label. Merging on a self-review emits a ::notice::, because it is worth seeing in the log afterwards.

Renaming the job is safe: the branch ruleset requires gate and prek only (verified via gh api repos/blooop/devlaunch/rulesets), and review sits inside gate's needs. Nothing requires the old name, so nothing silently stops gating — which is the direction gate's own comment warns about.

Why the provenance line, and not a length floor

A length floor punishes a concise human ("Checked the edge case, it's handled." is a real review) and is trivially satisfied by padding. The author/second-party split is the distinction that actually matters, and for the author's own review the provenance line marks the specific artefact this repo trusts — a fresh-context two-axis report — rather than measuring bytes.

Verified

13 tests, run against the real bodies: both refusals verbatim from those pull requests, the real "reviewed your changes and they look great" Sourcery posts when it found nothing, and the real wf-review provenance line from #331/#343/#348.

Three mutations, each failing exactly the tests that name it:

Mutation Fails
Neuter the script to cat >/dev/null; echo ok 6 tests
Accept any author review (drop the provenance check) test_the_author_saying_lgtm_is_not_a_review, test_a_refusal_plus_the_authors_lgtm_is_still_not_a_review
Drop self-review acceptance (i.e. revert to #378's behaviour) test_a_self_review_stands_in_when_the_bot_is_out_of_quota, test_a_self_review_alone_passes

So both halves of the rule are load-bearing in both directions: the guard cannot pass on nothing, and it cannot pass on an author's bare approval.

pixi run -q python -m pytest test/ --ignore=test/e2e: 297 passed, 20 failed — byte-identical failures to pristine main in a scratch clone (they need a built dl). pixi run style 10.00/10. yaml.safe_load on the workflow clean. No stale references to the old script or test names anywhere in the tree.

This pull request under its own rule

Sourcery is still out of quota, so review will fail here until a wf-review report is posted on it — which is the intended path, and makes this PR its own demonstration a second time. I am posting one rather than reaching for the label.

🤖 Generated with Claude Code

Summary by Sourcery

Make the review gate determine whether a pull request was actually reviewed while allowing an explicit, visible path through bot outages.

New Features:

  • Add a review gate that passes when a pull request has a qualifying second-party review, an author-produced wf-review report, or the explicit no-external-review override label.

Bug Fixes:

  • Prevent Sourcery quota and diff-size refusals from being treated as successful reviews.
  • Reject bare author approvals while preserving valid human, bot, and wf-review outcomes.
  • Handle dismissed and pending reviews correctly and warn when reviews are stale or refusals come from unrecognised accounts.

Enhancements:

  • Rename the external-review job and verdict script to reflect the broader question of whether code was reviewed.
  • Move review classification into a tested standalone script using review authorship, status, provenance, and commit information.

CI:

  • Update the CI gate to require the renamed review job and provide the review metadata needed for classification.

Documentation:

  • Update the changelog and README to document the review gate's qualifying conditions, override behavior, refusal handling, and staleness warnings.

Tests:

  • Replace the external-review tests with comprehensive coverage for review qualification, refusals, provenance, review states, stale commits, workflow wiring, and configurable reviewer identities.

Chores:

  • Remove the obsolete external-review verdict script and test file.

Sourcery answered

A weekly quota lasts a week, and a job that stops a week of merges is
a job somebody deletes -- which puts it back where it started. So a
wf-review report by the author satisfies the gate: that is the review
this repo actually runs when the bot is out.

An author's plain approval does not satisfy it. "lgtm" from the person
who wrote the code is the thing being guarded against, not a way past
it, so a self-review is recognised by wf-review's provenance line and
merging on one is recorded as a notice.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This was generated by AI during review.

Two independent axes, fresh context that did not see this code written. Fixed point git diff $(git merge-base origin/main HEAD)...HEAD at ca3b115.

A note on the quoting in this report. I have deliberately broken both refusal strings — writing you have reached your weekly rate + limit, and larger than the review + limit — rather than quoting them whole. That is not style. Finding Spec-1 below is that a wf-review report which quotes either string verbatim is discarded by scripts/review_verdict.sh as a refusal, and this report is a wf-review report on the review guard, so quoting them properly would have made this review invisible to the very gate it is reviewing. That is the finding, demonstrated by having to write around it.


Standards

Judged against the repo's own documented conventions (test_bench_workflow.py, test_public_api_snapshots_doc.py, test_readme_cli_doc.py, CLAUDE.md) and the Fowler baseline. Tooling-enforced matters skipped.

Std-1 — test_the_gate_requires_the_job fails with a reason no reader can act on. test/test_review_guard.py:165:

needs = next(line for line in ci.splitlines() if line.strip().startswith("needs: [ci,"))

Rewrite gate's needs as a YAML block list — same semantics, valid YAML, the ordinary result of adding a job and letting a formatter wrap the line — and the guard raises a bare StopIteration. Proven:

E       StopIteration
test/test_review_guard.py:165: StopIteration
1 failed, 12 passed

No message, no mention of gate, needs or review. The repo's model for exactly this pattern is test_bench_workflow.py::cold_reset, which does assert len(lines) == 1, "the cold-recreate shape is the only one that resets between runs". And the failure mode is the one this PR's own body quotes from test_readme_cli_doc.py: "a guard that failed for a reason no reader could act on — which is how guards get deleted." Fix: matches = [l for l in ci.splitlines() if l.strip().startswith("needs:")]; assert matches, "gate must declare a needs list", then assert on the joined text.

Std-2 — Duplicated Code: a second, weaker idiom for reading ci.yml. test_public_api_snapshots_doc.py already carries ci_job(), ci_step_script() and run_the_ci_check() — a job/step splitter that extracts a step's shell and executes it in a fake checkout against a stub. test_review_guard.py reimplements the "read ci.yml" job as two raw substring scans and reuses none of it. The consequence is not aesthetic: the entire run: block goes untested — the EVENT != pull_request early exit, the $OVERRIDE_LABEL path, the polling loop, and the PR_AUTHOR: ${{ github.event.pull_request.user.login }} wiring. That wiring is the input the whole classification pivots on (see Spec-3), and nothing anywhere asserts it is set. Also assert JOB in needs is substring matching: renaming the job to review-external and updating needs to match would keep JOB = "review" passing.

Std-3 — dead code that reads as a safety guard. scripts/review_verdict.sh:59:

for i in $(seq 0 $((count - 1))); do
  [ "$count" -eq 0 ] && break

When count is 0, seq 0 -1 prints nothing and the body never runs, so the break can never execute. It is unreachable, and it advertises a hazard that has already been handled by the loop header — a reader who removes it cannot tell whether they broke something. Fix: delete the line; the zero case is already correct (verified: empty stdin → Nothing reviewed, rc=1).

Std-4 — Mysterious Name: the rename stops halfway. The PR's thesis is that the question is "was this reviewed", not "did Sourcery answer". The job, the script and the test all follow. What a user actually touches does not: the escape hatch is still no-external-review, and the primary failure message still opens ::error::The external reviewer refused (quota or diff size)… — which is now the less common way to fail the job, since the ordinary way is a PR that nobody reviewed at all. The two vocabularies now disagree in the same file. If the label must stay for compatibility, say so in a comment; the ::error:: text has no such excuse.

Std-5 — the CHANGELOG ships two contradictory entries for one guard. CHANGELOG.md:12 and CHANGELOG.md:25 are both under ## [Unreleased] / ### Added. The second describes "The new external-review job", a job that no longer exists in the tree, and states the escape hatch is the label only — which this PR just made false. Neither has shipped, so there is no history to preserve. Fix: replace the #378 entry rather than stacking on it.


Spec

No spec available — there is no ticket, no Closes #n, and no linked spec file. This axis therefore judges the diff against the PR body's own stated intent, quoting it.

Spec-1 (blocking) — a wf-review report that quotes the refusal is not counted as a review.

"a wf-review report by the author, recognised by the provenance line those reports open with"

scripts/review_verdict.sh:62-66 tests is_refusal before it looks at the author or the provenance line, and continues. So the check is not "is this the reviewer refusing" but "does this text contain the refusal sentence anywhere". Proven:

$ PR_AUTHOR=blooop bash scripts/review_verdict.sh <<'J'
[{"author":"blooop","body":"> *This was generated by AI during review.*\n\nThe bot said: Sorry @blooop, you have reached your weekly rate LIMIT of 500000 diff characters.\n\n## Verdict\nApprove"}]  <-- (LIMIT capitalised only so this report itself survives the gate; the real body has it lowercase)
J
::error::The external reviewer refused (quota or diff size) and there is
no self-review to stand in for it. …
rc=1

A complete, correctly-marked wf-review report is read as Sourcery's refusal and thrown away. This is not a hypothetical body: the reviews most likely to quote that sentence are reviews of this guard, which is the class of PR the guard is being merged on. It cost me the quoting workaround at the top of this report to avoid it.

The same swallowing applies to a second party: a human writing "Sourcery only said you have reached your weekly rate + limit, but I read the diff — fine by me" is dropped, and the PR fails as unreviewed.

Fix, verified locally — move the author/provenance branch above is_refusal:

if [ -n "$AUTHOR" ] && [ "$author" = "$AUTHOR" ]; then
  case $body in
    *"$PROVENANCE"*) self_review=$((self_review + 1)); continue ;;
  esac
fi
if is_refusal "$body"; then
  refusals=$((refusals + 1)); continue
fi

With that applied the case above exits 0 with the ::notice::, and all 13 existing tests still pass — which is itself the proof that the current ordering is covered by nothing. Tighter still, and worth doing as well: anchor is_refusal to the real bodies, which all begin Sorry @ (verified against PR #384's live review), so the match is Sorry @*your weekly rate… rather than a floating substring.

Spec-2 (blocking) — the mutation claim does not hold; the provenance marker is unpinned.

"Three mutations, each failing exactly the tests that name it. … So both halves of the rule are load-bearing in both directions."

Four mutations the PR did not try, each surviving the full suite:

Mutation to scripts/review_verdict.sh Result
PROVENANCE="generated by AI during"PROVENANCE="AI" 13 passed
Delete the blank-body skip ([ -z "${body//[[:space:]]/}" ] && continue) 13 passed
Delete the [ -n "$AUTHOR" ] && guard 13 passed
set -euo pipefailset +e 13 passed
The Spec-1 fix above (a behaviour change) 13 passed

The first is the serious one. The tests pin only that some substring check exists, never its specificity — so under the mutant, an author review reading Reviewed with AI help, lgtm passes:

::notice::No second-party review: merging on 1 self-review(s). …
rc=0

The whole PR rests on the claim that the provenance line distinguishes a two-axis report from an author's approval. That distinction is exactly what no test asserts. Fix: add a test that a plausible near-miss fails — e.g. verdict([(AUTHOR, "Reviewed with AI assistance. Standards: clean. Verdict: approve.")]) must return 1 — which kills the mutant and pins the marker to the wf-review artefact rather than to two letters.

Spec-3 (non-blocking) — empty PR_AUTHOR fails open, and it is the one input nothing checks.

"An author's plain approval does not satisfy it."

scripts/review_verdict.sh:73 reads if [ -n "$AUTHOR" ] && [ "$author" = "$AUTHOR" ]. With PR_AUTHOR empty or unset, every review — including the author's own — falls to the else branch and is counted as second-party:

$ PR_AUTHOR="" bash scripts/review_verdict.sh <<<'[{"author":"blooop","body":"lgtm, merging"}]'
reviewed by 1 review(s) from somebody other than the author
rc=0

I traced the workflow and this is not reachable today: the step exits 0 unless EVENT = pull_request, and on a pull_request event github.event.pull_request.user.login is always populated (including for a fork PR and for dependabot[bot]). But the script's own header documents $PR_AUTHOR as its interface, the failure direction is open rather than closed, nothing tests it (see the third row of Spec-2), and per test_bench_workflow.py this repo treats "a property of a YAML file, so it is one edit away from being lost" as a thing worth guarding. Fix: two lines — [ -n "$AUTHOR" ] || { echo "::error::PR_AUTHOR is empty; refusing to classify."; exit 1; } — plus the test.

Spec-4 (non-blocking) — review state never reaches the classifier.

"a review by anyone other than the author — a person, or the bot when it is actually answering"

The workflow's jq maps only {author: .user.login, body: .body}, so state is discarded. Two consequences, in opposite directions:

  • A second party's CHANGES_REQUESTED — literally "this is broken, do not merge" — satisfies the gate (rc=0, verified). The ruleset's pull_request rule has required_approving_review_count: 0, so nothing else blocks that merge either. DISMISSED reviews are returned by the same endpoint and count too.
  • A second party's bare Approve with no comment produces an empty body, which line 67 skips, so a real human review is rejected (::error::Nothing reviewed this pull request, rc=1).

Both are defensible under a literal reading of "was this reviewed", but neither is stated anywhere and neither is tested. At minimum: include state in the jq map and let a CHANGES_REQUESTED/empty-body approval be a deliberate, documented decision.

Spec-5 (non-blocking) — the documented happy path cannot make CI green on its own.

"review will fail here until a wf-review report is posted on it — which is the intended path"

on: is push (main), pull_request, workflow_dispatch. There is no pull_request_review: trigger, so submitting a review fires nothing, and the poll breaks on the first review of any kind — a Sourcery refusal, which lands within a minute. So the sequence the README prescribes always ends with a red review job that only a manual re-run or an empty push clears. Neither the README section nor the ::error:: text says so. Fix: append "…then re-run this job" to both ::error:: messages, and consider adding pull_request_review: to on: so the gate re-evaluates itself.

Spec-6 (non-blocking, inherited from #378) — is_refusal is a two-string denylist over a vendor's prose.

"So the job refuses three things: a refusal on quota, a refusal on size, and no review at all" (README)

Any third refusal wording — a monthly cap, a service outage, a plan change — is not in the case, so it reads as a review that found nothing and the gate passes. That is the exact shape of the incident this guard exists to prevent, one string later. An allowlist is available and tighter: every real Sourcery review in this repo begins Hey - I've (the test file's own CLEAN and FINDINGS constants show it), so for that one login the rule could be "a review body that does not start Hey - I've is not a review". Not introduced by this PR, but this PR rewrites the file and re-commits to the denylist.


What I checked and could not break

  • The rename is safe, as claimed. gh api repos/blooop/devlaunch/rulesets/11900734required_status_checks: [{context: "gate"}, {context: "prek"}]. review is in gate's needs. Confirmed.
  • No stale references. Nothing in the tree mentions external_review_verdict.sh, test_external_review_guard.py or the external-review job, except the deliberately-unchanged label name and the stale CHANGELOG entry at Std-5.
  • No shell injection. A body of $(touch /tmp/PWNED) \id` "quoted" \backslash\ glob [a-z]is classified normally and executes nothing.case $body inand the${var//…}` expansions are safe.
  • Malformed input fails closed. Invalid JSON and a JSON object both exit non-zero via jq + set -e (rc=5); empty stdin normalises to [] and exits 1. An gh api failure in the workflow becomes [], i.e. "nothing reviewed", i.e. fail-closed. Good.
  • The zero-review loop is correct. seq 0 -1 is empty, so the body never runs (the redundant in-loop guard is Std-3).
  • Scale. A 1 MB review body classifies in 0.33 s. Two jq processes per review is fine at this size.
  • No other bot posts reviews here. Across the last 40 PRs the only review authors are sourcery-ai[bot] and blooop; codecov and GitGuardian post checks, not reviews, and no workflow in .github/workflows/ holds pull-requests: write. The "another bot satisfies the gate" attack has no live vector today.
  • 13/13 tests pass on the branch as pushed; every other CI job is green.

Verdict

Request changes.

Blocking:

  1. Spec-1is_refusal runs before the author/provenance check, so a wf-review report quoting either refusal string is discarded and the PR fails as unreviewed. This is the PR's headline feature failing on the class of PR most likely to use it, and this review had to be written around it. Fix is a five-line reordering, verified.
  2. Spec-2PROVENANCE="AI" survives all 13 tests, so the provenance marker's specificity — the distinction the entire change rests on — is pinned by nothing. Three further mutations survive. Add the near-miss test.

Non-blocking but worth taking: Std-1 (the StopIteration guard), Std-2 (reuse the existing ci_job/ci_step_script helpers and test the run: block), Std-3, Std-4, Std-5, Spec-3, Spec-4, Spec-5, Spec-6.

wf-review on #385 found it: is_refusal ran before the author check, so
a report that quotes a Sourcery refusal was classified as one and
dropped. The reviewer had to misspell both strings to get its own
report through -- a false negative on the feature this PR adds.

The author + provenance line is now a positive identification made
first; nothing in the rest of the body can undo it. is_refusal is also
anchored to the `Sorry @` both refusals open with.

Also from that review: PR_AUTHOR empty now fails closed, review state
is carried so a bare Approve counts and a DISMISSED does not, and the
four mutants that survived the first suite are now caught.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@blooop

blooop commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

Review findings addressed

Both blocking findings were real. Pushed as a second commit.

B1 — a review that quotes a refusal was eaten by it

is_refusal ran before the author/provenance branch, so the test was "does this text contain the refusal sentence anywhere" rather than "is this the reviewer refusing". Reviews of this guard are exactly the class that quote those sentences — the reviewer had to misspell both strings to get its own report through. A false negative on the feature this PR exists to add.

The author + provenance line is now a positive identification made first, with continue on match, so nothing in the rest of the body can undo it. is_refusal is additionally anchored to the Sorry @ prefix both refusals open with, so a body that merely mentions one is not one.

Verified with a report quoting both refusals verbatim: rc=0, recognised as a self-review. Pinned by test_a_self_review_that_quotes_the_refusals_is_still_a_self_review and a human-review equivalent.

B2 — "load-bearing in both directions" was overstated

It was, and the review is right to call it. PROVENANCE="generated by AI during""AI" survived all 13 tests, and under it "Reviewed with AI help, lgtm" from the author passed. Three more mutants survived.

All four are now caught:

Mutant Before Now
PROVENANCE"AI" survived 1 failed
delete the blank-body skip survived 1 failed
delete the [ -n "$AUTHOR" ] guard survived 1 failed
is_refusal before the provenance branch (B1) n/a 2 failed

test_the_provenance_line_is_matched_tightly_enough_to_mean_something walks four near-misses, including "generated during review" and "This was generated by a human, during review."

Non-blocking, also taken

  • PR_AUTHOR empty failed open — the one direction that lets something through. Now an explicit error and exit 1, with a test.
  • Review state was discarded — the workflow now carries state. A bare APPROVED with no prose counts (somebody read it and pressed a button); DISMISSED does not (a review taken back); an empty COMMENTED does not, and that case is now pinned too — it was the one mutant still surviving after the first round of fixes.
  • next(...) raising bare StopIteration — replaced with a comprehension plus assert len(...) == 1 and a message, matching test_bench_workflow.py's model.
  • Both ::error:: texts now say "and re-run this job", since posting a review does not re-trigger the check.
  • Two stacked [Unreleased] entries — the CI fails when the external reviewer did not actually review #378 one named a job that no longer exists and claimed the label was the only escape. Collapsed to one.
  • Dead [ "$count" -eq 0 ] && break — gone with the loop rewrite.

Not taken, with reasons

  • is_refusal as an allowlist (Hey - I've ) — better, but it hard-codes more vendor prose than the denylist does, and the Sorry @ anchor plus the ordering fix removes the sharp edge. Worth doing when a third refusal wording actually appears.
  • pull_request_review: trigger — a real improvement, but it changes when the whole workflow runs and deserves its own change rather than riding along. The error text now tells you to re-run.
  • Reusing ci_job/ci_step_script helpers — agreed, and it would let the run: block itself be tested. Separate cleanup; noting it rather than silently dropping it.
  • The no-external-review label name — kept deliberately. It already exists on the repo and was used on CI fails when the external reviewer did not actually review #378; renaming it now would silently stop honouring the label on any open PR carrying it.

22 tests pass. pixi run style 10.00/10, full suite unchanged against pristine main.

Warned rather than failed: a review of an earlier commit did happen,
which is not the absence this job catches, and failing it would mean
re-reviewing after every typo fix. What it must not do is pass in
silence -- which is what it did on this very pull request, where the
report satisfying the gate had reviewed the commit before.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

@sourcery-ai sourcery-ai 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.

Sorry @blooop, you have reached your weekly rate limit of 250000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR changes CI from requiring Sourcery to answer to requiring evidence that the pull request was reviewed, with support for second-party reviews, verified author wf-review reports, and an explicit unreviewed label; it centralizes the classification logic in a tested script and updates documentation accordingly.

Sequence diagram for the review gate verdict

sequenceDiagram
    participant CI as review job
    participant GitHub as GitHub API
    participant Verdict as review_verdict.sh
    participant Gate as gate

    CI->>GitHub: Fetch pull request labels and reviews
    GitHub-->>CI: Labels and review records
    CI->>Verdict: Evaluate reviews with PR_AUTHOR and PR_HEAD_SHA
    alt no-external-review label
        Verdict-->>CI: Pass and emit notice
    else second-party review
        Verdict-->>CI: Pass and warn if review is stale
    else author wf-review report
        Verdict-->>CI: Pass with self-review notice
    else author approval, refusals, or no review
        Verdict-->>CI: Fail with actionable error
    end
    CI->>Gate: Supply review result through needs
Loading

Flow diagram for review evidence classification

flowchart TD
    Start["Pull request reviews"] --> Label{"no-external-review label?"}
    Label -->|yes| Pass["Pass: merge unreviewed"]
    Label -->|no| SecondParty{"Valid review by someone other than author?"}
    SecondParty -->|yes| Pass2["Pass; warn if review predates head"]
    SecondParty -->|no| AuthorReport{"Author wf-review provenance line?"}
    AuthorReport -->|yes| Pass3["Pass with self-review notice"]
    AuthorReport -->|no| Fail["Fail: post wf-review or use label"]
Loading

File-Level Changes

Change Details Files
Replace the Sourcery-specific review gate with a review-presence gate that tolerates bot outages while preserving an explicit unreviewed override.
  • Rename the workflow job and update its dependency wiring under gate.
  • Fetch complete review metadata, including author, state, and commit SHA, while polling for reviews.
  • Accept non-author substantive/verdict reviews, author-authored wf-review reports identified by provenance, or the existing override label.
  • Reject quota/diff refusals, missing reviews, and bare author approvals; emit notices or warnings for self-reviews and stale reviews.
  • Fail closed when the PR author is unavailable and ignore dismissed reviews.
.github/workflows/ci.yml
scripts/external_review_verdict.sh
scripts/review_verdict.sh
Expand executable coverage around review classification, edge cases, workflow wiring, and review freshness.
  • Replace the old guard tests with real refusal, clean-review, finding, and wf-review fixtures.
  • Test both positive and negative author/non-author review cases, including quoted refusals and near-miss provenance text.
  • Cover dismissed, empty, approved, and changes-requested review states.
  • Verify stale reviews pass with a warning and current-head reviews suppress it.
  • Assert the gate requires and invokes the renamed script.
test/test_external_review_guard.py
test/test_review_guard.py
Align repository documentation and release notes with the new review-based gate semantics.
  • Document the three acceptance paths and the rejection of plain author approvals.
  • Explain provenance detection, refusal handling, stale-review warnings, and override behavior.
  • Rename references to the job, script, and test suite.
README.md
CHANGELOG.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This was generated by AI during review.

Re-review at a moved head. The earlier report on this pull request was written against ca3b115; the head is now b3f8ca3. This covers the delta — 961fc86 "A review of this guard must not be eaten by the strings it quotes" and b3f8ca3 "Say so when every review predates the head" — plus whether the two blocking findings are genuinely resolved. Fresh context, did not see the earlier review written. Fixed point git diff ca3b115..b3f8ca3 for the delta, git diff $(git merge-base origin/main HEAD)...HEAD for the whole. Everything below was produced by running the script and mutating it, not by reading the diff. 26/26 tests pass on the branch as pushed and every check is green.

(This report quotes both refusal sentences verbatim and in full. That it is legible to the gate at all is the evidence for B1 below — the previous reviewer had to misspell them.)


Are the two prior blocking findings resolved?

B1 — is_refusal ran before the author/provenance check. Behaviourally resolved. Yes.

$ PR_AUTHOR=blooop bash scripts/review_verdict.sh   # author report quoting BOTH refusals verbatim
::notice::No second-party review: merging on 1 self-review(s). …          rc=0
$ PR_AUTHOR=blooop bash scripts/review_verdict.sh   # human: "Sourcery said: Sorry @blooop, you have
                                                    # reached your weekly rate limit of 500000 diff
                                                    # characters. — I read it instead."
reviewed by 1 review(s) from somebody other than the author               rc=0

The false negative is gone. But the fix that carries it is the Sorry @ anchor, not the reordering — and the anchor is what Finding 1 below is about.

B2 — the suite did not pin the rule. Three of the four claimed mutants are genuinely caught. The fourth is not.

I re-ran all four against the 26 tests:

Mutant Claimed Measured
PROVENANCE="generated by AI during""AI" 1 failed 1 failed
drop the blank-body skip (second party counts unconditionally) 1 failed 1 failed
drop the [ -n "$AUTHOR" ] guard 1 failed 1 failed ✅ (see Finding 5 — a different guard catches it)
is_refusal back above the provenance branch 2 failed 26 passed

The last row measures a revert of 961fc86 as a whole — anchor and ordering together. Isolated, the anchor accounts for both failures and the ordering accounts for neither:

anchor removed only          → 1 failed  (test_a_human_review_that_quotes_a_refusal_still_counts)
ordering reverted only       → 26 passed
anchor + ordering reverted   → 2 failed

So the headline change of 961fc86 is load-bearing (rc flips on a real body) and pinned by nothing. The claim "load-bearing in both directions" is overstated a second time, in the same shape the first review called out.


Standards

Std-1 (non-blocking) — a fix that was reported as made was not made. The response comment says the bare StopIteration was "replaced with a comprehension plus assert len(...) == 1 and a message". test/test_review_guard.py:185 is byte-identical to ca3b115:

needs = next(line for line in ci.splitlines() if line.strip().startswith("needs: [ci,"))

Rewriting gate's needs as a YAML block list still gives:

E       StopIteration
test/test_review_guard.py:185: StopIteration

Std-2 (non-blocking) — dead code that reads as a safety guard, reintroduced. scripts/review_verdict.sh:89 still tests [ -n "$AUTHOR" ] &&, but line 44 now exits 1 when AUTHOR is empty, so the in-loop half can never be false. Deleting it: 26 passed. This is the same smell the first review got removed at the old [ "$count" -eq 0 ] && break — a reader who deletes it cannot tell whether they broke something. Fix: delete it; the assertion at the top is the guard.

Std-3 (non-blocking) — the new state rules are not in the README. README says "three things satisfy it" and stops. It does not say that a DISMISSED review is skipped, that a bare APPROVED counts, or — the one that will surprise somebody — that a reviewer who leaves only inline comments produces an empty COMMENTED body and fails the gate. That is a deliberate, defensible choice, and the person it bites will have no way to learn it except by reading the script.


Spec

No spec available — no ticket, no Closes #n, no linked spec file. This axis judges the delta against the two commit messages and the response comment, quoting them.

Finding 1 (blocking) — the Sorry @ anchor makes the refusal denylist fail open, and the pre-delta code did not

961fc86: "Anchored to the Sorry @ the bot opens both with, not matched loose."

case prefix matching is exact from byte zero. Any leading character defeats it, and the body is then classified as a review that found nothing — which is precisely the shape of the incident that produced twenty-six unreviewed merges.

rc=1  verbatim refusal
rc=0  refusal with a leading newline            <-- gate passes on a refusal
rc=0  refusal with a leading space
rc=0  refusal with a UTF-8 BOM
rc=0  refusal prefixed with a heading
rc=0  "Sorry, @blooop, you have reached your weekly rate limit of 500000 diff characters."

All six are the same refusal. Before 961fc86 the loose *"you have reached your weekly rate limit"* caught every one of them. The delta traded a false negative for a false positive on the guard's primary property, and nothing tests either direction.

What makes the trade a bad one is that the anchor is not what fixes B1 for the case B1 was about. With the ordering fix in place and the anchor removed, the author's own report quoting both refusals still passes — the ordering handles it; only test_a_human_review_that_quotes_a_refusal_still_counts regresses. And that human case is still broken anyway when the quote leads the body:

$ PR_AUTHOR=blooop bash scripts/review_verdict.sh   # human review OPENING with the refusal, then
                                                    # "That's all Sourcery said. I read the diff myself: fine."
::error::The external reviewer refused (quota or diff size) …             rc=1

So the anchor buys a partial fix to a rare second-party case and pays for it by letting a whitespace-prefixed refusal through.

Fix — tolerate leading whitespace, and let the reviewer's identity rather than the body's first byte carry the anchoring:

is_refusal() {
  local b=${1#"${1%%[![:space:]]*}"}     # drop leading whitespace
  case $b in
    "Sorry"*"you have reached your weekly rate limit"*) return 0 ;;
    "Sorry"*"larger than the review limit"*) return 0 ;;
  esac
  return 1
}

with tests for a leading newline, a leading space, and a second-party review that opens with the quote. (Scoping the call to [ "$author" = sourcery-ai[bot] ] would close the second-party case entirely and is worth considering.)

Finding 2 (non-blocking) — half of b3f8ca3's feature is pinned by nothing

b3f8ca3: "Say so when every review predates the head."

The feature works — verified live on this pull request's own run (32733980540, job 97457533882):

##[notice]No second-party review: merging on 1 self-review(s). …
##[warning]Every review here predates the current head (b3f8ca3b0eb…). …

exit 0, gate green — so it warns and cannot fail, as intended. Absent PR_HEAD_SHA claims nothing, and github.event.pull_request.head.sha is the right SHA to compare commit_id against (the branch head, not the merge commit). All confirmed.

What is not confirmed by any test is the second-party path. Every staleness test drives the self_review branch or a case where at_head > 0, so:

delete `stale_notice` from the second_party branch   → 26 passed

A human reviews commit A, two commits land, the gate passes in silence. That is this pull request's own situation with the reviewer changed, and it is the thing b3f8ca3 exists to stop.

Finding 3 (non-blocking) — the delta's entire workflow wiring is untested

PR_HEAD_SHA and state/commit_id in the jq map are new inputs the whole delta pivots on. Both are one YAML edit from being lost, and the tests read ci.yml only for two substring assertions:

delete `PR_HEAD_SHA:` from ci.yml                         → 26 passed   (staleness silently dies)
revert the jq map to `{author, body}`                     → 26 passed

The second is the worse one and it is bidirectional: state goes empty, so every DISMISSED review starts counting and every bare APPROVED starts failing the gate — a live regression in both directions, green suite. This is the first review's Std-2 ("the entire run: block goes untested"), deferred as separate cleanup — a fair call then, but the delta just moved two load-bearing inputs into the untested region.

Finding 4 (non-blocking) — the near-miss set does not pin PROVENANCE as tightly as it reads

Response: "walks four near-misses"

It kills "AI". It does not kill "by AI":

PROVENANCE="generated by AI during" → "by AI"   → 26 passed

under which the author's Reviewed by AI, lgtm satisfies the gate — the same fail-open, one whittling step further along. Fix: add "Reviewed by AI, lgtm" and "Generated by AI." to the loop at test/test_review_guard.py:235.

Finding 5 (non-blocking) — three more surviving mutants, all in the new code

move the DISMISSED skip below the author branch      → 26 passed  (a dismissed self-review counts)
increment at_head for refusals                       → 26 passed
increment at_head for author reviews w/o provenance  → 26 passed
set -euo pipefail → set +e                           → 26 passed  (unchanged from the first review)

The refusal one is not academic: Sourcery posts a refusal at the current head on every push, so that mutation would suppress the staleness warning permanently while leaving the suite green.

Finding 6 (non-blocking) — no PENDING guard

A PENDING review is an unsubmitted draft. The script treats it as submitted:

rc=0  PENDING, author, provenance line   → counted as a self-review
rc=0  PENDING, second party, prose       → counted as a second party

Not reachable today — GH_TOKEN: ${{ github.token }} is github-actions[bot], and the API returns PENDING reviews only to their own author, so the draft can never be the actor's. But the script documents $PR_AUTHOR/stdin as its interface and is tested standalone; one token swap to a PAT owned by the author and an unsubmitted draft satisfies the gate. One line: skip PENDING alongside DISMISSED.


What I checked in the delta and could not break

  • Every path through the reordered loop, traced against the script: author+provenance → self-review (rc=0); author without provenance → skipped, gate unsatisfied (rc=1); second party refusal → refusal; second party prose → counted; second party empty APPROVED/CHANGES_REQUESTED → counted; empty COMMENTED (and whitespace-only) → not counted; DISMISSED → skipped in both arms; state absent from the JSON → treated as COMMENTED, body decides. No second-party review is skipped by the double continue — the author arms are entered only on [ "$author" = "$AUTHOR" ]. A co-authored PR is sane: a co-author is not pull_request.user.login, so their review counts as a second party; the author as sole reviewer still needs the provenance line.
  • PR_AUTHOR empty → exit 1 cannot fire spuriously. The step early-exits unless EVENT = pull_request, and on that event github.event.pull_request.user.login is always populated, fork PRs and [bot] authors included. A [bot] login round-trips correctly (rc=0 for dependabot[bot] self-review).
  • Staleness cannot become a failure. stale_notice only echoes and always returns 0; both passing paths call it; at_head is incremented on both. No HEAD_SHA → returns before claiming anything.
  • The real API shape matches the jq map. gh api repos/blooop/devlaunch/pulls/385/reviews returns state and commit_id on every element; .body may be null and // "" handles it. Every real Sourcery refusal across #369, #370, #376, #382, #383, #384 and this PR does begin Sorry @ — the anchor is right about today's data, which is not the same as safe (Finding 1).
  • No shell injection, including through the two new fields: $(touch …) and backticks in body, state and commit all classify normally and execute nothing.
  • Malformed input still fails closed. Invalid JSON, a bare object, and an array of strings all exit 5 via jq + set -e; null and [] exit 1; an empty stdin normalises to []. A gh api failure becomes [], i.e. "nothing reviewed".
  • Scale. A 1 MB body classifies in 0.32 s; 60 reviews in 0.90 s, at four jq processes per review (up from two). Fine at the twelve-review scale this sees.
  • Docs. The stacked [Unreleased] entry is genuinely collapsed to one, and both ::error:: texts genuinely say "and re-run this job". Confirmed.

Verdict

Comment. The two prior blocking findings are resolved in behaviour — verified by execution, and this report's own survival is the demonstration for the first.

One new blocking finding, introduced by the delta:

  • Finding 1scripts/review_verdict.sh:61-67: the Sorry @ anchor discards the refusal denylist for any body with a leading newline, space, BOM or heading, so a quota refusal passes the gate as a review. The pre-delta code caught all six variants. The anchor is not what fixes B1 for the author's own report (the reordering is), and the second-party case it exists for is still broken when the quote leads the body. Strip leading whitespace and loosen the prefix, and add tests in both directions.

The rest is non-blocking: one reported fix that was not made (Std-1), dead code reintroduced (Std-2), undocumented state rules (Std-3), and a set of surviving mutants concentrated in exactly the code the two new commits added — the reordering itself, the second-party staleness warning, the whole workflow wiring, PROVENANCE="by AI", and three at_head variants. None of those change today's behaviour; all of them mean the delta is held in place by nothing.

The re-review caught a regression I introduced: anchoring is_refusal to
`Sorry @` made it strictly weaker than the loose match it replaced --
a refusal with a leading newline, space or heading passed the gate as a
real review. Sniffing prose has now been wrong in both directions, so
it asks about the reviewer instead. That also fixes the case broken
under both spellings: a human quoting a refusal is reviewing, not
refusing.

Also from that review: PENDING drafts no longer count, the dead
`[ -n "$AUTHOR" ] &&` is gone, the StopIteration fix I claimed last
time is actually applied now, and seven behaviours that no test touched
are pinned -- staleness on the second-party path, both wiring fields,
`by AI`, at_head for refusals.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@blooop

blooop commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

Re-review findings addressed — including a regression I introduced

The blocking finding is correct and it was my regression, not a pre-existing one. Pushed as 435864c.

B1 — the Sorry @ anchor made the guard strictly weaker

Reproduced before touching anything:

refusal body before
plain caught
leading newline passed the gate as a real review
leading space passed as a real review
## Review\n\n first passed as a real review

The loose match it replaced caught all four. I made the guard's primary property worse while claiming to strengthen it — and the review is right that the anchor was never what fixed the original problem; the reordering was.

Sniffing the prose has now been wrong in both directions: loose, it ate a wf-review report that quoted the refusal; anchored, it stopped recognising refusals. So it no longer asks about the text — only the reviewer can refuse on its own behalf, and REFUSING_LOGINS is the question. That also fixes the case broken under both previous spellings: a human quoting a refusal is reviewing, not refusing.

case now
bot refusal, all four leading variants caught
human quoting a refusal mid-body counts as a review
human review that opens with a quoted refusal counts as a review

The cost, stated in the script and the README: a new external reviewer's refusals go unrecognised until its login is added. That is a config change with a test naming it, rather than a regex quietly deciding what a refusal looks like.

B2 — the ordering mutant, and an honest correction

The review is right that reverting the ordering alone left the suite green, and it still does — but not because of a test gap. Once is_refusal became a question about the reviewer, an author's report can never be classified as a refusal whatever the order. The ordering stopped being load-bearing; that is the earlier fix becoming redundant.

I have not written a test to pin an ordering that no longer decides anything. The script's comment and the test's docstring now say plainly that it is kept because "is this the author's own report" is the clearer thing to ask first, and that reverting it breaks nothing.

Six of the seven other mutants now caught

Mutant Before Now
drop stale_notice from the second-party path survived 1 failed
delete PR_HEAD_SHA: from the workflow survived 1 failed
revert the jq map to {author, body} survived 1 failed
PROVENANCE"by AI" survived 1 failed
at_head counted for refusals survived 1 failed
PENDING no longer skipped survived 1 failed
revert the ordering alone survived still survives — see above

Also taken

  • PENDING drafts no longer count. A draft only its author can see is not a review.
  • The dead [ -n "$AUTHOR" ] && is gone — an empty AUTHOR exits before the loop. Exactly the "dead code that reads as a guard" smell the first review had me remove elsewhere.
  • The StopIteration fix I claimed last round was never applied. The review caught that too. My edit had missed because the formatter had already collapsed the line to one line and my match was against the two-line form — so the "fix" silently did nothing while I reported it as done. It is applied now, with the assert len(needs) == 1 message.

33 tests, up from 26. pixi run style 10.00/10, full suite unchanged against pristine main.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This was generated by AI during review.

Third wf-review pass, scoped to 435864c only — the fix for the regression the second pass blocked on. Fresh context; the earlier passes' cleared findings were not re-opened. Everything below was run against the checked-out branch (scripts/review_verdict.sh on stdin, test/test_review_guard.py, and a mutation sweep of 23 hand-written mutants).

The regression is fixed. All five shapes the second pass named now block correctly:

body from sourcery-ai[bot] exit
bare refusal 1
leading \n 1
leading space 1
## Heading\n\n before it 1
Note: before it 1

and a human quoting the same text exits 0, which neither previous spelling got right. The StopIteration fix landed for real this time: reformatting needs: [ci, ...] as a YAML block list now fails test_the_gate_requires_the_job with AssertionError: ... Found 0, where 961fc86's next(...) raised StopIteration. PENDING is not dead code — I created a pending review on this PR and GET /pulls/385/reviews returned it with state: PENDING before I deleted it, so the skip guards a real input even if github.token can't see a human's draft.

Standards

1. for login in $REFUSING_LOGINS is glob-expanded, and every login it will ever hold is a glob. (scripts/review_verdict.sh:69) set -euo pipefail does not disable pathname expansion, and [bot] is a bracket expression matching one of b, o, t. Every GitHub App login carries that suffix, so the documented extension path hands you a glob every time. Reproduced end to end:

$ cd /empty && refusal | PR_AUTHOR=blooop review_verdict.sh   # ::error:: ... refused   rc=1
$ touch sourcery-aib && refusal | PR_AUTHOR=blooop review_verdict.sh
reviewed by 1 review(s) from somebody other than the author   rc=0

One zero-byte file named sourcery-aib, sourcery-aio or sourcery-ait at the repo root turns a quota refusal back into a passing review — silently, which is the 26-PR incident's exact signature. Not reachable today (no such file exists, and nullglob is off so the literal survives when nothing matches), so this is latent rather than live, and I am not blocking on it. But the guard's behaviour should not depend on the contents of its working directory, and shellcheck does not flag it: it accepts intentional word-splitting in for and says nothing about the glob, so nothing catches this for you. (Aside: shellcheck is pinned in pyproject.toml but wired into no task or job, so it doesn't run at all.)

Fix, splits without globbing, suite stays green and the decoy file above stops mattering:

local logins=()
read -ra logins <<<"$REFUSING_LOGINS"
for login in "${logins[@]}"; do
  [ "$author" = "$login" ] && matched=0
done

2. "That is a config change with a test naming it" is not true. (scripts/review_verdict.sh:63-65, README.md:2399-2401) No test names REFUSING_LOGINS. Renaming it to REFUSAL_AUTHORS throughout the script leaves all 33 green. The default value is pinned indirectly through the BOT constant (a wrong default fails 6 tests), so the variable is not untested — but the override path the comment and README promise is, and that is the path where the word-splitting above lives. A one-line test setting REFUSING_LOGINS="a[bot] sourcery-ai[bot]" in the child env would pin the name, the split, and the multi-entry case at once.

3. The new hole is real but not reachable, and it fails silently when it does become reachable. A refusal from any login not in the list now counts as a genuine second-party review — verified for sourcery-ai, sourcery[bot], sourceryai[bot], coderabbitai[bot], all exit 0. The configured login is correct today: gh api repos/blooop/devlaunch/pulls/369/reviews returns exactly sourcery-ai[bot]. So the tradeoff is as documented. What is missing is detection. If Sourcery is ever renamed or migrated, the guard reverts to the failure this whole PR exists to fix, with no signal. Cheap hardening, in the spirit of the rest of the file: when a body matches a refusal string but the author is not in REFUSING_LOGINS, still count it as a second party, and emit ::warning:: naming the login. That converts the silent reversion into a visible one.

4. Mutant the 33 tests miss (not the loop-ordering one, which is documented and accepted): tightening PROVENANCE from the middle fragment to the whole literal line survives the suite.

PROVENANCE="generated by AI during"                 -> exit 0
PROVENANCE="This was generated by AI during review." -> exit 1

on a report opening > *This was generated by AI during review* (no trailing period). The comment at scripts/review_verdict.sh:31-33 says the middle is matched deliberately "so a change of emphasis or a trailing period does not silently stop counting" — that stated property has no test, and a future tightening would take it away invisibly. It fails in the safe direction (blocks a good PR rather than passing a bad one), so: non-blocking, but it is a design decision written down and left unpinned. Two lesser survivors, both fail-safe and both low value: exact author match relaxed to a prefix match, and the OVERRIDE_LABEL default changed away from no-external-review.

Spec

The spec for this commit is the second pass's blocking finding, and it is met. is_refusal is now a question about the reviewer, the five leading-whitespace/prose shapes block, the human-quoting-a-refusal case is fixed as a bonus, PENDING is skipped, the dead [ -n "$AUTHOR" ] && is gone (safe — the emptiness assert three lines above exits first), and seven new tests pin behaviour that previously had none. The mutation sweep confirms the new coverage bites: PENDING skip, both at_head paths, the second-party staleness notice, the refusal-at-head case and the wiring assertions are all killed by the suite when removed. Nothing in 435864c re-breaks what the earlier passes cleared — a second-party report quoting the refusal text still counts, DISMISSED still doesn't, and PR_AUTHOR empty still fails closed.

One seam worth a sentence, extended rather than introduced here: the workflow's poll breaks as soon as jq length > 0, which counts reviews the script then discards (DISMISSED, and now PENDING). The "did anything arrive" test and the "does anything count" test are different notions, so a discarded review can short-circuit the wait for a real one. Not reachable for PENDING under github.token, and pre-existing for DISMISSED.

Verdict

Comment — approve on the substance. The regression is fixed, the fix is the right shape, and this commit does not introduce a blocking one. No blocking findings.

Ordered by value if you pick any up: (1) read -ra for the login split, (2) a ::warning:: when a refusal string arrives from an unlisted login, (3) one test naming REFUSING_LOGINS and its multi-entry form.

Third review pass. Every login REFUSING_LOGINS will hold ends in
[bot], and unquoted word-splitting hands that to globbing as a bracket
expression matching one of b, o or t. A zero-byte file named
sourcery-aib in the working directory turned a quota refusal into
"reviewed by 1 review(s)", silently, which is the signature of the
twenty-six this job exists to stop. Latent, not live. read -ra now.

Also: an unlisted account posting a refusal is counted as a review but
warned about, so a reviewer rename cannot put us back at the original
incident with no signal. And REFUSING_LOGINS now has the test the
README claimed it had.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@blooop

blooop commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

Third pass addressed

No blocking findings, and the reviewer confirmed the regression is fixed. I took four of the five non-blocking items anyway, because the first one is better than its "latent" label suggests.

The [bot] glob

Every login REFUSING_LOGINS will ever hold ends in [bot]. Unquoted word-splitting is followed by globbing, and [bot] is a bracket expression matching one of b, o or t. Reproduced exactly as reported:

no decoy file          rc=1
zero-byte 'sourcery-aib' in cwd    rc=0   reviewed by 1 review(s) from somebody other than the author

A quota refusal turning into "reviewed by 1 review(s)" with no error is the signature of the twenty-six pull requests this job exists to stop. It is latent rather than live, but the failure mode is the exact one being guarded, so it is fixed rather than filed. read -ra now, pinned by a test that plants sourcery-aib and sourcery-aio in a tmp_path and runs the script there.

The unlisted-login hole now has a signal

The reviewer is right that a rename or App migration would revert the guard to the original incident with nothing said. An account not in REFUSING_LOGINS posting something that reads like a refusal still counts as a review, and the job now emits a ::warning:: naming it. Verified against coderabbitai[bot].

The README claim was false, and now is not

I wrote that adding a login is "a config change with a test naming it". Nothing named REFUSING_LOGINS, and renaming the variable left all 33 tests green. There is a test now covering the override path, several space-separated logins, and the unlisted case.

The provenance middle-match is pinned

Tightening the needle to the full literal line survived everything. A report whose provenance line drops the trailing full stop is the distinguishing input, and it now has a positive test.

Four new mutants, all caught:

Mutant Result
unquoted for login in $REFUSING_LOGINS 1 failed
rename REFUSING_LOGINS 1 failed
remove the unlisted-login warning 1 failed
tighten PROVENANCE to the whole line 1 failed

36 tests. pixi run style 10.00/10.

Not taken

The poll's "a review arrived" test still disagrees with the script's "a review counts" test, so a DISMISSED review can short-circuit the wait for a real one. Real, but it is a workflow-timing change rather than a verdict change, and it belongs with the pull_request_review: trigger I already deferred.

One thing worth its own issue

shellcheck is pinned in pyproject.toml and wired into no task and no CI job, so it never runs. It would not have caught this particular glob (it accepts word-splitting in a for), but a linter that is installed and never executed is the same shape as a reviewer that answers without reviewing. I will file it separately rather than expand this PR.

@blooop
blooop merged commit 9392779 into main Aug 24, 2026
15 checks passed
@blooop
blooop deleted the fix/self-review-satisfies-the-gate branch August 24, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant