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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
bump: patch
---

### Changed

- The post-merge check asks the compare API once per branch tip when the branch moved after the gate's local clone was made, instead of once for every lane record. It compares the clone's copy of the branch with the live tip, and a record whose run head is on that copy is on the tip with no further call. A record off that copy, a diverged tip or an unreadable compare still gets its own compare, so every verdict is unchanged and every failed read still refuses.
- An unattended gate run with no `--state-dir` (how the pr-merge lane runs) stops a pull request the REST record reads as merged before its demotion and post-merge reads, on the blocker it already gave it (`state=MERGED (not OPEN)`), instead of running the post-merge check first. The identity and queue readers are still read, so its queue sweep still dequeues a held entry. The verdict is unchanged. A closed pull request that was not merged, which can reopen, and any run with a state directory, where a recorded merge request's read-back needs the full policy, are read in full as before.
Original file line number Diff line number Diff line change
Expand Up @@ -1620,7 +1620,7 @@ def evaluate(
if owner not in allowed:
blockers.append(f"owner {owner!r} out of scope")
if pr.get("state") != "OPEN":
blockers.append(f"state={pr.get('state')} (not OPEN)")
blockers.append(merge_policy.not_open_refusal(pr.get("state")))
if pr.get("isDraft"):
blockers.append("PR is a draft -- mark ready first")
if BODY_MERGE_HOLD_RE.search(str(pr.get("body") or "")):
Expand Down Expand Up @@ -3278,6 +3278,7 @@ def assess_unattended(
lane_rung_cap: str | None = None,
shared: merge_policy.SharedPostMerge | None = None,
share: Callable[[merge_policy.SharedPostMerge], object] | None = None,
stop_when_closed: bool = False,
) -> merge_policy.UnattendedPolicy:
"""The unattended decision for one pull request, in the wiring order:
identity, the credential chokepoint, the kill switch, a default-branch
Expand Down Expand Up @@ -3314,6 +3315,7 @@ def promotion(window_start: datetime | None) -> merge_policy.Promotion:
lane_rung_cap=lane_rung_cap,
shared=shared,
share=share,
stop_when_closed=stop_when_closed,
)
return score_unattended(repo, number, policy, self_logins)

Expand Down Expand Up @@ -4938,6 +4940,10 @@ def _deadline_stop() -> int:
lane_kill_switch=args.lane_kill_switch,
lane_rung_cap=args.lane_rung_cap,
shared=shared,
# With no state directory there is no recorded merge
# request whose read-back needs the policy of a merged
# pull request, so a merged one skips the post-merge check.
stop_when_closed=pending_path is None,
# Each recompute goes back to the file for the next candidate.
share=(
None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4069,6 +4069,11 @@ def shared_refusal(
return None


def not_open_refusal(state: object) -> str:
"""The gate's blocker for a pull request whose GraphQL state is not OPEN."""
return f"state={state} (not OPEN)"


def assess(
repo: str,
number: int,
Expand All @@ -4083,6 +4088,7 @@ def assess(
shared: SharedPostMerge | None = None,
share: Callable[[SharedPostMerge], object] | None = None,
human_gated: str | None = None,
stop_when_closed: bool = False,
) -> UnattendedPolicy:
"""The policy readers for one pull request, in the wiring order.

Expand Down Expand Up @@ -4121,8 +4127,11 @@ def assess(
adopted pull request in the lane (`adoption_refusal`); None adopts none.
`promotion(window_start)` reads the promotion evidence with the newest
demotion record's creation as the window restart; without it nothing is
promoted. `self_logins` names only lane or bot self identities (see
`is_human`). Raises `PolicyReadError` when the pull request or either
promoted. With `stop_when_closed`, a pull request the REST record reads
as merged refuses (`not_open_refusal`) once the identity and the readers
the queue sweep needs are read, before the demotion and post-merge
reads: the gate holds it on the same blocker.
`self_logins` names only lane or bot self identities (see `is_human`). Raises `PolicyReadError` when the pull request or either
pipeline config cannot be read, because the agent identity is then
unknown. A default branch head config that cannot be parsed, or names
another identity, is a stop (`StopReadError`).
Expand Down Expand Up @@ -4236,6 +4245,10 @@ def assess(
"permission": permission,
}
inputs.update(permission=permission, self_logins=self_logins)
if stop_when_closed and pr.get("state") == "closed" and pr.get("merged") is True:
# The queue sweep still runs on what is read so far: this may be the
# run's last candidate. A merge is final; a closed one can reopen.
return replace(stopped, reason=not_open_refusal("MERGED"))
# The run's shared answer stands in for the demotion, floor and post-merge
# reads when it is bound to this run; otherwise they are read here.
reuse = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,9 @@ class _Context:
checks_cache: dict[tuple[int, int], frozenset[str]] = field(default_factory=dict)
# Full-history clones `contains` asks first; None until first resolved.
local_clones: list[Path] | None = None
# Per live branch tip, the clone's copy of the branch when the compare
# API read it as that tip's ancestor, else None (`_tip_bridge`).
bridge_cache: dict[str, str | None] = field(default_factory=dict)
baseline_cache: dict[tuple[str, datetime], frozenset[str] | None] = field(
default_factory=dict
)
Expand Down Expand Up @@ -1684,27 +1687,72 @@ def contains(self, ancestor: str, sha: str) -> bool:
"""Whether `sha` is `ancestor` or has it in its history: from a local
full-history clone when one holds both commits, else from the compare
API, whose `ahead` or `identical` status means `ancestor` is reachable
from `sha`, which is what `git merge-base --is-ancestor` tests."""
from `sha`, which is what `git merge-base --is-ancestor` tests. For the
branch tip the clone lacks (the branch moved after it was made), an
ancestor of the clone's copy of the branch is an ancestor of the tip
when that copy is (`_tip_bridge`), so one compare answers them all."""
key = (ancestor, sha)
if key not in self.contains_cache:
if ancestor == sha:
self.contains_cache[key] = True
elif (local := self._local_contains(ancestor, sha)) is not None:
self.contains_cache[key] = local
elif (
sha == self.branch_tip
and (bridge := self._tip_bridge(sha)) is not None
and self._local_contains(ancestor, bridge) is True
):
self.contains_cache[key] = True
else:
compare = _read(
[
"api",
f"repos/{self.owner}/{self.name}/compare/{ancestor}...{sha}",
],
f"compare {ancestor[:12]}...{sha[:12]}",
)
self.contains_cache[key] = json_object(compare).get("status") in (
"ahead",
"identical",
)
self.contains_cache[key] = self._compare(ancestor, sha)
return self.contains_cache[key]

def _compare(self, ancestor: str, sha: str) -> bool:
"""The compare API's answer to `contains`; a failed read raises."""
compare = _read(
["api", f"repos/{self.owner}/{self.name}/compare/{ancestor}...{sha}"],
f"compare {ancestor[:12]}...{sha[:12]}",
)
return json_object(compare).get("status") in ("ahead", "identical")

def _tip_bridge(self, tip: str) -> str | None:
"""The first full-history clone's copy of the branch, read from
`refs/remotes/origin/<branch>` or else `refs/heads/<branch>`, when the
compare API reads it as an ancestor of `tip`; else None. Read once per
tip. A failed compare is None, not an error: each record then gets
its own compare, which raises on its own failure."""
if tip not in self.bridge_cache:
bridge = None
if self.local_clones is None:
self.local_clones = _full_history_clones()
refs = (f"refs/remotes/origin/{self.branch}", f"refs/heads/{self.branch}")
local = next(
(
out.strip()
for root in self.local_clones
for ref in refs
if (
out := policy._git(
root,
"rev-parse",
"--verify",
"--quiet",
f"{ref}^{{commit}}",
)
)
and _FULL_SHA.fullmatch(out.strip())
),
None,
)
# A local tip leaves only the record's commit missing: no bridge.
if local is not None and local != tip:
try:
bridge = local if self._compare(local, tip) else None
except PostMergeReadError:
bridge = None
self.bridge_cache[tip] = bridge
return self.bridge_cache[tip]

def _local_contains(self, ancestor: str, sha: str) -> bool | None:
"""`git merge-base --is-ancestor` in the first local clone that can
decide (exit 0 or 1), or None. Only full SHA-1 ids are asked: a ref
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2247,6 +2247,65 @@ def test_every_input_good_permits_the_merge(self) -> None:
self.assertIs(result.promoted, True)
self.assertEqual(result.report()["credentials"], "identity-only")

def test_a_merged_pull_request_stops_before_the_post_merge_reads(self) -> None:
gh = self.readable()
gh.rest[f"repos/{REPO}/pulls/1"].update(state="closed", merged=True)
later = mock.Mock(side_effect=AssertionError("read after the refusal"))
with (
mock.patch.object(policy, "read_demotion", later),
mock.patch.object(policy, "in_flight", later),
mock.patch.object(policy, "eligibility", later),
):
result = self.assess(
post_merge=later, promotion=later, stop_when_closed=True
)
later.assert_not_called()
# The gate's own blocker for a pull request that is not open.
self.assertEqual(result.reason, "state=MERGED (not OPEN)")
self.assertIs(result.enabled, False)
self.assertIs(result.report()["reportOnly"], False)
# What the queue sweep reads: the identity, branch and readers.
for key in ("identity", "branch", "config", "permission", "self_logins"):
self.assertIn(key, result.inputs)

def test_a_closed_unmerged_pull_request_is_read_in_full_with_the_stop(
self,
) -> None:
# Closed but not merged can reopen before the gate's own state read,
# so only a merge, which is final, stops early.
gh = self.readable()
gh.rest[f"repos/{REPO}/pulls/1"].update(state="closed", merged=False)
self.all_clear()
seen: list[Any] = []
self.assess(
post_merge=lambda floor, who: seen.append(who) or gate(),
promotion=lambda s: PROMOTED,
stop_when_closed=True,
)
self.assertEqual(seen, [IDENTITY])

def test_a_closed_pull_request_is_read_in_full_without_the_stop(self) -> None:
gh = self.readable()
gh.rest[f"repos/{REPO}/pulls/1"].update(state="closed", merged=True)
self.all_clear()
seen: list[Any] = []
self.assess(
post_merge=lambda floor, who: seen.append(who) or gate(),
promotion=lambda s: PROMOTED,
)
self.assertEqual(seen, [IDENTITY])

def test_an_open_pull_request_is_read_in_full_with_the_stop(self) -> None:
gh = self.readable()
gh.rest[f"repos/{REPO}/pulls/1"].update(state="open", merged=False)
self.all_clear()
result = self.assess(
post_merge=lambda floor, who: gate(),
promotion=lambda s: PROMOTED,
stop_when_closed=True,
)
self.assertIsNone(result.reason)

def test_config_is_read_at_the_pull_requests_base_sha(self) -> None:
gh = self.readable()
self.all_clear()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,11 @@ class PrMergeHoldsOnAHumanVeto(unittest.TestCase):
)

def _vetoes(self, result: dict[str, Any]) -> list[str]:
return [b for b in result["blockers"] if "human blocking comment" in b or "human merge veto" in b]
return [
b
for b in result["blockers"]
if "human blocking comment" in b or "human merge veto" in b
]

def test_a_human_do_not_merge_comment_holds(self) -> None:
result = _evaluate(
Expand Down Expand Up @@ -471,7 +475,9 @@ def test_blocking_words_without_a_veto_hold_nothing(self) -> None:

def test_a_later_comment_from_the_same_person_lifts_the_veto(self) -> None:
veto = _comment(
"maintainer", "Do not merge until the release is cut.", at="2026-10-12T10:00:00Z"
"maintainer",
"Do not merge until the release is cut.",
at="2026-10-12T10:00:00Z",
)
lifted = _comment(
"maintainer", "Release is out, go ahead.", at="2026-10-12T11:00:00Z"
Expand Down Expand Up @@ -533,8 +539,13 @@ def test_only_owner_member_or_collaborator_can_veto(self) -> None:
"body": "Do not merge.",
"created_at": "2026-10-12T10:00:00Z",
}
for kwargs in ({"issue_comments": [veto]}, {"review_comments": [inline]}):
result = _evaluate(fixtures._pr(), HEAD, None, self.PR_MERGE, **kwargs)
for kwargs in (
{"issue_comments": [veto]},
{"review_comments": [inline]},
):
result = _evaluate(
fixtures._pr(), HEAD, None, self.PR_MERGE, **kwargs
)
self.assertEqual(
len(self._vetoes(result)), int(holds), result["blockers"]
)
Expand Down Expand Up @@ -810,6 +821,8 @@ def setUp(self) -> None:
self.pr_head_ref = "feat"
# REST `mergeable_state`; "dirty" is a conflict with the base.
self.pr_mergeable_state: str | None = None
# The REST `state` and `merged` fields; None leaves them out.
self.pr_rest_state: tuple[str, bool] | None = None
# The pull request's commits and the compare answers, by endpoint,
# for the Dependabot rebase request; None leaves the reads unfaked.
self.pr_commits: list[dict[str, Any]] | None = None
Expand Down Expand Up @@ -899,6 +912,11 @@ def run_main(
"mergeable_state": self.pr_mergeable_state,
"user": self.pr_user,
"labels": self.pr_labels,
**(
{"state": self.pr_rest_state[0], "merged": self.pr_rest_state[1]}
if self.pr_rest_state
else {}
),
}
kills = list(self.kill)
self.capture = mock.Mock(side_effect=self._capture)
Expand Down Expand Up @@ -1871,6 +1889,52 @@ def test_a_landed_entry_read_back_at_its_pin_dispatches_the_full_run(self) -> No
self.assertIs(out["unattended"]["dispatched"], True)
self.assertEqual(code, 10)

def test_a_landed_entry_read_back_dispatches_with_the_pr_read_as_merged(
self,
) -> None:
# The REST record says merged too: a state directory can hold a
# recorded request, so the closed stop stays off and the dispatch runs.
self.flight = policy.InFlight(False, f"unverified-auto-merge:#{PR}")
self.pr_view = fixtures._pr(state="MERGED")
self.pr_rest_state = ("closed", True)
self.prior = {
"queued": True,
"head": HEAD,
"status": "merged",
"verification": {"verified": True, "mismatches": []},
"requestedAt": "2026-10-12T10:00:00Z",
}
with tempfile.TemporaryDirectory() as state:
code, out = self.run_main("--state-dir", state)
self.assertIs(out["merged"], True)
self.dispatch.assert_called_once_with("owner/repo", ref="main")
self.post_check.assert_called_once()
self.assertEqual(code, 10)

def test_a_merged_pr_with_no_state_dir_still_sweeps_the_queue(self) -> None:
# The run's last candidate may be the merged one: its gate is then the
# only one left to dequeue a held entry the identity queued.
self.pr_view = fixtures._pr(state="MERGED")
self.pr_rest_state = ("closed", True)
self.queued = [5]
self.holds = {5: "do-not-merge"}
code, out = self.run_main()
self.post_check.assert_not_called()
self.assertEqual(code, 10)
self.assertEqual(self.log, ["dequeue:[5]"])
self.assertEqual(out["unattended"]["writes"]["holds"], {"5": "do-not-merge"})

def test_a_merged_pr_with_no_state_dir_skips_the_post_merge_check(self) -> None:
self.pr_view = fixtures._pr(state="MERGED")
self.pr_rest_state = ("closed", True)
code, out = self.run_main()
self.post_check.assert_not_called()
self.assertEqual(code, 10)
self.assertIs(out["merged"], False)
self.assertIn("state=MERGED (not OPEN)", out["blockers"])
self.assertEqual(self.log, [])
self.dequeue.assert_not_called()

def test_a_landed_entry_read_back_under_a_cap_with_room_exits_zero(self) -> None:
# At a cap of 5 the landed merge leaves room, so no refusal stands.
self.config = dataclasses.replace(self.config, max_in_flight=5)
Expand Down Expand Up @@ -4337,7 +4401,10 @@ def answer(self, endpoint: str) -> Any:
]
}
]
if endpoint == f"repos/owner/repo/check-runs/{job}/annotations?per_page=100":
if (
endpoint
== f"repos/owner/repo/check-runs/{job}/annotations?per_page=100"
):
message = (
f"claude-lane-review-complete lane={lane} sha={HEAD}"
" findings=0 inline=0 scope=whole"
Expand Down
Loading
Loading