fix(scripts): a merged pull request is not a mergeability still being computed - #3232
cagataycali wants to merge 2 commits into
Conversation
… computed check_merge_blockers.py reported an already-merged pull request as merge-state-unknown and prescribed "Re-read the pull request: mergeability is computed on demand and settles on a later read". A merged pull request is closed, so mergeable stays null for good and no later read settles anything. Measured on strands-labs#3219 and strands-labs#3230 right after they squashed: both closed, merged=true, mergeable=null, mergeable_state=unknown -- byte-identical, in every field this check read, to open strands-labs#3205's honest recompute at the same moment. The disambiguating key was already in the payload resolve_state fetches and was simply not read. Adds a terminal already-merged outcome, owed by nobody, returned ahead of every ruleset rule rather than gating them: once the change is on the base, "0 of 1 approvals" is not a rule the merge left unsatisfied. strands-labs#2586's treatment of an open pull request's null is untouched, and a control cell pins that. Refs strands-labs#3231
yinsong1986
left a comment
There was a problem hiding this comment.
Summary
Adds a terminal already-merged outcome to scripts/check_merge_blockers.py so a pull request that has already merged is named as such instead of reporting merge-state-unknown with a "re-read, it settles later" remedy that can never settle (a merged PR is closed, so mergeable stays null permanently). The fix reads the merged key already present in the REST payload resolve_state fetches, short-circuits evaluate() ahead of every ruleset rule so no obligation (approval, thread resolution) is invented against a closed PR, and places the outcome in _GATING so the report prints an owner (nobody) rather than the ungated "the answer is not in yet" wording. It is not a finding, so exit-status semantics are unchanged. Verified at head: the full tests/test_merge_blockers.py suite passes (88), ruff check/format clean, all three changed files ASCII-only.
What's good
- The open-PR cluster is pinned untouched across all three
mergeablevalues (test_the_merged_verdict_does_not_reach_an_open_pull_request), so this is not a revert of #2586 and cannot leak the merged verdict onto aDIRTYbranch. - The
--all-opensweep filters to open pull requests, so the new short-circuit is unreachable there by construction; the one place it can fire (a PR that merges between the list read and the state read) now reports a non-finding instead of a stale verdict, which is strictly better. - Hostile-input test (no approval, unresolved threads, no check conclusions) pins that no rule is named underneath a merged PR, which is the half of the pre-fix behaviour worth fixing most - it was also asking a reviewer to approve a landed change.
- Changelog fragment named per convention (
3232-<slug>.md), pushed with the PR; the closed-but-not-merged fourth reading is explicitly scoped out rather than silently conflated.
|
Closing in favour of #3233, which implements the same fix and is a superset of this diff. Both were written against #3231 independently -- the duplicate shape #3169 records.
The two that decide it:
The scoping note this branch added to the |
Closes #3231
The defect
check_merge_blockers.py --pr Nreported a pull request that had already merged asmerge-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
mergeablestaysnullpermanently. The report described a wait with no terminating condition, and it read in the
reassuring direction: "No party owes an action; the answer is not in yet" is what a
caller polling "can I merge yet" saw while the answer was "you already did".
Measured
GET /repos/strands-labs/robots/pulls/{n}, 2026-09-05, minutes after #3219 and #3230squashed:
statemergedmergeablemergeable_stateclosedtruenullunknownmerge-state-unknown, "Re-read"already-mergedclosedtruenullunknownmerge-state-unknown, "Re-read"already-mergedopenfalsenullunknownmerge-state-unknown, "Re-read"The third row is the reason this is not a dead state with a one-line special case: the
genuine transient #2586 was written for is live at the same moment, on the same fields.
All three rows were byte-identical in every field this check read, so
mergeable is Nonewas ambiguous between "still computing" and "nothing left to compute".
Why this line
resolve_statefetches the whole REST pull request object and read five keys from it -head,base,draft,mergeable,mergeable_state. The same response carriesmerged, which answers the question outright. This is the "fetched and not read" failureAGENTS.md names as the recurring root cause, in the one script whose stated purpose is
"ask for the verdict rather than re-deriving it".
The script's own docstring stated the assumption that fails, and it is now corrected:
True, and it is why
merge-state-unknownexists. But the pull request whose own mergeinvalidated the value is not open, and nothing downstream re-checked that.
This is not a revert of #2586. Reading the null as clean is still wrong (#1035 read
pusher-only-approvalon aDIRTYbranch). The null simply has a third reading #2585 didnot have to separate - the same shape as
reviewDecision: nullmeaning two things inAGENTS.md. A control cell pins the open cluster untouched across all three
mergeablevalues.
The change
Four edits in the seam that already existed:
PullRequestStategainsmerged: bool = False, andresolve_statepopulates it from thekey already fetched.
already-mergedoutcome, owed byNOBODY, and not a finding - the exit statusis for what an author-side pass can act on alone, and there is nothing left to act on.
evaluate()returns it alone, ahead of every rule including the draft check, ratherthan adding it to the gating list. Gating means the rules below cannot be assessed yet;
here there is nothing left to assess in any future read, and naming "0 of 1 approvals"
beside it would invent an obligation against a closed pull request.
_GATINGso the report printsNext action is owed by nobody, on `already-merged`rather than falling through to the ungated wording "the answer is notin yet", which is the sentence that was measured as misleading.
No new transport, no new query, no signature change.
Tests
tests/test_merge_blockers.py, beside the existingmergeable=Nonecluster, with themeasured triple as the fixture header.
The core cell is table-driven over the one field that was not being read:
test_the_same_null_mergeability_gets_two_verdicts_by_whether_it_merged, parametrizedmerged-3219-and-3230/open-3205. Also added: no rule is named underneath a merged pullrequest (hostile inputs - no approval, two unresolved threads, no check conclusion); the
verdict is owed by nobody and is not a finding; neither the re-read remedy, the ungated
"answer is not in yet" wording, nor #2574's merge attempt is printed; the blocker is
reported alone so "necessary but not sufficient" cannot appear; and two guards - the merged
verdict never reaches an open pull request at any
mergeablevalue, andresolve_stateactually reads the key.
The existing AST dataflow cell
test_every_outcome_the_report_can_emit_has_an_ownergradesthe new outcome for free, which is why
_OWED_BYis not asserted by spelling.Pre-fix control (the two behavioural edits reverted, tests and docstrings kept):
Exactly the six graders fail; the
open-3205row and the open-pull-request guard staygreen, so they are guards rather than restatements of the fix. The second failure is worth
reading twice - pre-fix a merged pull request was reported as
merge-state-unknownplusmissing-approval, so it was also asking a reviewer to approve a landed change.Gate
ruff check+format --checkmypy scripts/check_merge_blockers.py(pinned<2.0)Success: no issues foundpytest tests/test_merge_blockers.py80 passed->88 passed,+8= exactly the new cellsalready-merged, owed by nobody, exit 0missing-approvalowed by any reviewer - no leakDeliberately out of scope
A pull request that is
closedbut not merged reads the samemergeable: null. That isa fourth verdict with a different meaning again - abandoned rather than landed - and
plausibly a different owner, so it deserves separating. It is a distinct decision from this
one and is not what cost a cycle here, so
mergedis read rather thanstate.The
--all-opensweep filters to open pull requests and is unaffected; this lands on thesingle-PR path a merge-and-verify cycle uses.
Size: 3 files. Production: one field, one short-circuit, one payload key; the rest is
the regression table, the corrected docstring entry and the changelog fragment.