fix(scripts): a merged pull request is not a mergeability GitHub is still computing - #3233
Conversation
…till computing `check_merge_blockers.py --pr N` reported an already-merged pull request as `merge-state-unknown`, owed by nobody, with the remedy "Re-read the pull request: mergeability is computed on demand and settles on a later read". No later read settles it. A merged pull request is closed, so `mergeable` stays null permanently -- strands-labs#2586 still read `null`/`unknown` fourteen days after it squashed -- and the advice describes a wait with no terminating condition, in the reassuring direction: "no party owes an action" is literally true while the answer is "you already merged it". `mergeable is None` was ambiguous between "GitHub is still computing" and "there is nothing left to compute". Measured on 2026-09-05 right after strands-labs#3219 and strands-labs#3230 squashed, both merged rows and open strands-labs#3205 were identical in every field the script read, so the genuine transient strands-labs#2585 was written for is live at the same time as the terminal state and must keep its re-read. The field that separates them was already in the payload: `resolve_state` fetched the whole REST pull request object and read five keys from it, and `merged` came back in the same response. - `PullRequestState` gains `merged`, populated from that key. - A terminal `already-merged` outcome, owed by nobody, reported ahead of every rule and short-circuiting them: on a change already on the base, "0 of 1 approvals" is not an unsatisfied rule. Not a finding, so the exit status keeps its meaning. - `primary` and `_next_action` gain a terminal tier ahead of the gating one, so a merged pull request cannot get "the answer is not in yet". - The `merge-state-unknown` docstring no longer implies its "every open pull request" clause covers the merged case. Reading a null as clean is still wrong (strands-labs#1035), and the open recomputing path is unchanged -- pinned by the control row of the new table cell. Closes strands-labs#3231
ac432df to
ff04842
Compare
yinsong1986
left a comment
There was a problem hiding this comment.
Summary
Teaches scripts/check_merge_blockers.py --pr N to distinguish the two situations that share mergeable: null: an open pull request GitHub is still computing (kept as the transient merge-state-unknown with its re-read remedy, per #2585) and a pull request that has already merged, whose null is permanent because mergeability is only computed for open pull requests. The new already-merged outcome reads merged from the same REST payload resolve_state already fetches, short-circuits every ruleset rule (an approval count on a landed change is not an outstanding obligation), and is deliberately not a finding, so the exit-status contract is unchanged. The precedence change is sound: evaluate returns the terminal blocker alone, and the new terminal tier in primary/_next_action is held as a shared set so both renderers apply the same ordering, consistent with the module's own rule. The merged field is keyword-defaulted so existing constructors are untouched, and the sweep path is unaffected by construction (a merged pull request never appears in the open set). Tests pin both directions of the classification (the one-field-apart parametrized pair), the absence of every deferral remedy from the merged report, the short-circuit under three simultaneously-unsatisfied rules with a clean exit, and the resolve_state wiring itself; the existing AST dataflow grader covers the new outcome's _OWED_BY entry without an edit. AGENTS.md's step-8 outcome inventory is updated in the same change, and the changelog fragment follows the <pr-number>-<slug>.md convention. No security, crash, data-integrity, or one-way-door concerns: this is internal triage tooling with its output vocabulary extended additively and its exit semantics preserved.
What's good
- The control row (
open-and-recomputing) is pinned alongside the fix, so the genuine transient #2585 was written for cannot regress silently. - The not-in-scope section measures the closed-without-merge case (#3194) rather than assuming it shares the null, and correctly leaves it for a separate decision.
- Owner coverage rides the existing dataflow grader instead of a copied list, per the repo's derive-don't-restate convention.
The report told a merged pull request to wait for something that never arrives
check_merge_blockers.py --pr Nclassified an already-merged pull request asmerge-state-unknownand prescribed "Re-read the pull request: mergeability iscomputed on demand and settles on a later read." It does not settle. A merged
pull request is closed, so
mergeablestays null permanently, and the report'snext-action line reads in the reassuring direction while a merge-and-verify
cycle polls for an answer it already has.
Before / after, on the same pull request (
strands-labs/robots#3219, merged 12:26 UTC)Outcome: merge-state-unknownNo party owes an action; the answer is not in yet.…
Re-read the pull request: mergeability is computed on demand and settles on a later read.Outcome: already-mergedNo party owes an action, and none will: already-merged.This is terminal rather than not-in-yet -- no later read changes it.…
The pull request is already merged into main, so no rule is outstanding.…re-reading this one cannot settle itmergeable: nullhad three readings, and the script modelled oneflowchart LR N["mergeable: null"] --> Q{"merged?"} Q -->|"false, open"| T["merge-state-unknown<br>transient - a re-read settles it<br>(#2585, kept unchanged)"] Q -->|"true, closed"| A["already-merged<br>terminal - no read ever settles it<br>(new)"] style A fill:#dcffe0,stroke:#1a7f37 style T fill:#eef2ff,stroke:#3b5bdbMeasured,
GET /repos/strands-labs/robots/pulls/{n}, 2026-09-05Every field the script read was identical across the merged rows and the open
one, which is why
mergeable is Nonecould not carry the distinction. Thetransient #2585 was written for is live at the same time, so it must keep its
re-read.
statemergedmergeablemergeable_stateclosedtruenullunknownmerge-state-unknown+ re-readalready-mergedclosedtruenullunknownmerge-state-unknown+ re-readalready-mergedclosedtruenullunknownmerge-state-unknown+ re-readalready-mergedclosedtruenullunknownmerge-state-unknown+ re-readalready-mergedopenfalsetrueblockedmissing-approvalmissing-approval(unchanged)openfalsetrueblockedpusher-only-approvalpusher-only-approval(unchanged)#2586 is the row that makes "re-read" wrong rather than merely early: fourteen
days on, it still reads
null. A second read of #3219 ten seconds later alsoreturned
null.The change
The field that disambiguates was already in the payload —
resolve_statefetched the whole REST pull request object and took five keys from it, and
mergedcame back in the same response.PullRequestState.merged, populated from that key.already-mergedoutcome, owed by nobody, reported ahead of everyrule and short-circuiting them: once the change is on the base, "0 of 1
approvals" is not an unsatisfied rule, it is a question about a closed pull
request.
opens on a pull request whose author has nothing left to do.
primary/_next_actiongain a terminal tier ahead of the gating one.Terminal is distinct from gating: a gating blocker is one the rules below it
wait on and it clears; a terminal one has nothing below it and never clears,
so every remedy this report can print — re-read, re-run, attempt the merge —
is wrong for it. Held as a set because both renderers are handed blockers
alone, and the module's rule is that precedence cannot apply to one report and
not the other.
merge-state-unknownentry's "every open pull request"clause no longer implies the merged case is covered.
Reading a null as clean is still wrong (#1035) and is untouched.
Tests
5 cells beside the
mergeable=Nonecluster. The table cell carries the argument:two rows one field apart, both
mergeable=None/merge_state="unknown".…and_an_open_recomputing_one_do_not_read_the_same[already-merged]assert ['merge-state-unknown'] == ['already-merged']…and_an_open_recomputing_one_do_not_read_the_same[open-and-recomputing]…is_not_offered_a_remedy_that_cannot_terminateassert False is True…leaves_no_rule_unsatisfiedassert ['merge-state-unknown', 'missing-approval'] == ['already-merged']the_resolved_state_carries_the_merged_key_it_already_fetchedassert False is TruePre-fix (behavioural lines reverted, new symbols kept so the module still
imports): 4 failed, 81 passed. Owner coverage needs no new cell — the
existing AST dataflow grader
test_every_outcome_the_report_can_emit_has_an_owneralready fails on an outcome missing from
_OWED_BY.Gate
tests/test_merge_blockers.pymerge_blockers,duplicate_claim_check,closing_reference_gate,checkout_is_pr_head,pull_request_trigger_types)scripts/check_whole_tree_graders.pyruff check+format --check(CI scope)mypy(CI pin 1.20.2, cold cache both trees)mypy scripts/check_merge_blockers.pySuccess: no issues foundNot in scope, and measured rather than assumed
A pull request closed without merging does not share the null: #3194 reads
merged: false,mergeable: true,mergeable_state: blocked— it retains itslast computed value. So it is not a second instance of this defect. It is still
a distinct reading (abandoned, not landed) with a different owner, and is left
for a separate decision.
Size
scripts/check_merge_blockers.pytests/test_merge_blockers.pyAGENTS.mdchangelog.d/3233-…mdNet-positive: 82 production lines for the classification, its precedence tier
and the docstring correction; the rest is the pin, the log and the operator doc.
Closes #3231