From cafb603670bf0ebc11aa665a3d8bb710898a8248 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:39:55 -0400 Subject: [PATCH 1/4] fix(source-control): count an immediate-enqueue native arm as a native lane merge Arming a lanes App pull request that is already mergeable enqueues it at once, and GitHub records only an AddedToMergeQueueEvent by the identity, no AutoMergeEnabledEvent (#9554). native_auto_merge read that as a gate merge, so the post-merge check waited on a pin the gate never posts and would file a read-back demotion after six hours. It is now native when there is no auto-merge event, every enqueue in the history is the identity's, and merge.lane-prs was native at the merge commit's first parent. The merged read gains the parent and up to ten enqueue events in place; the config is read only for that shape, once per parent, and an unreadable config keeps the pin read-back. Co-Authored-By: Claude Opus 5.5 --- ...merge-native-immediate-enqueue-7c4e91b2.md | 7 + .../scripts/babysit_merge_policy.py | 13 ++ .../babysit-prs/scripts/babysit_postmerge.py | 103 +++++++++-- .../scripts/tests/test_babysit_postmerge.py | 170 ++++++++++++++++++ 4 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 .changes/source-control/fix-postmerge-native-immediate-enqueue-7c4e91b2.md diff --git a/.changes/source-control/fix-postmerge-native-immediate-enqueue-7c4e91b2.md b/.changes/source-control/fix-postmerge-native-immediate-enqueue-7c4e91b2.md new file mode 100644 index 0000000000..563b3e043c --- /dev/null +++ b/.changes/source-control/fix-postmerge-native-immediate-enqueue-7c4e91b2.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- The post-merge check counts a lanes App merge as native when arming an already mergeable pull request enqueued it at once, so GitHub recorded no auto-merge event. It counts the merge only when every enqueue on the pull request is the identity's and `merge.lane-prs` was `native` at the merge commit's parent. Such a merge no longer waits on a pin the gate never posts, and no longer turns into a false read-back demotion after six hours. A human enqueue, `merge.lane-prs: gate`, or an unreadable config keeps the pin read-back. diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py index fa0d026353..4205feed3b 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py @@ -620,6 +620,13 @@ class PipelineConfig: # (`babysit_postmerge`). Absent, or anything but a whole number from 0 to # `MAX_CONFIRM_WAIT_MINUTES`, is `CONFIRM_WAIT_MINUTES`. confirm_wait_minutes: int = CONFIRM_WAIT_MINUTES + # `merge.lane-prs`: `native` (absent from a readable pipeline file, as + # resolve-config resolves it) or `gate`; None for no file, a parse error + # or any other value (`babysit_postmerge.native_auto_merge`). + lane_prs: str | None = None + + +LANE_PRS_MODES = ("native", "gate") # The most `merge.max-queue-ejections` may be, and the timeline items the @@ -761,6 +768,7 @@ def file_at(path: str, sha: str) -> str | None: ("adoption.spec-from-body-apps", True), ("merge.quarantine-days", False), ("red-main.confirm-wait-minutes", False), + ("merge.lane-prs", False), ], ) if values is None: @@ -884,6 +892,10 @@ def switch(key: str) -> bool: f"from 0 to {MAX_CONFIRM_WAIT_MINUTES}" ) confirm_wait = None + lane_prs: str | None = scalar("merge.lane-prs") or "native" + if lane_prs not in LANE_PRS_MODES: + notes.append(f"merge.lane-prs {lane_prs!r} is not native or gate") + lane_prs = None return PipelineConfig( base_sha=base_sha, rung=rung, @@ -913,6 +925,7 @@ def switch(key: str) -> bool: confirm_wait_minutes=int(confirm_wait) if confirm_wait is not None else CONFIRM_WAIT_MINUTES, + lane_prs=lane_prs, ) diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py index 00a0b14c0b..840d660531 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py @@ -16,8 +16,8 @@ run on such a SHA, and a matching read-back all exist, and no red full run or canary run counted against it first. Only an enqueue by the identity has a pinned SHA to read back; any other enqueue, and the identity's own native -auto-merge (`native_auto_merge`), verifies on the dispatch and canary runs -alone. A red full run is attributed after one `rerun-failed-jobs` the +auto-merge (`native_auto_merge`, an immediate enqueue on arm included), +verifies on the dispatch and canary runs alone. A red full run is attributed after one `rerun-failed-jobs` the wrapper triggers: red again is a failure, green is a flake. A red full run the pr-merge lane dispatched gets that one re-run from pr-merge.yml's own `post-merge-dispatch` job, which records it (`_Context.lane_rerun`); with @@ -488,13 +488,19 @@ def _graphql( return payload["data"] +# The enqueues the merged read returns per pull request, newest last: an +# immediate-enqueue native arm counts only when every one is the identity's +# (`native_auto_merge`), and a longer history reads as not native. +ENQUEUE_EVENTS = 10 MERGED_QUERY = ( "query PostMergeMerged($o:String!,$r:String!,$b:String!,$c:String){" "repository(owner:$o,name:$r){pullRequests(baseRefName:$b,states:MERGED," f"first:{MERGED_PAGE_SIZE},after:$c,orderBy:{{field:UPDATED_AT,direction:DESC}}){{" "pageInfo{hasNextPage endCursor} nodes{number url mergedAt updatedAt headRefOid " "mergedBy{__typename login} author{__typename login} isCrossRepository " - "mergeCommit{oid} timelineItems(itemTypes:[ADDED_TO_MERGE_QUEUE_EVENT],last:1){nodes{" + "mergeCommit{oid parents(first:1){nodes{oid}}} " + f"timelineItems(itemTypes:[ADDED_TO_MERGE_QUEUE_EVENT],last:{ENQUEUE_EVENTS}){{" + "pageInfo{hasPreviousPage} nodes{" "... on AddedToMergeQueueEvent{createdAt actor{__typename login} enqueuer{__typename login}}}} " "autoMerge:timelineItems(itemTypes:[AUTO_MERGE_ENABLED_EVENT,AUTO_MERGE_DISABLED_EVENT],last:1){nodes{" "__typename ... on AutoMergeEnabledEvent{createdAt actor{__typename login}} " @@ -520,15 +526,33 @@ def _graphql( ) -def native_auto_merge(pr: dict[str, Any], merge: AutoMerge, identity: str) -> bool: +def native_auto_merge( + pr: dict[str, Any], + merge: AutoMerge, + identity: str, + lane_prs_at: Callable[[str], str | None] | None = None, +) -> bool: """Whether the identity's merge landed through GitHub's native auto-merge - (`enable-auto-merge.mjs` arms it), which posts no pin: the identity opened - the pull request from a branch of this repository, as that script requires, - and the newest auto-merge enable or disable event is the identity's enable, - made no later than the merge-queue enqueue. An arm under the identity token - on anyone else's pull request keeps the pin read-back. The gate disarms an - armed auto-merge before it pins and enqueues, and an arm after its enqueue - is not what queued the head, so either keeps the pin read-back. + (`enable-auto-merge.mjs` arms it), which posts no pin. The identity opened + the pull request from a branch of this repository, as that script + requires, and either: + + - the newest auto-merge enable or disable event is the identity's enable, + made no later than the merge-queue enqueue; or + - there is no auto-merge event, every enqueue on the pull request is the + identity's (`actor` the identity, `enqueuer` null or the identity), and + `merge.lane-prs` was `native` at the merge commit's first parent + (`lane_prs_at`). Arming a pull request that is already mergeable in a + merge-queue repository enqueues it at once, and GitHub records only the + `AddedToMergeQueueEvent`. Under native the gate neither lists nor + enqueues the identity's pull requests, so its pin is not missing. + + An arm under the identity token on anyone else's pull request keeps the + pin read-back. The gate disarms an armed auto-merge before it pins and + enqueues, and an arm after its enqueue is not what queued the head, so + either keeps the pin read-back. Any human enqueue, a longer enqueue + history than the read returns, an unknown merge parent, and a config that + cannot be read keep it too. Residuals: nothing compares a native merge's head with any vetted head. `expectedHeadOid` binds only the head at arm time, and a writer's push @@ -536,7 +560,13 @@ def native_auto_merge(pr: dict[str, Any], merge: AutoMerge, identity: str) -> bo the later head merges natively. An arm that lands between the gate's disarm check and its enqueue also reads as native and skips that merge's read-back, and so does an arm in the same second as the enqueue: event - times have one-second resolution, so the comparison is at or before.""" + times have one-second resolution, so the comparison is at or before. + Under native, a gate enqueue of the identity's pull request with no + auto-merge event reads as native whether its pin stands, was edited or + was deleted, so its read-back is skipped. The gate makes one only when it + read `merge.lane-prs: gate` at the default branch head and the setting + flipped to native before the merge, and the merge still verifies only on + a green dispatch run and a green canary run.""" if not merge.by_identity: return False if ( @@ -548,7 +578,7 @@ def native_auto_merge(pr: dict[str, Any], merge: AutoMerge, identity: str) -> bo if not is_json_object(connection) or not is_json_array(connection.get("nodes")): raise PostMergeReadError(f"#{merge.number}: no auto-merge events") if not connection["nodes"]: - return False + return _immediate_enqueue(pr, merge, identity, lane_prs_at) event = json_object(connection["nodes"][-1]) if event.get("__typename") != "AutoMergeEnabledEvent": return False @@ -562,6 +592,37 @@ def native_auto_merge(pr: dict[str, Any], merge: AutoMerge, identity: str) -> bo ) +def _immediate_enqueue( + pr: dict[str, Any], + merge: AutoMerge, + identity: str, + lane_prs_at: Callable[[str], str | None] | None, +) -> bool: + """`native_auto_merge`'s case with no auto-merge event: every enqueue is + the identity's, the read holds all of them, and `merge.lane-prs` was + native at the merge commit's first parent.""" + if not merge.queued or lane_prs_at is None: + return False + timeline = json_object(pr.get("timelineItems")) + if json_object(timeline.get("pageInfo")).get("hasPreviousPage") is not False: + return False + for item in json_array(timeline.get("nodes")): + event = json_object(item) + if _actor_key(event.get("actor")) != identity: + return False + if event.get("enqueuer") is not None and ( + _actor_key(event.get("enqueuer")) != identity + ): + return False + parents = json_array( + json_object(json_object(pr.get("mergeCommit")).get("parents")).get("nodes") + ) + parent = json_object(parents[0]).get("oid") if parents else None + if not isinstance(parent, str): + return False + return lane_prs_at(parent) == "native" + + def _default_branch(owner: str, name: str) -> str: """The repository's default branch; an unreadable one fails the read, so the check never judges a branch the repository does not merge into.""" @@ -1997,6 +2058,18 @@ def check( pinned_merges: set[int] = set() warnings: list[str] = [] identity = _login_key(agent_identity) + lane_prs: dict[str, str | None] = {} + + def lane_prs_at(sha: str) -> str | None: + # An unreadable config keeps the pin read-back for that merge + # alone rather than making the whole verdict unreadable. + if sha not in lane_prs: + try: + lane_prs[sha] = policy.read_config_at(repo, sha).lane_prs + except policy.PolicyReadError: + lane_prs[sha] = None + return lane_prs[sha] + for pr in _merged_since(owner, name, branch, merged_since): derived = policy.derive_auto_merge( pr, is_human, identity, lane_merges_only=lane_merges_only @@ -2005,7 +2078,9 @@ def check( warnings.append(derived) elif derived is not None: merges.append(derived) - if derived.by_identity and not native_auto_merge(pr, derived, identity): + if derived.by_identity and not native_auto_merge( + pr, derived, identity, lane_prs_at + ): pinned_merges.add(derived.number) merges.sort(key=lambda m: (str(m.merged_at), m.number)) ctx = _Context( diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py index 95b6854c55..4e80a22722 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py @@ -15,6 +15,7 @@ from __future__ import annotations +import base64 import contextlib import dataclasses import io @@ -235,6 +236,8 @@ def __init__(self) -> None: self.total_counts: dict[tuple[str, str], int] = {} # Commits on main, as the commits API lists them by committer date. self.branch_commits: list[dict[str, Any]] = [] + # File texts by (path, ref) the contents API serves; any other is 404. + self.files: dict[tuple[str, str], str] = {} def add_runs(self, workflow: str, event: str, *runs: dict[str, Any]) -> None: self.runs.setdefault((workflow, event), []).extend(runs) @@ -378,6 +381,14 @@ def __call__( return completed(returncode=1, stderr=f"gh: Error (HTTP {value})") return completed({"permission": value}) if endpoint.startswith(f"repos/{REPO}/contents/"): + path, _, ref = endpoint.removeprefix(f"repos/{REPO}/contents/").partition( + "?ref=" + ) + if (path, ref) in self.files: + content = base64.b64encode(self.files[(path, ref)].encode()).decode() + return completed( + {"type": "file", "encoding": "base64", "content": content} + ) return completed(returncode=1, stderr="gh: Not Found (HTTP 404)") if endpoint.startswith(f"repos/{REPO}/contents?ref="): # A readable tree without the config: every config key at its default. @@ -904,6 +915,165 @@ def test_an_identity_merge_without_its_auto_merge_events_is_unreadable( self.assertIn("no auto-merge events", verdict.reason) +PARENT_SHA = "6" * 40 +PIPELINE = "docs/conventions/pr-pipeline.yaml" + + +def enqueue(actor: Any, enqueuer: Any, at: str = "2026-10-12T09:50:00Z") -> dict: + return {"createdAt": at, "actor": actor, "enqueuer": enqueuer} + + +def immediate_enqueue( + *enqueues: dict[str, Any], earlier: bool = False, **overrides: Any +) -> dict[str, Any]: + """The identity's pull request armed while already mergeable, shaped like + #9554: GitHub enqueued it at once, so the timeline holds only the + identity's `AddedToMergeQueueEvent` and no auto-merge event.""" + fields: dict[str, Any] = { + "timelineItems": { + "pageInfo": {"hasPreviousPage": earlier}, + # #9554's enqueuer: a User typed node with the App's REST login. + "nodes": list(enqueues) + or [enqueue(APP_ACTOR, {"__typename": "User", "login": IDENTITY})], + }, + "autoMerge": {"nodes": []}, + "mergeCommit": {"oid": MERGE_SHA, "parents": {"nodes": [{"oid": PARENT_SHA}]}}, + } + fields.update(overrides) + return merged_pr(**fields) + + +class ImmediateEnqueueNativeArm(CheckCase): + """Under `merge.lane-prs: native` the gate never enqueues the lanes App's + pull requests, so an identity enqueue with no auto-merge event is the + native arm's immediate enqueue (#9554) and has no pin to read back.""" + + LATE = NativeAutoMerge.LATE + + def setUp(self) -> None: + super().setUp() + self.gh.merged = [immediate_enqueue()] + self.lane_prs("native") + + def lane_prs(self, value: str | None, sha: str = PARENT_SHA) -> None: + text = "version: 2\nmerge:\n rung: C2\n" + if value is not None: + text += f" lane-prs: {value}\n" + self.gh.files[(PIPELINE, sha)] = text + + def check_unpinned(self, now: datetime | None = None) -> pm.PostMergeVerdict: + def no_pin_read(merge: Any) -> str | None: + raise AssertionError(f"read the pin of #{merge.number}") + + return pm.check( + REPO, + agent_identity=IDENTITY, + is_human=human, + merged_since=SINCE, + now=now or self.LATE, + pinned_sha=no_pin_read, + ) + + def read_back_failures(self) -> list[str]: + return [f.signal for f in self.check(pinned=None, now=self.LATE).failures] + + def test_it_verifies_with_nothing_pending_and_no_pin_read(self) -> None: + self.all_green() + for now in (datetime(2026, 10, 12, 11, 0, tzinfo=UTC), self.LATE): + with self.subTest(now=now): + verdict = self.check_unpinned(now) + self.assertEqual(verdict.failures, ()) + self.assertEqual(verdict.merges[0].pending, ()) + self.assertTrue(verdict.merges[0].verified) + self.assertTrue(verdict.ok) + + def test_a_null_enqueuer_and_an_absent_lane_prs_are_native(self) -> None: + # resolve-config resolves an absent merge.lane-prs to native. + self.gh.merged = [immediate_enqueue(enqueue(APP_ACTOR, None))] + self.lane_prs(None) + self.all_green() + self.assertTrue(self.check_unpinned().ok) + + def test_lane_prs_is_read_at_the_merge_parent_not_the_merge(self) -> None: + # The merged pull request's own change to lane-prs does not decide it. + self.lane_prs("gate") + self.lane_prs("native", sha=MERGE_SHA) + self.all_green() + self.assertEqual(self.read_back_failures(), ["read-back"]) + + def test_the_pin_stays_required_unless_every_condition_holds(self) -> None: + cases: dict[str, tuple[str | None, dict[str, Any]]] = { + "lane-prs gate": ("gate", {}), + "lane-prs neither native nor gate": ("sometimes", {}), + "a human actor": ("native", {"enqueues": [enqueue(HUMAN, APP_ACTOR)]}), + "a human enqueuer": ("native", {"enqueues": [enqueue(APP_ACTOR, HUMAN)]}), + # #9540: a person enqueued and dequeued it before the identity did. + "a human enqueue before the identity's": ( + "native", + { + "enqueues": [ + enqueue(HUMAN, HUMAN, "2026-10-12T09:00:00Z"), + enqueue(APP_ACTOR, APP_ACTOR), + ] + }, + ), + "enqueues older than the read": ("native", {"earlier": True}), + "no merge parent": ("native", {"mergeCommit": {"oid": MERGE_SHA}}), + "enabled, then disabled": ( + "native", + {"autoMerge": auto_merge_event(APP_ACTOR, "AutoMergeDisabledEvent")}, + ), + "opened by a person": ("native", {"author": HUMAN}), + } + self.all_green() + for name, (value, fields) in cases.items(): + with self.subTest(name): + self.lane_prs(value) + enqueues = fields.pop("enqueues", []) + self.gh.merged = [immediate_enqueue(*enqueues, **fields)] + self.assertEqual(self.read_back_failures(), ["read-back"]) + + def test_an_unreadable_config_keeps_the_pin_and_the_verdict_readable(self) -> None: + self.gh.fail_reads.add(f"/contents/{PIPELINE}") + self.all_green() + verdict = self.check(pinned=None) + self.assertTrue(verdict.readable) + self.assertEqual(verdict.merges[0].pending, ("read-back-unrecorded",)) + + def test_one_config_read_per_parent_and_none_for_an_armed_merge(self) -> None: + self.gh.merged = [ + immediate_enqueue(number=7001), + immediate_enqueue(number=7002), + identity_merge(number=7003, autoMerge=auto_merge_event(APP_ACTOR)), + ] + self.all_green() + self.assertTrue(self.check_unpinned().ok) + reads = [ + c for c in self.gh.calls if any(f"/contents/{PIPELINE}" in a for a in c) + ] + self.assertEqual(len(reads), 1) + + +class LanePrsConfig(unittest.TestCase): + def lane_prs(self, line: str) -> str | None: + text = f"version: 2\nmerge:\n rung: C2\n{line}" + return pm.policy.read_config_at( + REPO, + "a" * 40, + file_at=lambda path, sha: text if path == pm.policy.CONFIG_PATH else None, + ).lane_prs + + def test_lane_prs_is_native_or_gate_and_absent_is_native(self) -> None: + self.assertEqual(self.lane_prs(" lane-prs: native\n"), "native") + self.assertEqual(self.lane_prs(" lane-prs: gate\n"), "gate") + self.assertEqual(self.lane_prs(""), "native") + self.assertIsNone(self.lane_prs(" lane-prs: off\n")) + + def test_no_pipeline_file_has_no_lane_prs(self) -> None: + config = pm.policy.read_config_at(REPO, "a" * 40, file_at=lambda p, s: None) + self.assertIsNone(config.lane_prs) + + class RunsOffTheBranch(CheckCase): """A run on a tag named like the branch reports the branch as its `head_branch`. Its commit may descend from the merge, yet it is not on From 81ef831a82205ab184a1a684bb483bd4d990e20d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:48:49 -0400 Subject: [PATCH 2/4] fix(pr-merge): pin the head before a native arm so an immediate enqueue reads back enable-auto-merge posts the gate's babysit-merge-pin comment for the head before it arms, unless the lanes App's newest pin already names that head unedited; a pin that fails to post leaves the pull request unarmed and fails the run. Arming an already-mergeable pull request enqueues it at once with no auto-merge event, so the post-merge check now reads that head back against a pin, and a later push fails the read-back. The immediate-enqueue native tests are split one condition per test, and the safety reference states both rules and the residual. Co-Authored-By: Claude Opus 5.5 --- .../fix-pin-before-native-arm-2b8e5d17.md | 7 ++ ...merge-native-immediate-enqueue-7c4e91b2.md | 2 +- .../scripts/pr-merge/enable-auto-merge.mjs | 37 ++++++- .../pr-merge/enable-auto-merge.test.mjs | 61 +++++++++++- .../skills/babysit-prs/reference/safety.md | 14 ++- .../scripts/tests/test_babysit_postmerge.py | 97 +++++++++++++------ 6 files changed, 179 insertions(+), 39 deletions(-) create mode 100644 .changes/pr-pipeline/fix-pin-before-native-arm-2b8e5d17.md diff --git a/.changes/pr-pipeline/fix-pin-before-native-arm-2b8e5d17.md b/.changes/pr-pipeline/fix-pin-before-native-arm-2b8e5d17.md new file mode 100644 index 0000000000..006e53b836 --- /dev/null +++ b/.changes/pr-pipeline/fix-pin-before-native-arm-2b8e5d17.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- `enable-auto-merge` posts the gate's `` comment before it arms a lanes App pull request, unless the App's newest pin already names that head unedited. When the pin fails to post, the pull request stays unarmed and the run fails. Arming a pull request that is already mergeable enqueues it at once with no auto-merge event, so the post-merge check now reads that head back against the pin. diff --git a/.changes/source-control/fix-postmerge-native-immediate-enqueue-7c4e91b2.md b/.changes/source-control/fix-postmerge-native-immediate-enqueue-7c4e91b2.md index 563b3e043c..6840ae4fca 100644 --- a/.changes/source-control/fix-postmerge-native-immediate-enqueue-7c4e91b2.md +++ b/.changes/source-control/fix-postmerge-native-immediate-enqueue-7c4e91b2.md @@ -4,4 +4,4 @@ bump: patch ### Fixed -- The post-merge check counts a lanes App merge as native when arming an already mergeable pull request enqueued it at once, so GitHub recorded no auto-merge event. It counts the merge only when every enqueue on the pull request is the identity's and `merge.lane-prs` was `native` at the merge commit's parent. Such a merge no longer waits on a pin the gate never posts, and no longer turns into a false read-back demotion after six hours. A human enqueue, `merge.lane-prs: gate`, or an unreadable config keeps the pin read-back. +- The post-merge check counts a lanes App merge as native when arming an already mergeable pull request enqueued it at once, so GitHub recorded no auto-merge event. It counts the merge only when every enqueue on the pull request is the identity's and `merge.lane-prs` was `native` at the merge commit's parent. Such a merge no longer waits on a pin the gate never posts, and no longer turns into a false read-back demotion after six hours. A human enqueue, `merge.lane-prs: gate`, or an unreadable config keeps the pin read-back. The babysit-prs safety reference describes this rule, the pin `enable-auto-merge` now posts before it arms, and the residual. diff --git a/.github/scripts/pr-merge/enable-auto-merge.mjs b/.github/scripts/pr-merge/enable-auto-merge.mjs index dba8c6c4dd..d1244997ed 100755 --- a/.github/scripts/pr-merge/enable-auto-merge.mjs +++ b/.github/scripts/pr-merge/enable-auto-merge.mjs @@ -75,7 +75,12 @@ // so the person removes it after fixing the reason. Holds a person already // set (labels, a body hold, a veto), the room and failed reads write nothing. // Arming passes the head just read as expectedHeadOid, so a push in between -// fails the arm rather than arming a head nobody read. An armed pull request +// fails the arm rather than arming a head nobody read. Before it arms, the run +// posts the gate's pin comment for that head (pinBody) unless the lanes App's +// newest pin already names it unedited, and a pin that fails to post leaves +// the pull request unarmed: an arm GitHub enqueues at once records no +// auto-merge event, so the post-merge check reads that head back against the +// pin. An armed pull request // that fails any rule is disarmed. Each request opens its own connection, and // a read or write that fails on the network is sent up to twice more // (withRetry). One whose arm or disarm still fails is left as it was, named @@ -147,7 +152,7 @@ export const PULLS_QUERY = `query($owner: String!, $name: String!, $after: Strin export const DETAIL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { - comments(last: 100) { totalCount nodes { databaseId author { __typename login } body createdAt updatedAt authorAssociation } } + comments(last: 100) { totalCount nodes { databaseId author { __typename login } body createdAt updatedAt lastEditedAt authorAssociation } } reviews(last: 100) { totalCount nodes { author { __typename login } body submittedAt authorAssociation } } reviewThreads(last: 100) { totalCount @@ -349,6 +354,23 @@ export function refusalBody(person, reason, head, label) { ].join("\n"); } +// The pin the post-merge read-back checks the merged head against +// (babysit_postmerge.pin_comment and PIN_RE): arming a pull request that is +// already mergeable enqueues it at once with no auto-merge event, which the +// read-back judges as an identity enqueue, so the head is pinned first. +export const PIN_RE = /^$/; +export const pinBody = (sha) => `\nThe unattended merge path armed native auto-merge at head \`${sha}\`.`; + +// Whether the read-back would already read `head` from `comments` +// (DETAIL_QUERY's): the lanes App's newest pin names it and is unedited. +export function pinned(comments, bot, head) { + const pin = (comments ?? []) + .filter((c) => c?.author?.__typename === "Bot" && c.author.login === bot && PIN_RE.test(String(c.body ?? "").split("\n", 1)[0].replace(/\r$/, ""))) + .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))) + .at(-1); + return pin !== undefined && !pin.lastEditedAt && PIN_RE.exec(String(pin.body).split("\n", 1)[0].replace(/\r$/, ""))[1] === head; +} + // The lanes App's newest refusal comment in `comments` (DETAIL_QUERY's). function priorRefusal(comments, bot) { return (comments ?? []) @@ -718,6 +740,17 @@ export async function main({ env = process.env, github, verify, room, sleep = (m await note(pull, detail, { clears, reason }); continue; } + if (action === "arm" && !pinned(detail?.comments?.nodes, slug, pull.headRefOid)) { + try { + await github("POST", `/repos/${repo[0]}/issues/${pull.number}/comments`, { body: pinBody(pull.headRefOid) }); + console.log(`enable-auto-merge: #${pull.number} pinned at ${pull.headRefOid}`); + } catch (error) { + // No pin, no arm: an immediate enqueue would have no read-back. + failed += 1; + console.log(`::error title=enable-auto-merge::#${pull.number} pin, so not armed: ${describe(error)}`); + continue; + } + } try { if (action === "arm") { await github("POST", "/graphql", { query: ARM_MUTATION, variables: { id: pull.id, head: pull.headRefOid } }); diff --git a/.github/scripts/pr-merge/enable-auto-merge.test.mjs b/.github/scripts/pr-merge/enable-auto-merge.test.mjs index 527e9725fd..27b4a21463 100644 --- a/.github/scripts/pr-merge/enable-auto-merge.test.mjs +++ b/.github/scripts/pr-merge/enable-auto-merge.test.mjs @@ -96,7 +96,8 @@ const labels = (...names) => ({ totalCount: names.length, nodes: names.map((name // armErrors: per pull request id, the errors its arm sends throw, in order. // filesError: what every changed-files GET throws. -function world(pulls, { failArm = false, armErrors = {}, filesError = null, noteErrors = [], disarmError = null } = {}) { +function world(pulls, { failArm = false, armErrors = {}, filesError = null, noteErrors = [], disarmError = null, pinError = null } = {}) { + const pins = []; const noteThrows = [...noteErrors]; const armThrows = Object.fromEntries(Object.entries(armErrors).map(([id, errors]) => [id, [...errors]])); const calls = []; @@ -117,6 +118,14 @@ function world(pulls, { failArm = false, armErrors = {}, filesError = null, note if (method === "GET" && path === "/repos/melodic-software/claude-code-plugins/issues/comments/7") { return COST_COMMENT; } + // The read-back pin, posted before an arm. + if (method === "POST" && String(body?.body ?? "").startsWith("`; + +test("the head is pinned in the read-back's format before the arm", async () => { + const { code, writes, pins, calls } = await run([pull(7)]); + assert.equal(code, 0); + assert.deepEqual(pins.map((pin) => [pin.path, pin.body.split("\n")[0]]), [[`${ISSUES}/7/comments`, PIN_LINE]]); + assert.deepEqual(writes, [{ mutation: "arm", id: "PR_7", head: HEAD }]); + const order = calls.map((call) => (call.body?.query === ARM_MUTATION ? "arm" : String(call.body?.body ?? "").startsWith(PIN_LINE) ? "pin" : null)).filter(Boolean); + assert.deepEqual(order, ["pin", "arm"]); +}); + +test("an unedited pin for the same head is not posted again", async () => { + const pin = { ...lanesSaid(`${PIN_LINE}\nArmed.`), lastEditedAt: null }; + const { writes, pins } = await run([pull(7, { discussion: { comments: [pin] } })]); + assert.deepEqual(pins, []); + assert.deepEqual(writes, [{ mutation: "arm", id: "PR_7", head: HEAD }]); +}); + +test("a pin for another head, an edited pin or another author's pin is posted again", async () => { + const other = { ...lanesSaid(``), lastEditedAt: null }; + const edited = { ...lanesSaid(`${PIN_LINE}\nArmed.`), lastEditedAt: "2026-10-23T11:00:00Z" }; + const person = { ...said("kyle-sexton", PIN_LINE, "2026-10-23T10:00:00Z"), lastEditedAt: null }; + const stale = [{ ...lanesSaid(PIN_LINE), lastEditedAt: null }, { ...other, createdAt: "2026-10-23T12:00:00Z" }]; + for (const comments of [[other], [edited], [person], stale]) { + const { pins } = await run([pull(7, { discussion: { comments } })]); + assert.equal(pins.length, 1); + } +}); + +test("a pin that fails to post leaves it unarmed and fails the run", async () => { + const { code, writes, pins, lines } = await run([pull(7), pull(8)], {}, { pinError: new Error("POST comments failed with status 502") }); + assert.equal(code, 1); + assert.equal(pins.length, 2); + assert.deepEqual(writes, []); + assert.ok(lines.some((line) => line.startsWith("::error title=enable-auto-merge::#7 pin, so not armed"))); +}); + +test("report-only posts no pin", async () => { + const { pins } = await run([pull(7)], { REPORT_ONLY: "true" }); + assert.deepEqual(pins, []); +}); + test("a Hold: line, do-not-merge or the human-gated label leaves it unarmed", async () => { const { code, writes } = await run([ pull(1, { body: "Closes #5\n\nHold: a human merges this\n" }), diff --git a/plugins/source-control/skills/babysit-prs/reference/safety.md b/plugins/source-control/skills/babysit-prs/reference/safety.md index 5eeca08be8..d650a85c37 100644 --- a/plugins/source-control/skills/babysit-prs/reference/safety.md +++ b/plugins/source-control/skills/babysit-prs/reference/safety.md @@ -1105,8 +1105,18 @@ Named residuals: its own and files a new record. - After a dispatch deadline with no unedited pin comment, an identity merge fails the read-back and a demotion record is filed. A native auto-merge the identity armed on its own same-repository - pull request no later than its enqueue, and did not disarm since, posts no pin and needs no - read-back: it verifies on the dispatch and canary runs alone. + pull request no later than its enqueue, and did not disarm since, needs no read-back: it + verifies on the dispatch and canary runs alone. +- Arming a pull request that is already mergeable enqueues it at once, and GitHub records only + the identity's `AddedToMergeQueueEvent`, no auto-merge event. `enable-auto-merge.mjs` therefore + posts the gate's pin for the head before it arms, and arms nothing when the pin fails to post, + so such a merge reads back against its pin, and a push after the arm fails the read-back. A + merge of that shape with no pin is still native when every enqueue on the pull request is the + identity's, the read holds the whole enqueue history, and `merge.lane-prs` is `native` at the + merge commit's first parent; a config that cannot be read there keeps the read-back. Under + native, a gate enqueue of the identity's pull request in that shape skips its read-back too, + whatever its pin says. The gate makes one only when it read `merge.lane-prs: gate` and the + setting turned native before the merge. - A native merge's head is compared with no vetted head: a writer's push after the arm (the pr-unblock writers above) leaves auto-merge armed, and that head merges natively with no read-back. `ci-status` still runs the hold rules on the merge-group head. The pending diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py index 4e80a22722..a2d19fddf5 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py @@ -1001,37 +1001,63 @@ def test_lane_prs_is_read_at_the_merge_parent_not_the_merge(self) -> None: self.all_green() self.assertEqual(self.read_back_failures(), ["read-back"]) - def test_the_pin_stays_required_unless_every_condition_holds(self) -> None: - cases: dict[str, tuple[str | None, dict[str, Any]]] = { - "lane-prs gate": ("gate", {}), - "lane-prs neither native nor gate": ("sometimes", {}), - "a human actor": ("native", {"enqueues": [enqueue(HUMAN, APP_ACTOR)]}), - "a human enqueuer": ("native", {"enqueues": [enqueue(APP_ACTOR, HUMAN)]}), - # #9540: a person enqueued and dequeued it before the identity did. - "a human enqueue before the identity's": ( - "native", - { - "enqueues": [ - enqueue(HUMAN, HUMAN, "2026-10-12T09:00:00Z"), - enqueue(APP_ACTOR, APP_ACTOR), - ] - }, - ), - "enqueues older than the read": ("native", {"earlier": True}), - "no merge parent": ("native", {"mergeCommit": {"oid": MERGE_SHA}}), - "enabled, then disabled": ( - "native", - {"autoMerge": auto_merge_event(APP_ACTOR, "AutoMergeDisabledEvent")}, - ), - "opened by a person": ("native", {"author": HUMAN}), - } + def assert_pin_required( + self, value: str | None, *enqueues: dict[str, Any], **fields: Any + ) -> None: + """Each case changes one condition of the #9554 shape, so removing + only that condition's guard makes the merge native and the test red.""" self.all_green() - for name, (value, fields) in cases.items(): - with self.subTest(name): - self.lane_prs(value) - enqueues = fields.pop("enqueues", []) - self.gh.merged = [immediate_enqueue(*enqueues, **fields)] - self.assertEqual(self.read_back_failures(), ["read-back"]) + self.lane_prs(value) + self.gh.merged = [immediate_enqueue(*enqueues, **fields)] + self.assertEqual(self.read_back_failures(), ["read-back"]) + + def test_a_pull_request_a_person_opened_needs_its_pin(self) -> None: + self.assert_pin_required("native", author=HUMAN) + + def test_a_cross_repository_pull_request_needs_its_pin(self) -> None: + self.assert_pin_required("native", isCrossRepository=True) + + def test_an_auto_merge_event_keeps_the_enable_rule(self) -> None: + # Enabled, then disabled: the newest event is a disable. + self.assert_pin_required( + "native", autoMerge=auto_merge_event(APP_ACTOR, "AutoMergeDisabledEvent") + ) + + def test_a_human_enqueue_actor_needs_its_pin(self) -> None: + self.assert_pin_required("native", enqueue(HUMAN, APP_ACTOR)) + + def test_a_human_enqueuer_needs_its_pin(self) -> None: + self.assert_pin_required("native", enqueue(APP_ACTOR, HUMAN)) + + def test_a_human_enqueue_before_the_identitys_needs_its_pin(self) -> None: + # #9540: a person enqueued and dequeued it before the identity did. + self.assert_pin_required( + "native", + enqueue(HUMAN, HUMAN, "2026-10-12T09:00:00Z"), + enqueue(APP_ACTOR, APP_ACTOR), + ) + + def test_enqueues_older_than_the_read_need_the_pin(self) -> None: + self.assert_pin_required("native", earlier=True) + + def test_no_merge_parent_needs_the_pin(self) -> None: + self.assert_pin_required("native", mergeCommit={"oid": MERGE_SHA}) + + def test_lane_prs_gate_at_the_parent_needs_the_pin(self) -> None: + self.assert_pin_required("gate") + + def test_lane_prs_neither_native_nor_gate_needs_the_pin(self) -> None: + self.assert_pin_required("sometimes") + + def test_a_gate_enqueue_under_gate_reads_its_pin_back(self) -> None: + # The gate's own enqueue looks the same: under gate its pin is read + # back, and a merged head other than the pinned one fails. + self.lane_prs("gate") + self.all_green() + verdict = self.check(pinned=OTHER_SHA, now=self.LATE) + self.assertEqual([f.signal for f in verdict.failures], ["read-back"]) + self.assertIn(f"is not the pinned {OTHER_SHA}", verdict.failures[0].detail) + self.assertTrue(self.check(pinned=HEAD_SHA, now=self.LATE).merges[0].verified) def test_an_unreadable_config_keeps_the_pin_and_the_verdict_readable(self) -> None: self.gh.fail_reads.add(f"/contents/{PIPELINE}") @@ -1039,6 +1065,17 @@ def test_an_unreadable_config_keeps_the_pin_and_the_verdict_readable(self) -> No verdict = self.check(pinned=None) self.assertTrue(verdict.readable) self.assertEqual(verdict.merges[0].pending, ("read-back-unrecorded",)) + self.assertEqual(self.read_back_failures(), ["read-back"]) + + def test_an_arm_pinned_before_its_immediate_enqueue_verifies(self) -> None: + # enable-auto-merge.mjs pins the head before it arms, so where the + # pin is read (here under gate) the merged head matches it. + self.lane_prs("gate") + self.all_green() + verdict = self.check(pinned=HEAD_SHA) + self.assertEqual(verdict.merges[0].pending, ()) + self.assertTrue(verdict.merges[0].verified) + self.assertTrue(verdict.ok) def test_one_config_read_per_parent_and_none_for_an_armed_merge(self) -> None: self.gh.merged = [ From e52f3fdad03ee938e6e1bcabca095f2c7616c6ce Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:55:27 -0400 Subject: [PATCH 3/4] fix(source-control): read a standing pin back on an immediate-enqueue native merge An immediate-enqueue native merge now reads the identity's unedited pin when one stands: a match verifies and a mismatch is a read-back failure, as on the pin-required path. Only a merge with no unedited pin skips the read-back, which pin-before-arm leaves to merges armed before it, such as #9554. The enable-event native path is unchanged. The no-merge-parent guard is removed: a missing parent reads as "", which read_config_at refuses, so it is not native either way. Co-Authored-By: Claude Opus 5.5 --- .../skills/babysit-prs/reference/safety.md | 8 ++-- .../babysit-prs/scripts/babysit_postmerge.py | 37 ++++++++++-------- .../scripts/tests/test_babysit_postmerge.py | 38 ++++++++++++++++--- 3 files changed, 58 insertions(+), 25 deletions(-) diff --git a/plugins/source-control/skills/babysit-prs/reference/safety.md b/plugins/source-control/skills/babysit-prs/reference/safety.md index d650a85c37..49ef416414 100644 --- a/plugins/source-control/skills/babysit-prs/reference/safety.md +++ b/plugins/source-control/skills/babysit-prs/reference/safety.md @@ -1113,10 +1113,10 @@ Named residuals: so such a merge reads back against its pin, and a push after the arm fails the read-back. A merge of that shape with no pin is still native when every enqueue on the pull request is the identity's, the read holds the whole enqueue history, and `merge.lane-prs` is `native` at the - merge commit's first parent; a config that cannot be read there keeps the read-back. Under - native, a gate enqueue of the identity's pull request in that shape skips its read-back too, - whatever its pin says. The gate makes one only when it read `merge.lane-prs: gate` and the - setting turned native before the merge. + merge commit's first parent; a config that cannot be read there keeps the read-back. Even then, + an unedited identity pin that stands is read back: a match verifies and a mismatch fails. Only + a merge of that shape with no unedited pin skips the read-back, and since pin-before-arm that + means one armed before it shipped, or one whose pin was edited or deleted after the enqueue. - A native merge's head is compared with no vetted head: a writer's push after the arm (the pr-unblock writers above) leaves auto-merge armed, and that head merges natively with no read-back. `ci-status` still runs the hold rules on the merge-group head. The pending diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py index 840d660531..eea8e73c7a 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py @@ -551,7 +551,8 @@ def native_auto_merge( pin read-back. The gate disarms an armed auto-merge before it pins and enqueues, and an arm after its enqueue is not what queued the head, so either keeps the pin read-back. Any human enqueue, a longer enqueue - history than the read returns, an unknown merge parent, and a config that + history than the read returns, an unknown merge parent (read as "", + which `read_config_at` refuses), and a config that cannot be read keep it too. Residuals: nothing compares a native merge's head with any vetted head. @@ -561,12 +562,13 @@ def native_auto_merge( disarm check and its enqueue also reads as native and skips that merge's read-back, and so does an arm in the same second as the enqueue: event times have one-second resolution, so the comparison is at or before. - Under native, a gate enqueue of the identity's pull request with no - auto-merge event reads as native whether its pin stands, was edited or - was deleted, so its read-back is skipped. The gate makes one only when it - read `merge.lane-prs: gate` at the default branch head and the setting - flipped to native before the merge, and the merge still verifies only on - a green dispatch run and a green canary run.""" + In the case with no auto-merge event, `check` still reads an unedited + identity pin back when one stands (`enable-auto-merge.mjs` pins the head + before it arms): a match verifies and a mismatch is a read-back failure. + Only a merge with no unedited pin skips the read-back, which pin-before- + arm leaves to merges armed before it shipped, such as #9554, and to a + pin edited or deleted after the enqueue. Such a merge still verifies only + on a green dispatch run and a green canary run.""" if not merge.by_identity: return False if ( @@ -617,10 +619,9 @@ def _immediate_enqueue( parents = json_array( json_object(json_object(pr.get("mergeCommit")).get("parents")).get("nodes") ) + # No parent reads as "", which `read_config_at` refuses, so it is not native. parent = json_object(parents[0]).get("oid") if parents else None - if not isinstance(parent, str): - return False - return lane_prs_at(parent) == "native" + return lane_prs_at(parent if isinstance(parent, str) else "") == "native" def _default_branch(owner: str, name: str) -> str: @@ -1839,7 +1840,7 @@ def passes(run: Run) -> bool: ) else: pending.append("read-back-unrecorded") - elif pin_required and pinned != merge.head_ref_oid: + elif pinned is not None and pinned != merge.head_ref_oid: failures.append( Failure( SIGNAL_READ_BACK, @@ -2056,6 +2057,9 @@ def check( # The identity's merges with a pin to read back: not its native # auto-merges, which verify on the dispatch and canary runs alone. pinned_merges: set[int] = set() + # Its immediate-enqueue native merges: a pin, when one stands, is read + # back; with none the read-back is excused. + optional_pins: set[int] = set() warnings: list[str] = [] identity = _login_key(agent_identity) lane_prs: dict[str, str | None] = {} @@ -2078,10 +2082,11 @@ def lane_prs_at(sha: str) -> str | None: warnings.append(derived) elif derived is not None: merges.append(derived) - if derived.by_identity and not native_auto_merge( - pr, derived, identity, lane_prs_at - ): - pinned_merges.add(derived.number) + if derived.by_identity: + if not native_auto_merge(pr, derived, identity, lane_prs_at): + pinned_merges.add(derived.number) + elif not json_object(pr.get("autoMerge")).get("nodes"): + optional_pins.add(derived.number) merges.sort(key=lambda m: (str(m.merged_at), m.number)) ctx = _Context( owner, @@ -2141,7 +2146,7 @@ def lane_prs_at(sha: str) -> str | None: full_runs=full_runs, canary_runs=canary_runs, pinned=pinned_sha(merge) - if pinned_sha and merge.number in pinned_merges + if pinned_sha and merge.number in pinned_merges | optional_pins else None, pin_required=merge.number in pinned_merges, now=now, diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py index a2d19fddf5..634be28a11 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py @@ -961,9 +961,16 @@ def lane_prs(self, value: str | None, sha: str = PARENT_SHA) -> None: text += f" lane-prs: {value}\n" self.gh.files[(PIPELINE, sha)] = text - def check_unpinned(self, now: datetime | None = None) -> pm.PostMergeVerdict: - def no_pin_read(merge: Any) -> str | None: - raise AssertionError(f"read the pin of #{merge.number}") + def check_unpinned( + self, now: datetime | None = None, pin: str | None = None + ) -> pm.PostMergeVerdict: + """The check with `pin` as every pin read's answer (None: no pin), + recording which merges' pins it read in `self.pin_reads`.""" + self.pin_reads: list[int] = [] + + def pin_read(merge: Any) -> str | None: + self.pin_reads.append(merge.number) + return pin return pm.check( REPO, @@ -971,13 +978,14 @@ def no_pin_read(merge: Any) -> str | None: is_human=human, merged_since=SINCE, now=now or self.LATE, - pinned_sha=no_pin_read, + pinned_sha=pin_read, ) def read_back_failures(self) -> list[str]: return [f.signal for f in self.check(pinned=None, now=self.LATE).failures] - def test_it_verifies_with_nothing_pending_and_no_pin_read(self) -> None: + def test_with_no_pin_it_verifies_with_nothing_pending(self) -> None: + # #9554: armed before pin-before-arm, so no pin stands. self.all_green() for now in (datetime(2026, 10, 12, 11, 0, tzinfo=UTC), self.LATE): with self.subTest(now=now): @@ -986,6 +994,22 @@ def test_it_verifies_with_nothing_pending_and_no_pin_read(self) -> None: self.assertEqual(verdict.merges[0].pending, ()) self.assertTrue(verdict.merges[0].verified) self.assertTrue(verdict.ok) + self.assertEqual(self.pin_reads, [7001]) + + def test_a_pin_at_the_merged_head_verifies(self) -> None: + self.all_green() + verdict = self.check_unpinned(pin=HEAD_SHA) + self.assertEqual(verdict.failures, ()) + self.assertEqual(verdict.merges[0].pending, ()) + self.assertTrue(verdict.merges[0].verified) + + def test_a_pin_at_another_head_fails_the_read_back(self) -> None: + # A push after the arm: the merged head is not the one pinned. + self.all_green() + verdict = self.check_unpinned(pin=OTHER_SHA) + self.assertEqual([f.signal for f in verdict.failures], ["read-back"]) + self.assertIn(f"is not the pinned {OTHER_SHA}", verdict.failures[0].detail) + self.assertFalse(verdict.ok) def test_a_null_enqueuer_and_an_absent_lane_prs_are_native(self) -> None: # resolve-config resolves an absent merge.lane-prs to native. @@ -1041,7 +1065,9 @@ def test_enqueues_older_than_the_read_need_the_pin(self) -> None: self.assert_pin_required("native", earlier=True) def test_no_merge_parent_needs_the_pin(self) -> None: + # Read as the parent "", which read_config_at refuses: no read is sent. self.assert_pin_required("native", mergeCommit={"oid": MERGE_SHA}) + self.assertFalse(any("/contents/" in a for c in self.gh.calls for a in c)) def test_lane_prs_gate_at_the_parent_needs_the_pin(self) -> None: self.assert_pin_required("gate") @@ -1085,6 +1111,8 @@ def test_one_config_read_per_parent_and_none_for_an_armed_merge(self) -> None: ] self.all_green() self.assertTrue(self.check_unpinned().ok) + # The enable-event merge's pin is never read. + self.assertEqual(sorted(self.pin_reads), [7001, 7002]) reads = [ c for c in self.gh.calls if any(f"/contents/{PIPELINE}" in a for a in c) ] From 1a2f7934afec34f13089cfca556276c7545aaaf6 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:59:01 -0400 Subject: [PATCH 4/4] fix(source-control): require a recorded enqueue for an immediate-enqueue native merge _immediate_enqueue now refuses a timeline with no AddedToMergeQueueEvent before its actor loop, so a lagging or unrecorded enqueue never reads as native by an empty loop. The guard replaces the indirect `merge.queued` check, which derive_auto_merge sets from the same nodes. Co-Authored-By: Claude Opus 5.5 --- .../babysit-prs/scripts/babysit_postmerge.py | 15 ++++++++++----- .../scripts/tests/test_babysit_postmerge.py | 10 ++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py index eea8e73c7a..293f8dc5a4 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py @@ -600,15 +600,20 @@ def _immediate_enqueue( identity: str, lane_prs_at: Callable[[str], str | None] | None, ) -> bool: - """`native_auto_merge`'s case with no auto-merge event: every enqueue is - the identity's, the read holds all of them, and `merge.lane-prs` was - native at the merge commit's first parent.""" - if not merge.queued or lane_prs_at is None: + """`native_auto_merge`'s case with no auto-merge event: at least one + enqueue, every one the identity's, the read holds all of them, and + `merge.lane-prs` was native at the merge commit's first parent.""" + if lane_prs_at is None: return False timeline = json_object(pr.get("timelineItems")) if json_object(timeline.get("pageInfo")).get("hasPreviousPage") is not False: return False - for item in json_array(timeline.get("nodes")): + # No enqueue recorded is no evidence of one, so the loop below must not + # pass with nothing in it. + enqueues = json_array(timeline.get("nodes")) + if not enqueues: + return False + for item in enqueues: event = json_object(item) if _actor_key(event.get("actor")) != identity: return False diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py index 634be28a11..d22c0bd7d7 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py @@ -1061,6 +1061,16 @@ def test_a_human_enqueue_before_the_identitys_needs_its_pin(self) -> None: enqueue(APP_ACTOR, APP_ACTOR), ) + def test_no_recorded_enqueue_needs_the_pin(self) -> None: + # A timeline with no enqueue in it (lagging, or never recorded) is no + # evidence of the identity's enqueue; the identity's mergedBy keeps + # the merge the identity's. + self.assert_pin_required( + "native", + timelineItems={"pageInfo": {"hasPreviousPage": False}, "nodes": []}, + mergedBy=APP_ACTOR, + ) + def test_enqueues_older_than_the_read_need_the_pin(self) -> None: self.assert_pin_required("native", earlier=True)