diff --git a/README.md b/README.md index e1c4720..96fbfa2 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ integration PRs, follows `main` through production, and pauses after failures. ## Install -Install the reviewed `v0.2.15` source commit directly from GitHub: +Install the reviewed `v0.2.16` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@1170353958fb31718977dfff465ce9e1a89b8db4' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@415f0a360bad2e54b156b330c2d21e3a64823a5a' deploybot init ``` @@ -95,7 +95,7 @@ worker can dispatch deployment when GitHub suppresses the `workflow_run` event for token-dispatched CI. Pin the Action to the full reviewed release commit: ```yaml -- uses: Forward-Future/DeployBot@1170353958fb31718977dfff465ce9e1a89b8db4 +- uses: Forward-Future/DeployBot@415f0a360bad2e54b156b330c2d21e3a64823a5a ``` The Action uses GitHub's built-in workflow token. GitHub intentionally does not diff --git a/adapters/claude-code/.claude-plugin/plugin.json b/adapters/claude-code/.claude-plugin/plugin.json index eb109d2..82b138b 100644 --- a/adapters/claude-code/.claude-plugin/plugin.json +++ b/adapters/claude-code/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "deploybot", - "version": "0.2.15", + "version": "0.2.16", "description": "DeployBot: a provider-neutral GitHub merge queue for coding agents", "author": { "name": "DeployBot contributors" diff --git a/adapters/claude-code/.mcp.json b/adapters/claude-code/.mcp.json index 531069b..01e719d 100644 --- a/adapters/claude-code/.mcp.json +++ b/adapters/claude-code/.mcp.json @@ -4,7 +4,7 @@ "command": "uvx", "args": [ "--from", - "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@1170353958fb31718977dfff465ce9e1a89b8db4", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@415f0a360bad2e54b156b330c2d21e3a64823a5a", "deploybot-mcp" ] } diff --git a/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json b/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json index 26bc18f..622d6e0 100644 --- a/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json +++ b/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "deploybot", - "version": "0.2.15", + "version": "0.2.16", "description": "Coordinate exact-head pull requests through verified deployment and thread notification", "author": { "name": "DeployBot contributors" diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index 531069b..01e719d 100644 --- a/adapters/cursor/.cursor/mcp.json +++ b/adapters/cursor/.cursor/mcp.json @@ -4,7 +4,7 @@ "command": "uvx", "args": [ "--from", - "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@1170353958fb31718977dfff465ce9e1a89b8db4", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@415f0a360bad2e54b156b330c2d21e3a64823a5a", "deploybot-mcp" ] } diff --git a/docs/reference.md b/docs/reference.md index 1207139..97eceaa 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,7 +1,7 @@ # DeployBot reference This reference describes the CLI, MCP server, policy file, and GitHub Action in -DeployBot v0.2.15. GitHub labels and authenticated comments are the durable state; +DeployBot v0.2.16. GitHub labels and authenticated comments are the durable state; the CLI and MCP tools are two interfaces to the same operations. ## CLI diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index 3b8c696..552dd52 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -59,5 +59,5 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - # v0.2.15 implementation; keep the full commit for privileged workflows. - - uses: Forward-Future/DeployBot@1170353958fb31718977dfff465ce9e1a89b8db4 + # v0.2.16 implementation; keep the full commit for privileged workflows. + - uses: Forward-Future/DeployBot@415f0a360bad2e54b156b330c2d21e3a64823a5a diff --git a/pyproject.toml b/pyproject.toml index a09f40f..089b8d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deploybot-merge-queue" -version = "0.2.15" +version = "0.2.16" description = "DeployBot: a provider-neutral GitHub merge queue for coding agents" readme = "README.md" license = "MIT" diff --git a/src/agent_merge_queue/__init__.py b/src/agent_merge_queue/__init__.py index 6679f35..d531d45 100644 --- a/src/agent_merge_queue/__init__.py +++ b/src/agent_merge_queue/__init__.py @@ -1,3 +1,3 @@ """DeployBot: a provider-neutral GitHub merge queue for coding agents.""" -__version__ = "0.2.15" +__version__ = "0.2.16" diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index 67191d5..21b6c04 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -1792,17 +1792,14 @@ def create_integration_pull_request( "pull_requests": [entry.number for entry in entries], } self.comment(number, integration_body(marker)) - # The cumulative PR now owns this batch. Remove source queue labels only - # after its durable marker exists so repeated events cannot scaffold a - # second integration PR for the same source work. + # The cumulative PR now owns the queue position, but the source intent + # remains discoverable until delivery succeeds. This lets promotion + # recover the exact-head request if the integration closes, conflicts, + # or rejects a source that changed after the batch was frozen. for entry in entries: source_labels = self.labels(entry.number) - for label in ( - self.config.queue_label, - self.config.pipeline.intent_label, - ): - if label in source_labels: - self.remove_label(entry.number, label) + if self.config.queue_label in source_labels: + self.remove_label(entry.number, self.config.queue_label) return { "number": number, "url": pull.get("html_url"), @@ -2924,6 +2921,8 @@ def record_repair( entry: QueueEntry, intent: dict[str, Any] | None, reason: str, + *, + resume_pull_request: int | None = None, ) -> dict[str, Any]: comments = client.comments(entry.number) previous = latest_payload( @@ -2959,11 +2958,15 @@ def record_repair( "provider": (intent or {}).get("provider"), "pull_request": entry.number, "reason": reason, - "resume_command": f"deploybot resume {entry.number}", + "resume_command": ( + f"deploybot resume {resume_pull_request or entry.number}" + ), "source_paths": entry.source_paths, "thread_id": (intent or {}).get("thread_id"), "thread_url": (intent or {}).get("thread_url"), } + if resume_pull_request is not None: + payload["repair_pull_request"] = resume_pull_request client.comment(entry.number, repair_body(payload)) labels = client.labels(entry.number) if client.config.blocked_label not in labels: @@ -2989,6 +2992,59 @@ def record_repair( return payload +def record_integration_conflict_repair( + client: GitHub, + result: dict[str, Any], + entries: list[QueueEntry], +) -> dict[str, Any] | None: + """Elect one tracked source thread to repair a conflicted integration.""" + + conflict = result.get("conflict") + if not isinstance(conflict, dict): + return None + integration_number = int(result["number"]) + conflicting_number = int(conflict["number"]) + owner: QueueEntry | None = None + owner_intent: dict[str, Any] | None = None + for entry in entries: + intent = latest_intent(client.comments(entry.number), client.trusted_logins) + if ( + intent + and intent.get("state") == "requested" + and intent.get("provider") + and intent.get("thread_id") + ): + owner = entry + owner_intent = intent + break + if owner is None: + owner = next( + entry for entry in entries if entry.number == conflicting_number + ) + owner_intent = latest_intent( + client.comments(owner.number), client.trusted_logins + ) + reason = ( + f"integration PR #{integration_number} could not merge delegated PR " + f"#{conflicting_number}: {conflict.get('reason') or 'merge conflict'}; " + f"repair {result.get('branch') or 'the integration branch'} without " + "dropping any frozen source head" + ) + repair = record_repair( + client, + owner, + owner_intent, + reason, + resume_pull_request=integration_number, + ) + result["repair_owner"] = { + "pull_request": owner.number, + "provider": repair.get("provider"), + "thread_id": repair.get("thread_id"), + } + return repair + + def reconcile_externally_merged_threads(client: GitHub) -> list[dict[str, Any]]: reconciled: list[dict[str, Any]] = [] records = client.thread_records() @@ -3131,7 +3187,14 @@ def evaluate( ) return "blocked", {"number": number, "reason": reason}, entry if client.config.blocked_label in entry.labels: - if repair_marker_is_transitional(repair): + # A conflict repair targets the cumulative PR while it is open. + # If that integration disappears, active_integration_sources() no + # longer suppresses this source, so release the integration-owned + # hold and let the original exact-head intent recover normally. + integration_repair_abandoned = bool( + repair and repair.get("repair_pull_request") + ) + if repair_marker_is_transitional(repair) or integration_repair_abandoned: if deployment_repair_required(entry): reason = "; ".join(entry.reasons or ["blocked"]) repair = record_repair(client, entry, intent, reason) @@ -3247,8 +3310,12 @@ def promote_integrations( client.add_label(number, client.config.queue_label) for source_number in integration.get("pull_requests") or []: source_labels = client.labels(int(source_number)) - if client.config.queue_label in source_labels: - client.remove_label(int(source_number), client.config.queue_label) + for label in ( + client.config.queue_label, + client.config.blocked_label, + ): + if label in source_labels: + client.remove_label(int(source_number), label) promoted.append(number) notify( client.config.pipeline, @@ -3422,8 +3489,12 @@ def command_resume(client: GitHub, selector: str | None) -> None: client.remove_label(number, client.config.blocked_label) for source_number in integration.get("pull_requests") or []: source_labels = client.labels(int(source_number)) - if client.config.queue_label in source_labels: - client.remove_label(int(source_number), client.config.queue_label) + for label in ( + client.config.queue_label, + client.config.blocked_label, + ): + if label in source_labels: + client.remove_label(int(source_number), label) print(f"resumed and queued integration PR #{number} on {entry.head_sha}") return intent = latest_intent(comments, client.trusted_logins) @@ -4287,16 +4358,7 @@ def command_integrate(client: GitHub, *, all_entries: bool) -> dict[str, Any]: batch=frozen.batch, entries=selected, ) - if result.get("conflict"): - conflicting_number = int(result["conflict"]["number"]) - entry = next(value for value in selected if value.number == conflicting_number) - intent = latest_intent(client.comments(entry.number), client.trusted_logins) - record_repair( - client, - entry, - intent, - "integration branch conflict: " + str(result["conflict"]["reason"]), - ) + record_integration_conflict_repair(client, result, selected) notify( client.config.pipeline, "integration-created", @@ -5088,12 +5150,12 @@ def own_integration_checks( ) integrations: list[dict[str, Any]] = [] if frozen.batch is not None and client.config.integration.mode == "all": - integrations.append( - client.create_integration_pull_request( - batch=frozen.batch, - entries=frozen.queue, - ) + integration = client.create_integration_pull_request( + batch=frozen.batch, + entries=frozen.queue, ) + record_integration_conflict_repair(client, integration, frozen.queue) + integrations.append(integration) deferred_drain: dict[str, Any] = { "batch_id": frozen.batch["batch_id"], "batch_ids": [frozen.batch["batch_id"]], @@ -5121,12 +5183,12 @@ def own_integration_checks( overlap_entries = [ entry for entry in frozen.queue if entry.number in overlap_members ] - integrations.append( - client.create_integration_pull_request( - batch=frozen.batch, - entries=overlap_entries, - ) + integration = client.create_integration_pull_request( + batch=frozen.batch, + entries=overlap_entries, ) + record_integration_conflict_repair(client, integration, overlap_entries) + integrations.append(integration) created_clean = [ int(value["number"]) for value in integrations diff --git a/tests/test_cli.py b/tests/test_cli.py index a8e5b5d..dabe23a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -57,6 +57,7 @@ queue_from_intent, queue_timestamp, reconcile_externally_merged_threads, + record_integration_conflict_repair, record_repair, release_follow_needed, repair_overlap_hold_active, @@ -3281,6 +3282,53 @@ def test_promote_never_auto_resumes_a_repair_block(self) -> None: self.assertIn("deploybot resume", result["waiting"][0]["reasons"][0]) client.add_label.assert_not_called() + def test_promote_recovers_owner_after_conflicted_integration_closes(self) -> None: + ready = entry(1) + ready.labels = ["deploy-requested", "merge-queue-blocked"] + intent_comment = { + "id": 1, + "created_at": "2026-06-20T00:00:00Z", + "user": {"login": "trusted"}, + "body": intent_body( + intent_id="intent-1", + state="requested", + requested_at="2026-06-20T00:00:00Z", + requested_head=ready.head_sha, + ), + } + repair_comment = { + "id": 2, + "created_at": "2026-06-20T00:01:00Z", + "user": {"login": "coordinator"}, + "body": repair_body( + { + "head_sha": ready.head_sha, + "intent_id": "intent-1", + "pull_request": 1, + "reason": "integration PR #99 could not merge delegated PR #2", + "repair_pull_request": 99, + "resume_command": "deploybot resume 99", + } + ), + } + client = Mock() + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.intent_numbers.return_value = [1] + client.active_integration_sources.return_value = set() + client.comments.return_value = [intent_comment, repair_comment] + client.snapshot.return_value = ready + client.labels.return_value = set(ready.labels) + + with redirect_stdout(io.StringIO()): + result = command_promote(client) + + self.assertEqual(result["promoted"], [1]) + client.remove_label.assert_any_call(1, "merge-queue-blocked") + client.add_label.assert_called_with(1, "merge-queue") + def test_promote_does_not_hold_resumed_repair_against_itself(self) -> None: ready = entry(1) ready.labels = ["deploy-requested", "merge-queue"] @@ -3600,9 +3648,185 @@ def test_integration_pr_contains_every_frozen_head(self) -> None: [call.args for call in client.remove_label.call_args_list], [ (1, "merge-queue"), - (1, "deploy-requested"), (2, "merge-queue"), - (2, "deploy-requested"), + ], + ) + + def test_conflicted_integration_keeps_source_intents_discoverable(self) -> None: + first = entry(1, "shared.py") + second = entry(2, "shared.py") + batch = new_batch([first, second], frozen_at="2026-06-20T00:00:00Z") + client = object.__new__(GitHub) + client.config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "integration": {"mode": "overlap"}, + } + ) + client.repository = "example/repo" + client.owner = "example" + client.name = "repo" + client.base_sha = Mock(return_value="b" * 40) + client._json = Mock( + side_effect=[ + {}, + {"sha": "1" * 40}, + QueueError("gh: Merge conflict (HTTP 409)"), + [], + {"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=[first, second] + ) + + self.assertEqual(result["conflict"]["number"], 2) + self.assertEqual( + [value.args for value in client.remove_label.call_args_list], + [(1, "merge-queue"), (2, "merge-queue")], + ) + + def test_conflicted_integration_elects_one_tracked_repair_owner(self) -> None: + first = entry(1, "first.py") + second = entry(2, "second.py") + intent = { + "id": 1, + "created_at": "2026-06-20T00:00:00Z", + "user": {"login": "trusted"}, + "body": intent_body( + intent_id="intent-1", + state="requested", + requested_at="2026-06-20T00:00:00Z", + requested_head=first.head_sha, + provider="codex", + thread_id="thread-1", + ), + } + client = Mock() + client.config = CONFIG + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.comments.side_effect = lambda number: [intent] if number == 1 else [] + client.labels.return_value = {"deploy-requested"} + client.base_sha.return_value = "b" * 40 + result = { + "number": 99, + "branch": "deploybot/integration/batch-1", + "conflict": {"number": 2, "reason": "merge conflict"}, + } + + repair = record_integration_conflict_repair( + client, result, [first, second] + ) + + self.assertEqual(result["repair_owner"]["pull_request"], 1) + self.assertEqual(repair["resume_command"], "deploybot resume 99") + self.assertEqual(repair["repair_pull_request"], 99) + client.add_label.assert_called_once_with(1, CONFIG.blocked_label) + self.assertTrue( + any( + "deploybot resume 99" in value.args[1] + for value in client.comment.call_args_list + ) + ) + + def test_conflicted_integration_skips_cancelled_repair_owner(self) -> None: + first = entry(1, "first.py") + second = entry(2, "second.py") + + def tracked_intent( + value: QueueEntry, *, state: str, thread_id: str + ) -> dict[str, object]: + return { + "id": value.number, + "created_at": "2026-06-20T00:00:00Z", + "user": {"login": "trusted"}, + "body": intent_body( + intent_id=f"intent-{value.number}", + state=state, + requested_at="2026-06-20T00:00:00Z", + requested_head=value.head_sha, + provider="codex", + thread_id=thread_id, + ), + } + + client = Mock() + client.config = CONFIG + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.comments.side_effect = lambda number: [ + tracked_intent( + first if number == 1 else second, + state="cancelled" if number == 1 else "requested", + thread_id=f"thread-{number}", + ) + ] + client.labels.return_value = {"deploy-requested"} + client.base_sha.return_value = "b" * 40 + result = { + "number": 99, + "branch": "deploybot/integration/batch-1", + "conflict": {"number": 2, "reason": "merge conflict"}, + } + + repair = record_integration_conflict_repair( + client, result, [first, second] + ) + + self.assertEqual(result["repair_owner"]["pull_request"], 2) + self.assertEqual(repair["thread_id"], "thread-2") + + def test_resumed_integration_finishes_source_label_delegation(self) -> None: + integration_head = "9" * 40 + marker = { + "batch_id": "batch-1", + "conflict": {"number": 2, "reason": "merge conflict"}, + "heads": {"1": "1" * 40, "2": "2" * 40}, + "pull_requests": [1, 2], + } + comments = [ + { + "id": 1, + "created_at": "2026-06-20T00:00:00Z", + "user": {"login": "coordinator"}, + "body": integration_body(marker), + } + ] + integration = entry(99) + integration.head_sha = integration_head + client = Mock() + client.config = CONFIG + client.coordinator_logins = {"coordinator"} + client.resolve_pr.return_value = 99 + client.comments.return_value = comments + client.snapshot.return_value = integration + client.is_ancestor.return_value = True + source_labels = { + CONFIG.queue_label, + CONFIG.pipeline.intent_label, + CONFIG.blocked_label, + } + client.labels.side_effect = lambda number: set() if number == 99 else source_labels + + with redirect_stdout(io.StringIO()): + command_resume(client, "99") + + client.add_label.assert_called_once_with(99, CONFIG.queue_label) + self.assertEqual( + [value.args for value in client.remove_label.call_args_list], + [ + (1, CONFIG.queue_label), + (1, CONFIG.blocked_label), + (2, CONFIG.queue_label), + (2, CONFIG.blocked_label), ], ) @@ -4094,6 +4318,10 @@ def test_reactor_does_not_follow_conflicted_all_mode_integration_batch( with ( patch("agent_merge_queue.cli.promote_integrations", return_value=[]), patch("agent_merge_queue.cli.command_promote", return_value={}), + patch( + "agent_merge_queue.cli.record_integration_conflict_repair", + return_value={}, + ), patch("agent_merge_queue.cli.freeze_queue", return_value=frozen), patch("agent_merge_queue.cli.command_drain") as drain, patch("agent_merge_queue.cli.command_follow") as follow_release, diff --git a/tests/test_skill.py b/tests/test_skill.py index f387674..e6da9a7 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -8,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] CANONICAL = ROOT / "skills" / "deploybot" / "SKILL.md" -RELEASE_COMMIT = "1170353958fb31718977dfff465ce9e1a89b8db4" +RELEASE_COMMIT = "415f0a360bad2e54b156b330c2d21e3a64823a5a" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"