Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 57 additions & 13 deletions scripts/cast-vote.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,15 @@

Exit codes
----------
0 the review is posted and `check-vote-count.py` counts it
0 the review is posted and `check-vote-count.py` counts it. A **veto** counts
here too: the counter reads a counted veto as `NO ... counts - resets the
run`, i.e. as a verdict that resets the run rather than as a vote for the
PR, and it is neither a lost vote nor a reason to re-post
1 the review is posted and does NOT count — the vote was spent for nothing;
the counter's own reason is printed, because the remedy depends on it
the counter's own reason is printed, because the remedy depends on it. The
counter never showing the review at all is reported separately, as
**unmeasurable** rather than as a wrong vote: the review is on GitHub and
cannot be un-posted, so the reader re-reads before spending it
2 nothing was posted: the body has no cycle id (or more than one), `--cycle`
disagrees with it, this cycle already has a counted vote here, or `gh`
failed — fail loud, and never report a posted vote for a review that was
Expand Down Expand Up @@ -229,13 +235,27 @@ def confirm(
attempts: int,
delay: float,
mergeability_wait: float = 0.0,
) -> tuple[bool, str]:
) -> tuple[str, str]:
"""Read the counter back until this cycle's review is visible, then judge it.

Returns the state the counter reported — `"counted"`, `"veto"`, `"void"` or
`"none"` — with a note, because three of those are definite answers with three
different remedies and the fourth is the absence.

A retry exists for one reason: GitHub registers a review a moment after the
POST returns, so a single read can miss it and report "never appeared" for a
review that did arrive. The retry is bounded, and a *definite* answer (counted,
or void with a reason) returns immediately — only the absence retries.
a counted **veto**, or void with a reason) returns immediately — only the
absence retries.

A veto belongs in that list and was missing from it. The counter reads a
counted veto as `NO ... counts - resets the run`, i.e. `counted=True`, so
`_state_of` answers `"veto"` — but this function short-circuited only on
`"counted"` and `"void"`, so a veto that *had* registered fell through the
retry loop and was reported as a review that never appeared, with the advice
to spend the vote (measured 2026-09-17, `cyc20260917-043948`, on #1303 and
#1305: this tool said "not counted", the counter said `counts - resets the
run` when re-read minutes later).

`mergeability_wait` is passed through for the other transient: a read that
raises because GitHub has not computed mergeability yet is not a definite
Expand All @@ -249,10 +269,15 @@ def confirm(
)
state, why = _state_of(verdict, cycle)
if state == "counted":
return True, f"{verdict.valid_count}/{verdict.needed} valid votes"
return "counted", f"{verdict.valid_count}/{verdict.needed} valid votes"
if state == "veto":
return "veto", (
f"the veto is on the record at head {verdict.head_sha[:8]} and the "
"counter reads it as `counts - resets the run`"
)
if state == "void":
return False, why
return False, (
return "void", why
return "none", (
f"the review never appeared in the counter's reading of #{pr} after "
f"{max(1, attempts)} attempt(s) - posted, but not readable as a vote"
)
Expand Down Expand Up @@ -352,22 +377,41 @@ def main(argv: list[str] | None = None) -> int:
)
return 2

counted, note = confirm(
state, note = confirm(
args.pr,
cycle,
args.min_votes,
args.attempts,
args.delay,
args.mergeability_wait,
)
if counted:
if state == "counted":
print(f"#{args.pr}: review posted as {cycle} and counted - {note}")
return 0
if state == "veto":
print(
f"#{args.pr}: review posted as {cycle} and counted as a VETO - {note}\n"
"A veto is not a lost vote: it is on the record and it resets the run, so "
f"#{args.pr} now needs three consecutive LGTMs from other cycles. "
"Re-posting contributes nothing - re-read it with "
f"scripts/check-vote-count.py {args.pr}."
)
return 0
if state == "void":
print(
f"#{args.pr}: review POSTED and NOT counted - {note}\n"
"The vote was spent for nothing. Nothing is rolled back by re-posting: a "
"second review from this cycle contributes nothing either, so fix the body "
"and let a later cycle vote.",
file=sys.stderr,
)
return 1
print(
f"#{args.pr}: review POSTED and NOT counted - {note}\n"
"The vote was spent for nothing. Nothing is rolled back by re-posting: a "
"second review from this cycle contributes nothing either, so fix the body "
"and let a later cycle vote.",
f"#{args.pr}: review posted, and the counter never showed it - {note}\n"
"That is unmeasurable, not a verdict: the review is on GitHub and cannot be "
"un-posted, and it can register after this tool's bounded retries. Do not "
"spend it and do not re-post - re-read the counter first "
f"(scripts/check-vote-count.py {args.pr}) and act on what it says.",
file=sys.stderr,
)
return 1
Expand Down
69 changes: 66 additions & 3 deletions tests/test_cast_vote.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,9 @@ def test_a_posted_review_the_counter_does_not_count_exits_1(mod, monkeypatch, ca
"""The one state the caller cannot detect alone: rc 0 from gh, no vote.

Exit 1 rather than 0, because "posted" is what every signal at the call site
says, and the counter's reason is printed: a veto, a repeat vote and a
pre-push review are three different situations with three different remedies.
says, and the counter's reason is printed: a repeat vote and a pre-push review
are different situations with different remedies. A *veto* is not one of them —
a counted veto is registered, so it exits 0.
"""
void = vote(counted=False, why="submitted before the head push (2026-09-15T18:13:26Z)")
counter = FakeCounter(verdict_with(), verdict_with([void], counted=[False], valid_count=1))
Expand All @@ -302,6 +303,51 @@ def test_a_posted_review_the_counter_does_not_count_exits_1(mod, monkeypatch, ca
assert "submitted before the head push" in err


def test_a_counted_veto_is_reported_as_registered_not_as_spent(
mod, monkeypatch, capsys, body_file
):
"""A veto counts as a verdict that resets the run, not as a vote for the PR.

The counter reads a counted veto as `NO ... counts - resets the run`, so
`_state_of` answers `"veto"` with `counted=True`. `confirm` used to
short-circuit only on `"counted"` and `"void"`, so a veto that had registered
fell through the retry loop and came out as a review that never appeared —
exit 1 with "the vote was spent for nothing" for a vote that was on the record
(measured 2026-09-17, `cyc20260917-043948`, on #1303 and #1305).

The read count is asserted too: a definite answer must return on the first
confirm read, not after the attempts are exhausted.
"""
counter = FakeCounter(
verdict_with(),
verdict_with([vote(kind="veto", counted=True)], counted=[True], valid_count=0),
)
gh = FakeGh()
monkeypatch.setattr(mod.time, "sleep", lambda _s: None)
rc = _run(
mod,
monkeypatch,
counter,
gh,
[
"1255",
"--body-file",
body_file(f"{CYCLE} — ❌ needs fix"),
"--attempts",
"3",
"--delay",
"0",
],
)
out, err = capsys.readouterr()
assert rc == 0, "the veto is on the record - nothing was lost"
assert "VETO" in out
assert "resets the run" in out
assert "spent for nothing" not in err
assert len(counter.calls) == 2, "1 pre-flight + 1 confirm read, then it returns"
assert len(gh.calls) == 1, "the review was posted exactly once"


def test_a_failed_post_exits_2_and_never_reads_the_counter(mod, monkeypatch, capsys, body_file):
"""A `gh` failure is not a vote, so it must not be confirmed as one.

Expand Down Expand Up @@ -393,7 +439,16 @@ def test_the_settle_retry_re_reads_until_the_review_appears(mod, monkeypatch, ca


def test_a_review_that_never_appears_is_reported_not_guessed(mod, monkeypatch, capsys, body_file):
"""Bounded retries, then a loud "not readable as a vote" - never a silent 0."""
"""Bounded retries, then a loud "unmeasurable" - never a silent 0, and never "spent".

The absence is a *different state* from a void vote, and only its wording separates
them: both exit 1, and `confirm`'s note is the same either way, so the printed verdict
is the only thing telling a reader "do not re-post, re-read" apart from "the vote was
spent for nothing". Measured 2026-09-17 by an outside reviewer: replacing this branch's
message with the void branch's left `tests/test_cast_vote.py` at 18 passed, i.e. the
separation had no assertion behind it. Both halves are asserted here — the word that
means unmeasurable, and the verdict it must not be confused with.
"""
counter = FakeCounter(verdict_with())
gh = FakeGh()
monkeypatch.setattr(mod.time, "sleep", lambda _s: None)
Expand All @@ -408,6 +463,14 @@ def test_a_review_that_never_appears_is_reported_not_guessed(mod, monkeypatch, c
assert rc == 1
assert len(counter.calls) == 4, "1 pre-flight + 3 confirm attempts"
assert "never appeared" in err
assert "unmeasurable" in err, (
"the absence has to be reported as unmeasurable, not as a verdict - the review is "
"on GitHub and cannot be un-posted"
)
assert "spent for nothing" not in err, (
"this is the `void` branch's verdict, and the `none` state is not a spent vote: "
"re-collapsing the two leaves a reader re-posting instead of re-reading"
)


def test_dry_run_posts_nothing(mod, monkeypatch, capsys, body_file):
Expand Down
Loading