diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index 21b6c04..1b6069a 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -1672,12 +1672,21 @@ def create_integration_pull_request( except QueueError as error: if "Reference already exists" not in str(error): raise + ref = self._json("api", f"repos/{self.repository}/git/ref/heads/{branch}") + branch_head = str((ref.get("object") or {}).get("sha") or "") + if not branch_head: + raise QueueError(f"integration branch {branch} has no readable head") + else: + branch_head = base_sha merged_heads: list[str] = [] conflict: dict[str, Any] | None = None for entry in entries: + if self.is_ancestor(entry.head_sha, branch_head): + merged_heads.append(entry.head_sha) + continue try: - self._json( + merged = self._json( "api", "--method", "POST", @@ -1689,8 +1698,22 @@ def create_integration_pull_request( "-f", f"commit_message=DeployBot batch {batch_id}: PR #{entry.number}", ) + branch_head = str((merged or {}).get("sha") or branch_head) merged_heads.append(entry.head_sha) except QueueError as error: + # GitHub returns 204 with an empty body when a concurrent + # update makes this merge a no-op. _json cannot decode that + # response, so re-read the authoritative branch head and + # accept it only when the exact frozen source is now present. + if str(error) == "GitHub returned invalid JSON": + ref = self._json( + "api", f"repos/{self.repository}/git/ref/heads/{branch}" + ) + current_head = str((ref.get("object") or {}).get("sha") or "") + if current_head and self.is_ancestor(entry.head_sha, current_head): + branch_head = current_head + merged_heads.append(entry.head_sha) + continue conflict = { "number": entry.number, "head_sha": entry.head_sha, diff --git a/tests/test_cli.py b/tests/test_cli.py index dabe23a..010de58 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2426,6 +2426,7 @@ def test_integration_can_require_non_actions_author(self) -> None: client.owner = "example" client.coordinator_logins = {"trusted"} client.base_sha = Mock(return_value="b" * 40) + client.is_ancestor = Mock(return_value=False) client._json = Mock( side_effect=[ {}, @@ -2460,6 +2461,7 @@ def test_integration_requires_app_author_to_be_a_coordinator(self) -> None: client.owner = "example" client.coordinator_logins = {"trusted"} client.base_sha = Mock(return_value="b" * 40) + client.is_ancestor = Mock(return_value=False) client._json = Mock( side_effect=[ {}, @@ -3619,6 +3621,7 @@ def test_integration_pr_contains_every_frozen_head(self) -> None: client.owner = "example" client.name = "repo" client.base_sha = Mock(return_value="b" * 40) + client.is_ancestor = Mock(return_value=False) client._json = Mock( side_effect=[ {}, @@ -3644,6 +3647,13 @@ def test_integration_pr_contains_every_frozen_head(self) -> None: if "repos/example/repo/merges" in call.args ] self.assertEqual(len(merge_calls), 2) + self.assertEqual( + [call.args for call in client.is_ancestor.call_args_list], + [ + (first.head_sha, "b" * 40), + (second.head_sha, "1" * 40), + ], + ) self.assertEqual( [call.args for call in client.remove_label.call_args_list], [ @@ -3670,6 +3680,7 @@ def test_conflicted_integration_keeps_source_intents_discoverable(self) -> None: client.owner = "example" client.name = "repo" client.base_sha = Mock(return_value="b" * 40) + client.is_ancestor = Mock(return_value=False) client._json = Mock( side_effect=[ {}, @@ -3830,6 +3841,89 @@ def test_resumed_integration_finishes_source_label_delegation(self) -> None: ], ) + def test_integration_skips_source_already_contained_transitively(self) -> None: + cumulative = entry(1, "shared.py") + contained = entry(2, "shared.py") + batch = new_batch([cumulative, contained], frozen_at="2026-06-20T00:00:00Z") + client = object.__new__(GitHub) + client.config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "integration": {"mode": "all"}, + } + ) + client.repository = "example/repo" + client.owner = "example" + client.name = "repo" + client.base_sha = Mock(return_value="b" * 40) + client.is_ancestor = Mock(side_effect=[False, True]) + client._json = Mock( + side_effect=[ + {}, + {"sha": "m" * 40}, + [], + {"number": 99, "html_url": "https://example.test/99"}, + ] + ) + client.comment = Mock() + client.labels = Mock(return_value={"merge-queue", "deploy-requested"}) + client.remove_label = Mock() + + result = client.create_integration_pull_request( + batch=batch, entries=[cumulative, contained] + ) + + self.assertIsNone(result["conflict"]) + merge_calls = [ + call + for call in client._json.call_args_list + if "repos/example/repo/merges" in call.args + ] + self.assertEqual(len(merge_calls), 1) + marker = client.comment.call_args.args[1] + self.assertIn(cumulative.head_sha, marker) + self.assertIn(contained.head_sha, marker) + + def test_integration_recovers_empty_merge_response_after_race(self) -> None: + source = entry(1, "shared.py") + batch = new_batch([source], frozen_at="2026-06-20T00:00:00Z") + client = object.__new__(GitHub) + client.config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "integration": {"mode": "all"}, + } + ) + client.repository = "example/repo" + client.owner = "example" + client.name = "repo" + client.base_sha = Mock(return_value="b" * 40) + client.is_ancestor = Mock(side_effect=[False, True]) + client._json = Mock( + side_effect=[ + {}, + QueueError("GitHub returned invalid JSON"), + {"object": {"sha": "m" * 40}}, + [], + {"number": 99, "html_url": "https://example.test/99"}, + ] + ) + client.comment = Mock() + client.labels = Mock(return_value={"merge-queue", "deploy-requested"}) + client.remove_label = Mock() + + result = client.create_integration_pull_request(batch=batch, entries=[source]) + + self.assertIsNone(result["conflict"]) + marker = client.comment.call_args.args[1] + self.assertIn(source.head_sha, marker) + def test_failed_integration_pr_creation_removes_its_orphan_branch(self) -> None: first = entry(1, "shared.py") second = entry(2, "shared.py") @@ -3848,6 +3942,7 @@ def test_failed_integration_pr_creation_removes_its_orphan_branch(self) -> None: client.owner = "example" client.name = "repo" client.base_sha = Mock(return_value="b" * 40) + client.is_ancestor = Mock(return_value=False) client._json = Mock( side_effect=[ {},