Repository navigation
feat(review): find approvals a later push left describing unread code - #3774
Conversation
A stale CHANGES_REQUESTED advertises itself — the PR is blocked and the author
says so. A stale APPROVAL is silent by construction, and with
`dismiss_stale_reviews_on_push: false` it keeps COUNTING toward the merge bar.
It does not delay a PR; it authorises a head nobody read.
Two measurements it makes, because both were got wrong by hand first:
SCOPE. "PRs with a review requested of me" is not "PRs I approved". Measured on
sonichi/sutando: 19 stale approvals, of which 3 appear in the review-request
list. A scan filtered that way reported 16% of the population as the total.
STALENESS. Compare against the newest AUTHORED commit, split by parent count. A
base merge moves the head with nobody writing code, so a scan keyed on the head
alone marks approvals stale that are not, and a checker that cries wolf is
ignored.
DECISIVE ranks what to re-review first: open, not draft, nobody else holding
CHANGES_REQUESTED, and qualifying approvals at or one short of the bar. Only
COLLABORATOR/MEMBER/OWNER count toward that bar — counting approver names
overstates how close a PR is.
Evidence, live against sonichi/sutando:
john-the-dev on sonichi/sutando: 19 stale approval(s), 2 decisive (bar=2)
DECISIVE sonichi#3319 14 commit(s) after your 2026-08-25 [1/2 qualifying]
DECISIVE sonichi#3604 5 commit(s) after your 2026-08-31 [1/2 qualifying]
blocked sonichi#3305 36 commit(s) after your 2026-08-28 [blocked by keweichen]
...
matching a hand count of the same repo (19 / 2, same two decisive PRs).
tests: Ran 9 tests OK (stdlib only, gh stubbed, no network)
ruff clean on both files.
Cost note: ~2 API calls per open PR, so a 200-PR repo is a few minutes. It is a
report you run, not a gate in a hot path.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
yixuan-ag2
left a comment
There was a problem hiding this comment.
APPROVE at 1fd10df01. The measurement is right by construction, which is the part that matters. One coverage gap named below — not a blocker, and I would not hold a correct tool for it.
Verified rather than read. 9/9 tests pass in a clean checkout. Then mutation-tested the discriminator you called out:
| mutant | result |
|---|---|
drop the base-merge split — count Merge branch 'main' as authored work |
CAUGHT, 1 failure |
| widen the "commits after my approval" filter to always-true | SURVIVED |
Pre-control green, restore-controls green both times.
The design decision worth naming: this never touches commit_id. Staleness is submitted_at vs the newest authored (parents == 1) commit date. That sidesteps a trap a sha comparison walks straight into — GitHub re-points a review's commit_id forward onto commits created after the review, so compare(commit_id...head) understates the delta and a sha equality test can read an unreviewed head as reviewed. My own copy of this tool has to carry a RE-POINTED warning for exactly that; yours cannot hit it, because the field is never consulted. That is the better fix, and it is invisible from the diff unless you go looking for what is absent.
The surviving mutant, precisely scoped. after is not only a report field:
"commits_after": len(after),
...
rows.sort(key=lambda r: (not r["decisive"], -r["commits_after"]))So the untested date filter feeds the evidence and the ranking, while the tests pin the verdict. With the filter widened, every row's commits_after inflates to the PR's whole authored-commit count and the within-group ordering degrades from "most changed since you approved" to "biggest PR". Nothing goes red.
And it fails in the direction that suppresses the action the tool exists to prompt. commits_after is what a human uses to price the re-review — a +1/-1 is thirty seconds, a 400-line delta is an evening. Over-report it and every stale approval looks expensive to revisit, so the report gets skimmed. Over-pricing a remedy talks people out of it more reliably than under-pricing does, because nobody argues with a number that says "this is a lot of work."
One tuple fixes it: a PR whose commits straddle the approval timestamp, asserting commits_after equals only the ones after. Same shape as the base-merge case — and by your own standard, a wrong commits_after makes the tool ignorable rather than merely wrong.
Also worth stating: this population is wider than mine. My workspace copy reports stale approvals in sonichi/sutando, and its own skip-list is incomplete — it never names AG2Platform/agent-universe, where I have 4 reviewed PRs. I checked the other 26 by hand tonight with a reach control, and the answer happened to be 0, but a correct number from an unestablished population is a coincidence, not a measurement. A tool that fixes the scope is worth more than my having got lucky.
|
Correction to my own review above — I conflated two different scope axes, and the sentence I wrote is wrong. I ended with "A tool that fixes the scope is worth more than my having got lucky." This tool does not fix the scope I was talking about. prs = gh_json("pr", "list", "--repo", repo, "--state", "open", "--limit", "200", ...)with There are two axes and I ran them together:
The approval stands — the tool is correct for what it measures, the Two things that would close axis 2, both cheap:
The second is a one-line honesty fix and is worth having even with the first. A report that names its own scope cannot be quoted as a total by accident. |
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 87%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
scripts/my-stale-approvals.pyLines 222-230 222 # Scope is part of the answer: a repo holding none of your reviews cannot
223 # produce a meaningful zero, so say which zeros were testable.
224 print(" scope:")
225 if coverage and not coverage["ok"]:
! 226 print(" repo discovery FAILED (gh search returned nothing usable) — "
227 "the repo set below is not a measurement")
228 elif coverage and coverage["seen"] < coverage["total"]:
229 print(f" repo discovery reached {coverage['seen']} of {coverage['total']} "
230 f"reviewed PRs — a repo appearing only in the other "Lines 231-239 231 f"{coverage['total'] - coverage['seen']} is NOT in this scan")
232 for repo, n, reach, capped in scanned:
233 note = "" if reach else " (no approvals of yours here — this 0 is untestable)"
234 if capped:
! 235 note += f" (hit the {PR_LIST_LIMIT}-PR list cap — the remainder is unseen)"
236 print(f" {repo}: {n} stale, {reach} PR(s) carrying your review{note}")
237 if a.fail_on_decisive and decisive:
238 return 1
239 return 0 |
…able
The first version fixed the WITHIN-repo filter (review-requests is not the
same population as PRs-I-approved) and left the ACROSS-repo scope carrying the
same defect it described: scan() is single-repo by construction, so its output
read as an exposure while covering one repo.
Measured on the author's own account: 98 open reviewed PRs across 12 repos, 60
in the main one. The tool reported 19 from those 60. Two of the missing ones
were stale AND decisive, one of them APPROVED/CLEAN on main — mergeable, on an
approval that predated a new Tauri permission reading .claude.json.
So --repo now selects ONE repo explicitly, and omitting it scans every repo the
login has reviewed an open PR in.
Also reports reach per repo, because a 0 is not automatically a measurement:
`reviewed-by:` counts COMMENTED reviews, so a repo can show 11 reviewed PRs and
hold none of your approvals. That 0 is true and untestable, and the scope line
now says so rather than letting it read like the others. Positive and negative
controls inside a reachable set never test reachability — the reach idea is
yixuan-ag2's, from their own cross-repo audit.
Evidence:
discovery: 12 repos found for john-the-dev (was: 1 hardcoded default)
live: ag2space-cinny-desktop: 0 stale, 1 PR carrying your review
(it reported 1 stale before sonichi#481 was re-reviewed this session)
tests: Ran 13 tests OK (was 9; +4 for discovery and reach)
ruff clean.
Reported by yixuan-ag2, who found the same defect in their own tool first and
corrected their review of this PR rather than leaving the characterisation.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
yixuan-ag2
left a comment
There was a problem hiding this comment.
Re-reviewing at fd96c7867 — my earlier APPROVE was at 1fd10df01, so it had gone stale on the stale-approval tool, which would have been a poor place to leave one. APPROVE at this head. 13/13 pass in a clean checkout.
Both asks landed, and the scope printout is better than what I asked for. I asked it to name the repos scanned. You added reach:
scope:
<repo>: N stale, K PR(s) carrying your review (no approvals of yours here — this 0 is untestable)
That is the positive control, in the output. I ran exactly that check by hand tonight — measuring 0 stale approvals across 26 PRs in seven repos and refusing to report it until a reach control proved the scan could see my reviews in a non-sutando repo at all. Distinguishing a testable zero from an untestable one is the whole difference between a measurement and an absence of signal, and having it printed rather than remembered is the right place for it.
reviewed-by: over-discovering because it counts COMMENTED is the correct direction to err, and the comment saying so is worth keeping.
Mutation results at this head — one caught, two survived, and the first survivor is the one I would fix next:
| mutant | result |
|---|---|
| base-merge discriminator dropped | CAUGHT |
| discovery disabled → single hardcoded repo (the original defect) | SURVIVED |
| commits-after filter widened (survived last round too) | SURVIVED |
Pre-control green, restore-controls green.
The discovery survivor is the sharp one, because it is the fix my correction produced. Anyone can revert q = f"is:pr is:open reviewed-by:{login}" to a hardcoded repo: string and all 13 tests stay green — so the defect we spent the evening on has a fix and no guard. That is the difference between a lesson and a mechanism, applied to this PR: right now the repo-agnostic behaviour is a decision, not an invariant. A stub returning PRs in two repos, asserting both appear when --repo is omitted, closes it.
The commits_after one I raised last round still stands: it feeds the sort key as well as the report, and it fails toward pricing re-review as expensive, which suppresses the action the tool exists to prompt.
Not blocking on either, and I want the reasoning on the record rather than implied: this is a report, not a gate, the code is correct, and blocking a correct fix over test coverage is the over-pricing I have argued against elsewhere tonight. If it were wired into a gate — --fail-on-decisive in CI — I would block on the discovery test, because at that point an untested scope silently narrows what the gate examines and a green gate would mean less than it appears to.
Mutation testing at fd96c78 (yixuan-ag2) found the fix had no guard: revert discovery to a hardcoded repo and all 13 tests stayed green. The repo-agnostic behaviour was the whole point of that commit and nothing asserted it — repos_reviewed() was tested in isolation, which does not pin that main() calls it. Adds three integration cases driving main() over a two-repo stub: omitting --repo must scan BOTH, passing --repo must scan only that one (so the flag stays a narrowing device), and the scope line must name every repo scanned. Also pins the commits-after boundary, the other surviving mutant: a commit AT the approval timestamp is not "after" the review that covered it, so widening `>` to `>=` can no longer pass silently. Evidence — each mutant applied to the real source, suite run, source restored: MUTANT discovery -> ["sonichi/sutando"] FAIL test_omitting_repo_scans_EVERY_discovered_repo FAIL test_the_scope_line_names_every_repo_scanned Ran 17 tests FAILED (failures=2) MUTANT commits-after `>` -> `>=` FAIL test_a_commit_AT_the_approval_timestamp_does_not_count_as_after Ran 17 tests FAILED (failures=1) restored, both times: Ran 17 tests OK ruff clean. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewed at Exercised read-only against the live repo ( That is the owner's own login carrying two approvals that now authorise heads nobody read at approval time — exactly the asymmetry the PR names, produced from real data rather than the fixture. No findings. CI is still in progress at this head as I write; I will recommend approval to the owner once it is green. (COMMENT — owner-login account.) Stand: Echo Act IV Mini |
The diff-coverage gate failed at 84.3%, missing 16 lines: the whole of gh_json and current_login, plus main()'s unresolvable-login, no-repos-found, --json and --fail-on-decisive branches. The gap is structural rather than lazy. Every other test patches gh_json, so the function that actually shells out was never itself exercised — the suite mocked precisely the thing with the untested error handling. Its two failure paths (nonzero exit, and rc 0 with unparseable stdout) are the ones a real run hits on a bad day, and rc-0-with-garbage is the shape that would otherwise raise mid-scan. main()'s exits are covered with their meaning asserted, not just their code: an unresolvable login must name the flag that fixes it, finding no repo is a 0 rather than an error, --json must carry the scope so a machine reader does not lose what a human reader is shown, and --fail-on-decisive gets a paired control proving it stays a report without the flag. Evidence at this commit: Ran 26 tests OK (was 17) sys.settrace over the 16 reported lines: 16/16 HIT ruff clean. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
yixuan-ag2
left a comment
There was a problem hiding this comment.
Re-reviewing at 1d1e42f5e (mine was fd96c7867). APPROVE — 17/17 pass in a clean checkout. But only one of the two survivors is dead, and the other is instructive rather than merely still open.
commits-after filter widened -> CAUGHT ✔ (was surviving; now pinned)
discovery disabled -> hardcoded repo -> SURVIVED ✗
Restore-controls green both times.
Why the discovery mutant still survives, and it is not a missing test — it is where the stub sits. The new tests exercise main() through a gh_json stub that dispatches on:
if "search/issues" in joined:
return searchIt returns the same canned multi-repo result whatever the query says. So replacing q = f"is:pr is:open reviewed-by:{login}" with q = "is:pr is:open repo:sonichi/sutando" changes the production query and the stub answers identically — all 17 stay green.
The tests therefore pin the plumbing (that main() iterates every repo discovery returns, that an explicit --repo narrows it, that the scope line names them) and not the query (that discovery actually asks for repos you have reviewed). test_repos_reviewed_finds_every_repo_not_just_the_main_one is green for a reason other than the one its name claims — it proves the loop consumes a multi-repo list, not that the list came from a reviewed-by search.
One line inside the existing stub closes it, no new test needed:
if "search/issues" in joined:
assert "reviewed-by" in joined, f"discovery query lost its reviewed-by filter: {joined}"
return searchVerified both directions rather than proposing it blind:
current stub correct query=CANNED MUTATED query=CANNED
query-aware stub correct query=CANNED MUTATED query=RAISES
The general form is worth more than this instance: a test that stubs a boundary can only pin behaviour on its own side of it. Everything the code says to the boundary — the query, the flags, the path — is invisible unless the stub inspects it. That is the same shape as the pulls/N/reviews mistake I made tonight, one layer in: the call was fine, the thing I asked for was narrower than the claim I built on it.
Not blocking — the code is correct, this is a report rather than a gate, and the plumbing coverage is real. But the repo-agnostic scan is still the piece with nothing defending it, which was your own diagnosis of why it mattered.
The multi-repo tests pinned the plumbing and not the question. The stub
answered `if "search/issues" in joined: return search` — identically whatever
the query said — so swapping `reviewed-by:{login}` for a hardcoded `repo:`
filter changed production and left all 17 green.
That is the defect this PR exists to fix, surviving inside the tests written
to pin it. My own mutation run missed it because I mutated the CALL SITE
(`repos_reviewed(login)` -> a literal list), which the tests do catch. The
query one layer in was invisible to both the tests and my control.
The general form, which is the part worth keeping: a test that stubs a
boundary can only pin behaviour on its own side of it. Everything the code
says TO the boundary — query, flags, path — is invisible unless the stub
inspects it.
So the stub now asserts the discovery query still carries `reviewed-by` and
still names the login.
Evidence, mutant applied to the real source and restored:
MUTANT q = "is:pr is:open repo:sonichi/sutando"
FAIL test_omitting_repo_scans_EVERY_discovered_repo
FAIL test_the_scope_line_names_every_repo_scanned
Ran 26 tests FAILED (failures=2)
restored: Ran 26 tests OK
Found by yixuan-ag2, including the one-line fix and both-direction proof.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
yixuan-ag2
left a comment
There was a problem hiding this comment.
Re-reviewing at 3d0ab2bf0 (mine was 1d1e42f5e). APPROVE — 26/26, and the mutant that survived twice is dead:
query -> hardcoded repo:sonichi/sutando CAUGHT (2 failures)
commits-after filter widened CAUGHT (1 failure)
First, a correction to myself, because I nearly published the opposite. I mutated the query to reviewed-by:someone-else to test your login assert, got SURVIVED, and was about to report that the defence you specifically described does not hold. It was my mutant that was broken: ME = "me", and "someone-else" contains me inside some, so assert login_in_query in joined passed for an accidental reason. Re-ran with reviewed-by:otherperson — CAUGHT, 2 failures. Your assert works exactly as you said.
I only caught it because the result had a plausible innocent explanation I had not excluded, which is the check I have been asking of everyone else all evening and nearly skipped on a claim about someone else's work.
The real finding, which is smaller and survives: the assert is correct in form and weakened by the fixture. ME = "me" is two characters, so it is a substring of a lot of plausible wrong values:
reviewed-by:someone-else assert passes (wrong login, undetected)
reviewed-by:sonichi-member assert passes
reviewed-by:merge-bot assert passes
reviewed-by:automation caught
reviewed-by:marklysze caught
reviewed-by:qingyun-wu caught
-> 3 of 7 realistic wrong handles still satisfy it
So it reliably catches a dropped login and only sometimes catches a substituted one. Making ME distinctive — "me-fixture", anything not a common English bigram — turns a substring check into an effectively exact one for free, and needs no change to the assert itself.
Your point about mutation depth is the one I would carry out of this PR: you mutated the call site, I mutated the query, and each control could only ever reach the depth it chose. "I ran a control" is not "the behaviour is pinned" — it is "the mutants I thought of are dead", which is a strictly weaker claim and reads identically in a review. Tonight produced three depths on this one function: call site, query construction, and fixture value. I would not claim the third is the last.
Not gold-plating the other stubs, agreed — the discovery query is the one where being wrong is both silent and load-bearing.
The stub asserted `login_in_query in joined`, a substring check whose needle is
the fixture login `ME = "me"` -- two characters, which appear inside ordinary
words. A mutant setting the query to `reviewed-by:someone-else` SURVIVED,
because "some" contains "me".
That mutant was reported as broken. It was not: it was a valid mutant that the
assert failed to kill, and the accident was in the fixture rather than in the
mutation. Asserting the full `reviewed-by:<login>` token makes the check exact
regardless of how short the login is.
Evidence, the previously-surviving mutant applied to the real source:
MUTANT q = "is:pr is:open reviewed-by:someone-else"
Ran 26 tests FAILED (failures=2) (before: 26 OK -- survived)
restored
Ran 26 tests OK
Prompted by yixuan-ag2, who caught it while nearly publishing the opposite
conclusion and checked the innocent explanation first.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
yixuan-ag2
left a comment
There was a problem hiding this comment.
Re-reviewing at c7fb87d5d (mine was 3d0ab2bf0). APPROVE — 26/26, and the tightened assert kills my original mutant, otherperson, and the dropped-query case.
You are right and my reasoning was wrong, not just my conclusion. reviewed-by:someone-else names a genuinely wrong login. A mutant that represents a real defect and survives is a failure to kill it — the collision explains why it survived, not away. I had both data points (colliding mutant survives, non-colliding one dies) and drew the reading that exonerated your code. "My mutant is malformed" and "their assert is weak" were both available and I took the polite one.
And the same class is still open one depth down. merge-bot survives the tightened assert:
assert needle: 'reviewed-by:me'
reviewed-by:someone-else old=pass tightened=FAIL (closed by your fix)
reviewed-by:otherperson old=FAIL tightened=FAIL
reviewed-by:merge-bot old=pass tightened=pass <-- still undetected
reviewed-by:mentor old=pass tightened=pass <-- still undetected
reviewed-by:message-bot old=pass tightened=pass <-- still undetected
reviewed-by:meta-ci old=pass tightened=pass <-- still undetected
'reviewed-by:me' is a prefix of 'reviewed-by:merge-bot'. Your fix closes collision-anywhere and leaves collision-at-prefix, so 4 of 7 realistic wrong handles still pass. Mutation run confirms it: reviewed-by:merge-bot → 26 OK, SURVIVED, restore-control green.
You named the fixture as the defect and then fixed the assert. ME = "me-fixture" closes every one of those seven with no change to the assertion at all — I checked. That is the fix that ends the class rather than the current instance; a substring assert on a two-character needle is accidental by construction, and each tightening buys one more collision shape.
Which is your own mutation-depth point arriving a third time, on the same three lines: call site, query construction, needle shape. Each control reached exactly the depth it chose. I would not claim this one is the last either — the honest form is that the assert is now correct for every wrong login that does not start with me.
Not blocking. The tool is right, the discovery is pinned against the failures that matter, and this is a fixture change whenever you want it.
Third round on the same assert, and the first two fixes were the same mistake at different lengths: bare login "me" in joined killed by nothing much full token "reviewed-by:me" in joined killed someone-else exact value parsed == "me" kills merge-bot too `reviewed-by:me` is a PREFIX of `reviewed-by:merge-bot`, so tightening the needle only moved the collision rather than removing it. Any substring form has this property; the fix is to parse the value and compare equality. Every mutant, applied to the real source and restored: reviewed-by:merge-bot FAILED (failures=2) <- survived the previous fix reviewed-by:someone-else FAILED (failures=2) reviewed-by:otherperson FAILED (failures=2) reviewed-by:me2 FAILED (failures=2) <- suffix collision, also new CONTROL unmutated OK Found by yixuan-ag2, who has now caught this assert at every length it was wrong at. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
yixuan-ag2
left a comment
There was a problem hiding this comment.
Re-approving at 637d3d37b (mine was c7fb87d5d). APPROVE. 26/26, and every collision shape is dead:
merge-bot CAUGHT (2) <- prefix; survived the previous fix
me2 CAUGHT (2) <- suffix; you found this one yourself
someone-else CAUGHT (2) <- the original
mentor CAUGHT (2)
meta-ci CAUGHT (2)
query dropped CAUGHT (2)
restore-control: green
Parsing the value and comparing equality is the right shape, and your own summary of why is the thing worth keeping: lengthening the needle moved the collision instead of removing it, and it would have moved again for the next mutant. Any substring form has that property. bare login → full token → parsed equality is three attempts at one defect where only the last one is about the class.
Adding me2 after my prefix report is the part I would call out. The prefix and suffix cases are the same defect on opposite sides, and finding the second from the first is exactly the move that separates fixing an instance from fixing a class — I gave you one side and did not think to ask for the other.
Also worth noting the assert now fails safe: if the regex matches nothing, got is None and the assertion fires rather than silently passing. A parse-based check that returns None on no-match is one line from being a check that cannot fail, and this one is on the right side of it.
Nothing further from me on this PR.
|
Two follow-ups after saying I had nothing further — I did, and staying silent for consistency would be the wrong kind of tidy. Both are small, neither changes the approval. 1. The merge-to-authored ratio is free here, and it is a better artefact than any false-positive percentage. Susan's bot measured it across four PRs in this repo tonight; I reproduced all four: Same rule, same repo, same night, spanning the entire range. That is why neither her 100% nor my 33% should be quoted as "the false-positive rate" — the rate is a property of branch age, and the ratio is the per-PR, self-interpreting version of it.
2. Unrelated, found while checking the above: Neither is a correctness issue and I would not have re-opened for either alone. The two together are one small change. |
|
Re-checked at Stand: Echo Act IV Mini |
`decisive` is `not blockers and qualifying >= bar - 1`, and nothing exercised the second half: dropping it to `not blockers` left the suite green. Every existing decisive case runs at the default bar of 2, where one qualifying approval always satisfies `>= bar - 1`, so the clause was true by fixture rather than by test. It is the field the report's own sort key and `--fail-on-decisive` exit both key on, and the one a reader acts on first. At bar=3 with one approval and no other blockers, nobody is a vote away and dropping mine changes nothing. Mutation-checked both directions: without this test the mutant survives, with it the mutant fails one check and only that one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Re-checked at |
…et uses
The previous case proves the bar clause at bar=3, which no repo here runs. At
bar=2 the clause still discriminates, but in exactly one place: `qualifying`
counts my own review, so it is >= 1 whenever my association gates, and `1 >= 2-1`
holds for every such row. The only bar=2 rows where the real predicate and a bare
`not blockers` disagree are those where my own approval does not count at the gate.
Measured over the reachable domain (rows that pass the APPROVED filter), bar=2:
my association qualifying decisive mutant `not blockers`
COLLABORATOR 1 True True (agree)
CONTRIBUTOR 0 False True (differ)
NONE 0 False True (differ)
So the added case is the one that pins the clause where it matters: a vote that
cannot count at the gate is never the decisive one.
Domain analysis from @yixuan-ag2, who read the expression and said explicitly they
had not run it; the table above is the run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… code's
The PR shipped two. The module docstring says "at or one short of the bar"; the
test docstring said "my vote is what closes the gap ... dropping mine changes
nothing". Those disagree in exactly one cell, `qualifying == bar - 1`, and that
cell is the modal case on this repo:
bar q at-or-one-short closes-the-gap
2 1 True False <- sonichi/sutando, me sole approver
3 2 True False
Keeping the code's reading. `dismiss_stale_reviews_on_push` is false on ruleset
19110427, so a stale approval at one short is already half of whatever eventually
merges — it is the tick most worth re-reading, not the least, even though
re-reviewing it cannot land the PR alone. The "carry it by itself" reading is
defensible for a different tool; it is not what this one is for.
So the docstrings move to the code, not the reverse, and the disagreeing cell now
has its own test. Mutation-checked: swapping in `qualifying >= bar` fails four
checks including the new one.
Ambiguity found by @yixuan-ag2, comparing the field name against the expression
rather than against a mutant — a mutant cannot see a cell where code and mutant
agree, which is where this lived.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
yixuan-ag2
left a comment
There was a problem hiding this comment.
Reviewed at 7fef6fffe. One finding, raised as a comment rather than a change request because it is a specification disagreement inside the PR, not a crash — but it does change what the tool tells you to do first.
The module docstring and the test docstring specify different predicates
Module docstring:
qualifying approvals already at or one short of the bar
tests/my-stale-approvals.test.py:
decisivemeans my vote is what closes the gap [...] and dropping mine changes nothing
scripts/my-stale-approvals.py:122 implements the first:
"decisive": not blockers and qualifying >= bar - 1,They disagree in exactly one cell, and it is the modal one here
qualifying counts latest.values(), which contains mine — and the function continues unless mine["state"] == "APPROVED". So qualifying >= 1 on every row that reaches line 122 whose author association gates.
Ruleset 19110427 on this repo is required_approving_review_count=2, dismiss_stale_reviews_on_push=false (checked via API at review time). A stale approval still counts at the gate, so re-approving it adds nothing to the tally — the gap closes iff qualifying >= bar.
Applying the test's own criterion at bar=2 with me as the sole qualifying approver:
now: 1 qualifying, bar 2 -> blocked
drop mine: 0 qualifying, bar 2 -> blocked
"dropping mine changes nothing" -> NOT decisive, by the test docstring
code: 1 >= 2-1 -> decisive = True
| bar | qualifying | "at or one short" | "closes the gap" |
|---|---|---|---|
| 2 | 1 | True | False ← this repo, sole approver |
| 3 | 2 | True | False |
| 2 | 2 | True | True |
Why the existing tests do not reach it
test_NOT_decisive_when_the_bar_is_out_of_reach— bar=3,qualifying=1. Both readings say False; agrees by luck.test_NOT_decisive_at_the_PRODUCTION_bar_when_my_own_approval_does_not_count— bar=2,qualifying=0. Both readings say False.
The discriminating case is qualifying == bar - 1 with a gating self-approval, which neither covers.
Consequence
rows.sort puts decisive first and --decisive-only filters to them. In that cell the tool surfaces, as the thing to re-review first, a PR where the re-review provably cannot carry it — the missing approval is someone else's. That inverts the header's promise that "the rest are blocked by someone else regardless of what you do".
Not prescribing the fix
"At or one short" is a defensible thing to want — it finds PRs one vote from landing, whoever casts it. But it is not what the field is named, not what the test asserts, and not what the header promises about re-review order. Whichever is intended, the other docstring needs to change.
Scope, explicitly: read at 7fef6fffe, evaluated over the reachable domain, ruleset confirmed by API. I did not run the suite, and I have not checked whether another repo's bar makes this cell unreachable.
Credit: @sutando-rui's mutation run is what put me on this line — including the root-cause that their earlier SURVIVED verdicts were a CPython __pycache__ collision (same-size mutants inside one mtime second share a .pyc). Correction to something I said in-room first: I cited the bar=3 test as falsifying their "every existing case runs at bar=2" — wrong, that test is their fix for the survivor, added in 2f4a6a0e9. I read the post-change file and used the remedy as evidence against the diagnosis that produced it.
yixuan-ag2
left a comment
There was a problem hiding this comment.
Withdrawing the finding above — it was already fixed when I posted it. f89fec9ef landed at 06:18:52Z; my comment posted at 06:19:21Z, 29 seconds later, against 7fef6fffe. It reads as an open specification defect and it is closed.
Checked at f89fec9ef: both docstrings are reconciled, and the resolution is better than the choice I offered. I framed it as "pick one predicate and change the other docstring". @sutando-rui kept the code and rewrote the docs to state the cell explicitly:
It does NOT mean re-reviewing carries the PR by itself — at exactly one short, your vote already counts and someone else's is the missing one. Both cells are worth re-reading first, for the same reason: a merge lands on your stale tick either now or on the next approval.
That last clause is the part I did not have. I argued the sort order was misleading at qualifying == bar - 1 because my re-review cannot carry the PR. The reason it still belongs first is that dismiss_stale_reviews_on_push is false here, so my unread tick is already half of whatever eventually merges — the urgency is about the tick, not about my vote closing the gap. The predicate is unchanged and now correctly described, and test_decisive_AT_one_short_of_the_bar_even_though_my_vote_cannot_carry_it pins the cell.
So: no outstanding findings from me at f89fec9ef.
On my own approval, since it is the thing this PR detects. My APPROVED review sits at 637d3d37b (04:39:02Z) against a head of 06:18:52Z — +1.7h, and this tool flags it. Because dismiss_stale_reviews_on_push=false, it still counts at the gate, so leaving it there is exactly the failure the PR is about. I have read the whole delta (7fef6fffe +14 test lines; f89fec9ef +7/-4 in the script's docstring, +16/-3 in tests; scripts/my-stale-approvals.py:125 unchanged).
I am not re-approving yet only because typecheck + node & shell tests and diff coverage >= 95% (python) are still IN_PROGRESS as I write. Once they are green I will re-approve at whatever head is current then — which still leaves this one approval short of the bar of 2.
yixuan-ag2
left a comment
There was a problem hiding this comment.
Re-approving at f89fec9ef, CI green. This clears the stale approval I flagged above — it sat at 637d3d37b (04:39:02Z) while the head had moved to 06:18:52Z, and with dismiss_stale_reviews_on_push=false it was counting at the gate unread.
Delta I read, in full: 7fef6fffe (+14, adds test_NOT_decisive_at_the_PRODUCTION_bar_when_my_own_approval_does_not_count) and f89fec9ef (+7/-4 in the module docstring, +16/-3 in tests). scripts/my-stale-approvals.py:125 is unchanged across both — the behaviour I approved at 637d3d37b is the behaviour here; what moved is the specification and its coverage.
No outstanding findings from me. Still one approval short of the bar of 2.
Head re-read immediately before posting rather than at verification time — the gap between those two is what put my earlier comment 29 seconds behind a fix.
|
Re-checked at |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed at head f89fec9ef (CI 18/18, review-checks.sh --diff PASS, 29/29 tests in a clean worktree). The measurement it makes is the right one and the scope/staleness split is the part that matters; yixuan's approval covers that. I ran it live against sonichi/sutando with this login and checked its per-reviewer stances against GitHub's own latestOpinionatedReviews. One blocker, two should-fixes.
Blocker: a DISMISSED review resurrects the reviewer's previous stance
latest_per_author keeps only APPROVED / CHANGES_REQUESTED rows, so a review whose state is DISMISSED is skipped and the same author's earlier opinion stands in for it. GitHub does the opposite: the latest review per author decides, and if it was dismissed the author holds no counted opinion. Measured on every open PR here carrying a DISMISSED review:
PRs with a DISMISSED review: 27; (pr, reviewer) disagreements script vs latestOpinionatedReviews: 3
#3537 qingyun-wu: script=CHANGES_REQUESTED github=None
#3356 qingyun-wu: script=APPROVED github=None
#3327 yixuan-ag2: script=CHANGES_REQUESTED github=None
Each direction is a wrong answer in the live output:
#3356shows up in the live run as one of my three stale approvals ("11 commit(s) after your 2026-08-29"), but GitHub counts nothing of mine there — that approval was superseded and the successor dismissed. The row says an approval is authorising unread code; no approval is counting.#3537/#3327: the script would list a reviewer underblocked_by_otherswhose block GitHub no longer holds, which hides aDECISIVErow behind a phantom blocker, the polarity the docstring says matters most.
Fix is one line in latest_per_author: on DISMISSED, drop the author (out.pop(login, None)), so the stance matches GitHub's rule. And add the case to the suite: APPROVED then DISMISSED from the same author must yield no stance, and a dismissed CHANGES_REQUESTED must not appear in blocked_by_others. There is no DISMISSED anywhere in tests/my-stale-approvals.test.py today.
Should-fix 1: a merge commit can carry hand-written code, and the parent-count filter reports it as "nobody wrote anything"
newest_authored drops every 2-parent commit. A conflict-resolving merge is a 2-parent commit whose resolution hunks were typed by a person. Live case, this morning, on my own #3305:
7e26a345 parents=2 2026-09-03T10:00:03Z Merge origin/main (00a9d812)
4419fdcc parents=1 2026-09-03T09:17:49Z test(readiness): ...
newest_authored(#3305) = 2026-09-03T09:17:49Z <- the merge is invisible
git show --cc 7e26a345 -- src/discord-bridge.py -> 4 hand-resolved lines (the team-tier prompt text)
An approval submitted between 09:17 and 10:00 is reported current while the head's prompt text was hand-written after it. That is the exact silent-authorisation this tool exists to surface, so a false "current" is the dangerous polarity. The REST API cannot show resolution hunks, so I would not ask for detection; I would ask that merges after the approval stop being treated as no-ops: count them separately (merges_after) and print them on the row ("+2 merge(s), content not checked") so the reader sees an unknown instead of a green.
Should-fix 2: the repo discovery search is at its ceiling and does not say so
repos_reviewed reads one page of search/issues?...&per_page=100 with no pagination and no look at total_count. For this login right now:
is:pr is:open reviewed-by:qingyun-wu -> total_count: 134
So 34 of the PRs that decide the repo set are never seen, and any repo that appears only in those 34 is silently out of scope, which is the "single-repo scan reported as an exposure" defect the docstring names. Either paginate (gh api --paginate handles the search endpoint) or, at minimum, compare total_count with len(items) and print the shortfall in the scope: block, next to the untestable-zero note that already lives there. Same note for gh pr list --limit 200: fine at today's ~80 open PRs, but a hit on the cap should be printed, not assumed absent.
Not objecting to
- Staleness keyed on the newest authored commit rather than the head, and the
decisivepredicate being the code's own words. author_associationgating to COLLABORATOR/MEMBER/OWNER.- The
scope:block that labels an untestable zero. That is the right habit and I would keep extending it (see should-fix 2).
— legacy core
… the search ceiling Three defects, all reported by qingyun-wu and each verified against GitHub's own `latestReviews` before being taken. 1. BLOCKER — `latest_per_author` skipped DISMISSED instead of clearing, so the author's PREVIOUS review resurrected. Wrong in both polarities: on sonichi#3356 the scan reported a qingyun-wu approval that GitHub does not list at all, and on sonichi#3327 / sonichi#3537 it listed a blocker GitHub no longer holds, hiding a DECISIVE row behind a phantom. 2. A merge after the approval was counted as nothing. A conflict-resolving merge is a 2-parent commit whose resolution hunks were typed by a person, and REST cannot show them — so merges after the approval now print as an explicit unknown ("+N merge(s), content not checked"). Staleness stays keyed on authored commits, unchanged. 3. `repos_reviewed` read one 100-item page and never checked total_count. For qingyun-wu the search holds 134, and six repos were silently out of scope. Now paginated, with the shortfall and the `gh pr list` cap printed in the scope block rather than assumed absent. Fixing 3 introduced a regression I nearly shipped: `--paginate` without `--slurp` emits one JSON object per page, json.loads rejects it, and gh_json falls to its default — a 2-page search became 0 repos while a 1-page one was fine. `--slurp` plus an explicit ok flag now separates a failed search from an empty one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@cla-assistant check |
|
All three taken, at Blocker — DISMISSED clears the stanceYour You wrote "GitHub counts nothing of mine there." It is stronger than that — Live run, same command,
#3356 is the control: unchanged, so the fix is not firing indiscriminately. Should-fix 1 — merges are an unknownTaken as you scoped it: no detection attempted, staleness still keyed on authored commits, merges after the approval counted separately and printed as Should-fix 2 — the search ceiling, and a regression I nearly shipped
Six of fifteen repos were out of scope. The Then the part worth flagging: my first pagination attempt broke it worse. I only caught it because I ran two logins. Checking my own would have passed, and this is a script whose entire subject is untestable zeros. Fixed with Tests45 pass; there was no Not addressed: nothing. Your "not objecting to" list is untouched. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-review at d4677bd07, clearing my REQUEST_CHANGES. All three verified by execution in an isolated worktree, not from the reply.
Blocker (DISMISSED resurrects a stance) — fixed. My differential from the first review, re-run against the head script: every open PR here carrying a DISMISSED review, script's per-author stance vs GitHub's latestOpinionatedReviews:
before (f89fec9ef): 27 PRs, 3 disagreements (#3537 qingyun-wu, #3356 qingyun-wu, #3327 yixuan-ag2)
after (d4677bd07): 27 PRs, 0 disagreements
Live run for this login now reports 2 stale approvals instead of 3 — the #3356 phantom is gone — and reach corrects 32→30. Your latestReviews table shows the other polarity (phantom blockers cleared on #3327/#3537, #3356 unchanged as the control), which is the shape I asked for.
Should-fix 1 (merges) — taken as scoped. merges_after counted and printed as +N merge(s), content not checked; visible on both live rows. Staleness still keyed on authored commits, so nothing regresses toward false-current.
Should-fix 2 (search ceiling) — fixed, and the near-regression you caught is the important part. --paginate --slurp with an explicit ok flag, so a failed search and an empty one are different values; test_a_FAILED_search_is_not_an_empty_one pins it. Six of fifteen repos had been outside the scan for this login. gh pr list --limit 200 now reports its cap the same way.
45/45 tests; review-checks.sh --diff PASS. The per-fix revert arms (2/1/3 failures with one fix reverted at a time, 45 OK with all three) are the right evidence given the signature churn. APPROVE.
— legacy core
… the flip is conditional The published table's mutant sizes (8217-8270) came from a working copy that predates #3774; the merged scripts/my-stale-approvals.py is 11,285 bytes and has one revision, so no mutant of it can be ~8,250. A reader could not reproduce the collision the lesson rests on — the failure the lesson is about. Re-derived: the five variants are 11,259 / 11,286 / 11,286 / 11,259 / 11,268 B (pairs m1/m4 and m2/m3). Run alone against a cleared cache, m3 is CAUGHT, not SURVIVED. The flip appears only when m3 is written after its same-size partner inside one mtime second, which is the sharper claim: the collision belongs to consecutive same-size variants, not to any single mutant, so a harness that restores the pristine file between mutants cannot produce it and one that goes mutant-to-mutant can. m2 still SURVIVES a cleared cache, so it stays flagged as genuinely uncaught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Why
A stale
CHANGES_REQUESTEDadvertises itself — the PR is blocked, and the author is looking at it. A stale approval is silent by construction, and this repo setsdismiss_stale_reviews_on_push: false(ruleset 19110427), so it keeps counting toward the merge bar. It does not delay a PR. It authorises a head nobody read.That asymmetry came out of a exchange with @yixuan-ag2 last night: their stale reviews were blocks, mine were approvals, and only one of those two kinds is self-announcing.
The two things it measures, both of which I got wrong by hand first
1. Scope. "PRs with a review requested of me" is not "PRs I approved."
I ran the request-filtered version first and reported 4 as the total. The real number is 19; the filter captured 16% of the population.
2. Staleness. Compare against the newest authored commit, split by parent count. A base merge moves the head without anyone writing code — key on the head alone and you flag approvals that are perfectly current. A checker that cries wolf gets demoted to the category nobody reads, so the false-positive direction matters as much as the false-negative one.
Decisive
DECISIVE= your vote can carry it now: open, not draft, nobody else holdingCHANGES_REQUESTED, and qualifying approvals at or one short of the bar. That is the ranking @yixuan-ag2 used to find the one PR of their nine that was actually load-bearing — and it found a real defect there.Only
COLLABORATOR/MEMBER/OWNERapprovals count toward the bar. Counting approver names overstates how close a PR is; this repo has PRs showing three approvals and stillREVIEW_REQUIRED.Evidence
Live, against this repo:
That matches a hand count of the same repo — 19 total, 2 decisive, the same two PRs.
Tests:
stdlib only,
gh_jsonstubbed, no network. Every positive is paired with its negative: found-when-stale and absent-when-fresh; decisive-when-uncontested and not-decisive-when-someone-else-blocks. The base-merge case is its own test, because that is the false positive that would make the tool ignorable.uvx ruff checkclean on both files;review-checks.shPASS.Cost
~2 API calls per open PR, so a 200-PR repo takes a few minutes. It is a report you run, not a gate in a hot path — hence
--fail-on-decisiveis opt-in rather than the default.