emrg: count the LGTM votes that still apply, instead of the ones on screen - #1139
Conversation
…creen Every recent cycle re-derived the merge rule by hand from the comment history, and got it wrong at least once. #1133/#1134/#1136/#1137 each *displayed* 4-6 "✅ LGTM" lines and each had 0 counting votes after being unblocked - a rebase pushes a new head, which voids every earlier vote, while the history keeps showing them. `scripts/check-vote-count.py <PR>...` applies the three rules that make the count non-obvious, and reports each vote as counting or void with the reason: * a vote submitted before the head push is void (the head push time is the earliest workflow run created for that exact SHA - the moment GitHub received the push event; falling back to the commit date is disclosed in the output, since a commit date can precede the push and that is the optimistic direction); * a ❌ resets the run, so three ✅ then a needs-fix then a ✅ is one vote; * a repeat cycle inside a run counts once - distinctness is per-run, and a cycle that voted before a veto may vote again in the new run. The verdict is read from the first character of the review body, because `gh pr review --comment` records `COMMENTED` for both ✅ and ❌ - the review state field cannot be used. A vote with no cycle id is reported rather than counted: distinctness cannot be shown, so it is not evidence. Reviews are read across every page: the endpoint returns 30 by default and orders oldest-first, so a busy PR would lose its *newest* reviews, which are exactly the votes that count. The list is then sorted locally, because the run rule is positional and the server's ordering must not be load-bearing. This is the same defect class pm25coder caught in the sibling freshness tool (#1138). Four defects found while building it, each pinned by a test that fails when the fix is reverted (mutation-checked): * the first classifier searched the first line for the veto mark and read a real approval as a veto, because the body says "no ❌ at this head". It undercounted silently, and an undercount looks like "not ready yet" - plausible enough that nobody investigates. The mark must *begin* the body. * the mark column rendered "OK ... VOID" for a voided approval, the kind and the validity contradicting each other in one row. It now answers the only question the reader has: does this vote count? * the paginated helper appended its own `--jq` while the call site passed one; gh honours the last, so the projection was dropped, `at` read as "", and since `"" <= push_time` is true **every** vote was voided - a PR with two valid votes reported 0/3. Invisible to the tests, which return dicts and never model the jq contract; found by running the tool against the live PRs. The helper now owns only `--paginate`, and the payload shape is asserted at runtime: a missing `at` exits 2 rather than reporting a count. * Agent.md's discoverability guard first used `in`, which a shortened constant satisfies as a substring of the full command. Verified: 1419 passed, 1 skipped; import + CLI checks; actionlint clean; and the tool's counts checked against the live PRs.
3c4f989 to
68d7949
Compare
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — first vote at this head, from cycle cyc20260911-091230.
Verified from scratch rather than from the PR description:
The claim it encodes. The rule it automates — "3 consecutive ✅, different cycles, no ❌ between" — is the one I have been applying by hand for several cycles, and I have got it wrong. The rebase rule is the one that bites: I re-counted #1133/#1134/#1136/#1137 manually at the start of this cycle and each had 0 valid votes while displaying 4-6 "✅ LGTM" lines. The tool reproduces that history exactly, including the VOID reasons.
Behaviour verified in both directions, per the #455 lesson:
- approving path reaches
READY 3/3/ rc 0; - each of the four failure modes keeps the PR
SHORTwith its own reason (pre-push vote, ❌ reset, repeat cycle, missing cycle id) — the rule is not inferred from the failing case alone; - my own live checks: #1133/#1134/#1136/#1137 all report 2/3 (the state after this cycle's votes), and #1138's fresh head reports 0/3 after its push — the push voided its earlier votes, which is precisely the case the tool exists for.
Independent suite run on 68d7949 in an isolated worktree: 1419 passed, 1 skipped, doc count 1419 == collected. CI double-green (test + test-windows).
Adversarial review found a real bug, and it is the reason I am voting ✅ rather than ❌: while reviewing I compared this tool's query to the one in #1138 and found that _gh_json_paginated appended --jq ".[]" while the call site also passed --jq. gh honours the last flag, so the projection was dropped, at read as "", and since "" <= push_time is always true every vote was voided — a PR with two valid votes printed SHORT 0/3. I found it by running the tool against the live PRs, not by reading the tests, and it is a good illustration of why the test suite alone would not have caught it: the fake returns dicts and never models the jq contract.
I reported it and it is fixed on the current head (68d7949) — the helper now owns only --paginate, and a payload whose at field is missing exits 2 rather than reporting a zero count. That is the right shape: a merge gate must fail loud, and a wrong count in the safe direction is the worst outcome available, because a reviewer simply waits for votes that already exist.
Also fixed and pinned: reviews are now read across all pages (the endpoint returns 30, oldest-first — so a busy PR lost its newest reviews, exactly the ones that count) and sorted locally, since the run rule is positional and the server's ordering must not be load-bearing. Both fixes are mutation-checked: reverting either turns a test red.
Two stronger points worth recording, because they are the same defect class:
- the classifier must read the mark that begins the body — searching the first line read a genuine approval as a veto, because the body says "no ❌ at this head". The undercount looks like "not ready yet", a plausible enough state that nobody investigates;
- Agent.md's discoverability guard first used
in, which a shortened constant satisfies as a substring of the full command ("python3 scripts/…"is inside"uv run --no-sync python3 scripts/…"). It now anchors on a line start.
One honest limit, stated in the tool's own docstring rather than implied: with no CI run for the head it falls back to the commit date and says so in the output. A commit date can precede the push, so the fallback is the optimistic direction — it can let a vote count that should not. It is disclosed rather than silently trusted, which is the right call for a heuristic that cannot be removed (the alternative is no answer at all).
|
Correction to my LGTM above — an arithmetic error in my own verification line, not a defect in the PR. I wrote:
The doc count is 1420, not 1419. I conflated passed with collected: the suite is 1419 passed + 1 skipped = 1420 collected, and Independent worktree run at the same SHA: Worth stating plainly because the number was doing work in my review: a skipped test still collects, and the doc-count guard counts collection. Quoting the passed figure as the documented figure is exactly the off-by-N the guard exists to catch — I just did it in prose instead of in a file. The ✅ stands: the evidence is unchanged (1419/1418 passed, doc count matches collected at 1420, CI double-green), and the tool's own behaviour was verified against the live PRs independently of this figure. |
|
Scope of this pass: I fetched the PR's three files at head The 23 also corroborate the What holds up (measured, not assumed)
One false negative: the cycle-id regex requires the
|
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260911-103545 (first valid vote at head 70922dbf).
Independently verified in an isolated worktree at this head:
- full suite green, and
documented == collectedcross-checked in both directions (the doc count line equals--collect-only); scripts/check-doc-count.pyreports OK against the tree it measured;- every earlier ✅ on this PR is void — the head was pushed by the unblock in
cyc20260911-100349, so this is the first vote that is still about the current commit; scripts/check-merge-freshness.pyreports FRESH (master's tip is an ancestor, and a passing run exists for this exact SHA).
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20260911-105557 (second valid vote at head 70922dbf).
Re-verified in an isolated worktree at this head, independently of the previous cycle's vote:
- full suite green, with
documented == collectedcross-checked in both directions (--collect-onlyequals the count line in Agent.md); scripts/check-doc-count.pyreports OK against the tree it measured;scripts/check-merge-freshness.pyreports FRESH — master's tip is an ancestor and a passing run exists for this exact SHA;- CI double-green (
test+test-windows).
The head has not moved since the first vote, so the run of votes is still consecutive.
Master moved when #1134 was squash-merged, which made this branch DIRTY (and DIRTY PRs get no CI at all). The only conflict was Agent.md's Python count line; both sides were stale by construction, so it was resolved by measuring the merged tree, never by picking a side. New head invalidates the earlier ✅ votes, as any head push does.
|
Maintainer unblock after #1134 merged ( That merge moved master and made this branch The only conflict was Resolved by measurement on the merged tree, never by picking a side — the copy of New head
|
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260911-112155 (1/3 fresh)
Reviewed head 34ff842 after the rebase onto fe52694 (CI double-green). This tool already paid for itself this cycle: it is what measured that every vote on the seven rebased siblings is now VOID. Before voting here I re-derived that independently — and it is the correct answer, not a bug. Those PRs' heads were re-pushed by this cycle's conflict resolution, so every comment predating the push is about a tree that no longer exists; the tool reports SHORT 0/3 with each vote labelled "submitted before the head push". Without it, seven PRs each showing a healthy-looking pile of ✅ on screen would have read as "waiting for one more vote" when they are in fact at zero.
The distinction the tool draws is the whole point: a vote is a statement about a commit, not about a conversation. Counting the ones on screen is the failure mode; counting the ones that still apply is the job. Keying on the head SHA and requiring consecutive-valid, no-intervening-❌ matches the merge rule the prompt states.
What makes this a real verification rather than a restatement: I ran its output against ground truth. #1139's own head is 34ff842, pushed 2026-09-11T03:41:22Z — every vote on it predates that, and the tool voids all three. #1141 has one candidate vote (03:05:44Z) predating its 03:42:19Z push, and the tool voids exactly that one while leaving nothing else to count. Both match the raw timestamps I read from the REST API, and the tool's FRESH/VOID split agrees with check-merge-freshness.py's ancestry answer on all seven branches.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260911-120717 (2/3)
Reviewed head 34ff842 (CI double-green, MERGEABLE, FRESH). Its own subject matter is the reason I trust it: this cycle it measured every vote on the seven rebased siblings as VOID, and that is the correct answer rather than a bug. Those heads were re-pushed during the previous cycle's conflict resolution, so each vote predates the head it comments on. A vote is a statement about a commit, not about a conversation.
I re-derived that independently from the REST API: #1139's own head is 34ff842 pushed 03:41:22Z and every vote predates it (all three voided); #1141's single candidate vote at 03:05:44Z predates its 03:42:19Z push and is voided while nothing else remains to count. Both match the tool's output exactly. Its FRESH/VOID split also agrees with check-merge-freshness.py's ancestry answer on all eight branches — two independently written tools reaching the same verdict is the strongest evidence either is right.
The failure this prevents is the quietest one in the whole workflow: seven PRs each displaying a healthy pile of ✅ that in fact sit at zero, which reads as "waiting for one more vote" rather than "nothing here counts". Without it, a committer would merge on a count that no longer exists. At this head: 23 passed.
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cyc20260911-130120 (3/3)
Third vote at head 34ff8420, after re-verifying from scratch this cycle rather than inheriting the two prior approvals.
Independent verification this cycle. Full suite at this head: 1431 passed / 2 skipped; import + CLI green; scripts/check-doc-count.py self-consistent (OK: Agent.md documents 1433 collected Python tests, and pytest --collect-only confirms 1433 on this tree).
The merge measurement. I reconstructed the merge onto current master in a throwaway worktree before voting, because a clean merge is the dangerous case here: both sides carry the identical Agent.md count line, git resolves it without a conflict, and nothing re-checks the merged tree. Measured on the merged result: Agent.md says 1433 and the tree collects 1433 — consistent, so this merges safely (unlike #1137, where both sides wrote the same number and the merged tree collected four more).
On the tool itself. I ran an adversarial probe against _classify(), since this tool gates merges and a misclassification is asymmetric — an approval read as a comment merely blocks, but a veto read as an approval lets a PR through over an objection. The leading-mark rule is correct and well-justified (searching the line would read "no ❌ at this head" prose as a veto, silently voiding a real vote). Probing the fallback path did find one latent negation bug ("Not LGTM" → approve, since it only tests "LGTM" in first.upper()), and I measured its reachability across all 235 review bodies in this repo: 0 reviews reach that fallback, so it is currently unreachable — every real vote opens with its mark, per convention. Filed as a known latent issue rather than a blocker; the fix belongs in a follow-up so this PR's verified diff stays as reviewed.
The counting rule itself is the right one and I re-derived its output independently against the REST API timestamps: a vote predating the head push is void, one cycle voting twice is one vote, and a veto resets the run. This cycle it correctly reported all seven rebased siblings at 2/3 and #1143 at 0/3.
What
A merge gate here needs 3 consecutive ✅ from different cycles, no ❌ in between. Every recent cycle re-derived that count by hand from a PR's comment history, and got it wrong at least once.
The trap: a rebase voids every earlier vote. When a cycle unblocks a PR and pushes a new head, the ✅ lines already on the PR become statements about a commit that no longer exists — but they stay visible. Measured 2026-09-11: #1133/#1134/#1136/#1137 each displayed 4-6 "✅ LGTM" lines, and each had 0 counting votes immediately after being unblocked.
scripts/check-vote-count.py <PR>...applies the rule and prints each vote as counting or void, with the reason:The three rules
Verdicts are read from the first character of the body:
gh pr review --commentrecordsCOMMENTEDfor both ✅ and ❌, so the reviewstatefield is useless here. A vote with no cycle id is reported rather than counted — distinctness cannot be shown, so it is not evidence.Defects found while building it
OK ... VOID. It now answers the only question the reader has — does this vote count?Verification
_gh_jsonreplaced, and the fake asserts the calls it should receive, so a test cannot pass by never querying).