diff --git a/README.md b/README.md index 8591cb0..99851d3 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.23` source commit directly from GitHub: +Install the reviewed `v0.2.24` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@de0819770c2496b5048488c2a6a207be0378af06' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@73004ea7c9dcb81e7f1281c0687aea0897d1571d' 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@de0819770c2496b5048488c2a6a207be0378af06 +- uses: Forward-Future/DeployBot@73004ea7c9dcb81e7f1281c0687aea0897d1571d ``` 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 e9a73a3..3197f1a 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.23", + "version": "0.2.24", "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 18c6c84..dff1ee2 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@de0819770c2496b5048488c2a6a207be0378af06", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@73004ea7c9dcb81e7f1281c0687aea0897d1571d", "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 e8ea10a..d0ec9d3 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.23", + "version": "0.2.24", "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 18c6c84..dff1ee2 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@de0819770c2496b5048488c2a6a207be0378af06", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@73004ea7c9dcb81e7f1281c0687aea0897d1571d", "deploybot-mcp" ] } diff --git a/docs/reference.md b/docs/reference.md index 364e32d..ece430c 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.23. GitHub labels and authenticated comments are the durable state; +DeployBot v0.2.24. 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 f7de978..e1a6423 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -77,8 +77,8 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - # v0.2.23 implementation; keep the full commit for privileged workflows. - - uses: Forward-Future/DeployBot@de0819770c2496b5048488c2a6a207be0378af06 + # v0.2.24 implementation; keep the full commit for privileged workflows. + - uses: Forward-Future/DeployBot@73004ea7c9dcb81e7f1281c0687aea0897d1571d with: # PR and review events reconcile quickly. Only release-owner events # stay attached to cumulative main through CI and deployment. diff --git a/pyproject.toml b/pyproject.toml index ca92a3f..f4e76ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deploybot-merge-queue" -version = "0.2.23" +version = "0.2.24" 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 bb457a9..52ab33d 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.23" +__version__ = "0.2.24" diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index 9b221c5..9a570ad 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -1199,6 +1199,16 @@ def pull_head(self, number: int) -> dict[str, str]: "state": str(value.get("state") or ""), } + def reopen_pull_request(self, number: int) -> None: + self._json( + "api", + "--method", + "PATCH", + f"repos/{self.repository}/pulls/{number}", + "-f", + "state=open", + ) + def source_deploy_authorized(self, number: int, expected_head: str) -> bool: source = self._json( "pr", @@ -3512,6 +3522,12 @@ def record_integration_conflict_repair( source_heads=frozen_heads, source_pull_requests=frozen_numbers, ) + for source_number in frozen_numbers: + if source_number == owner.number: + continue + source_labels = client.labels(source_number) + if client.config.blocked_label not in source_labels: + client.add_label(source_number, client.config.blocked_label) result["repair_owner"] = { "pull_request": owner.number, "provider": repair.get("provider"), @@ -3587,6 +3603,12 @@ def record_integration_ci_repair( source_heads=frozen_heads, source_pull_requests=frozen_numbers, ) + for source_number in frozen_numbers: + if source_number == owner.number: + continue + source_labels = client.labels(source_number) + if client.config.blocked_label not in source_labels: + client.add_label(source_number, client.config.blocked_label) # Publish the terminal integration marker only after the durable repair # handoff exists. If source reads or notification writes fail, the clean # marker remains retryable on the next reconciliation. @@ -3750,14 +3772,19 @@ def evaluate( ) return "blocked", {"number": number, "reason": reason}, entry if client.config.blocked_label in entry.labels: - # 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 repair and repair.get("repair_pull_request"): + return ( + "waiting", + { + "number": number, + "reasons": [ + "cumulative integration repair must resume before any " + "frozen source can merge" + ], + }, + entry, + ) + if repair_marker_is_transitional(repair): if deployment_repair_required(entry): reason = "; ".join(entry.reasons or ["blocked"]) repair = record_repair(client, entry, intent, reason) @@ -4199,6 +4226,9 @@ def command_resume(client: GitHub, selector: str | None) -> None: ) if integration: pull = client.pull_head(number) + if pull.get("state") == "CLOSED": + client.reopen_pull_request(number) + pull = client.pull_head(number) branch = str(pull.get("branch") or "") head_sha = str(pull.get("head_sha") or "") known_checks = completed_integration_ci_checks( diff --git a/tests/test_cli.py b/tests/test_cli.py index 727a0af..7c0d6c8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3993,7 +3993,7 @@ def test_record_repair_replaces_packet_missing_frozen_members( self.assertEqual(refreshed["source_heads"]["8"], "8" * 40) client.comment.assert_called_once_with(7, repair_body(refreshed)) - def test_promote_recovers_owner_after_conflicted_integration_closes(self) -> None: + def test_promote_keeps_owner_blocked_if_integration_closes(self) -> None: ready = entry(1) ready.labels = ["deploy-requested", "merge-queue-blocked"] intent_comment = { @@ -4036,9 +4036,11 @@ def test_promote_recovers_owner_after_conflicted_integration_closes(self) -> Non 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") + self.assertEqual(result["promoted"], []) + self.assertEqual(result["waiting"][0]["number"], 1) + self.assertIn("cumulative integration repair", result["waiting"][0]["reasons"][0]) + client.remove_label.assert_not_called() + client.add_label.assert_not_called() def test_promote_does_not_hold_resumed_repair_against_itself(self) -> None: ready = entry(1) @@ -4799,7 +4801,10 @@ def test_conflicted_integration_elects_one_tracked_repair_owner(self) -> None: repair["source_heads"], {"1": first.head_sha, "2": second.head_sha}, ) - client.add_label.assert_called_once_with(1, CONFIG.blocked_label) + self.assertEqual( + [value.args for value in client.add_label.call_args_list], + [(1, CONFIG.blocked_label), (2, CONFIG.blocked_label)], + ) self.assertTrue( any( "deploybot resume 99" in value.args[1] @@ -4942,6 +4947,52 @@ def test_failed_integration_stays_retryable_until_repair_owner_exists( client.comment.assert_not_called() client.add_label.assert_not_called() + def test_failed_integration_blocks_every_frozen_source(self) -> None: + owner = entry(1, "shared.py") + peer = entry(2, "shared.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=owner.head_sha, + provider="codex", + thread_id="thread-1", + ), + } + result = { + "pull_request": 99, + "integration": { + "batch_id": "batch-1", + "conflict": None, + "heads": {"1": owner.head_sha, "2": peer.head_sha}, + "pull_requests": [1, 2], + }, + "reason": "integration PR #99 CI failed: CI", + "state": "blocked", + } + client = Mock() + client.config = CONFIG + client.trusted_logins = {"trusted"} + client.snapshot.side_effect = [owner] + client.comments.return_value = [intent] + client.labels.return_value = set() + client.base_sha.return_value = "b" * 40 + + record_integration_ci_repair(client, result) + + self.assertEqual( + [value.args for value in client.add_label.call_args_list], + [ + (1, CONFIG.blocked_label), + (2, CONFIG.blocked_label), + (99, CONFIG.blocked_label), + ], + ) + def test_resumed_integration_finishes_source_label_delegation(self) -> None: integration_head = "9" * 40 marker = { @@ -4965,11 +5016,18 @@ def test_resumed_integration_finishes_source_label_delegation(self) -> None: client.coordinator_logins = {"coordinator"} client.resolve_pr.return_value = 99 client.comments.return_value = comments - client.pull_head.return_value = { - "branch": "deploybot/integration/batch-1", - "head_sha": integration_head, - "state": "OPEN", - } + client.pull_head.side_effect = [ + { + "branch": "deploybot/integration/batch-1", + "head_sha": integration_head, + "state": "CLOSED", + }, + { + "branch": "deploybot/integration/batch-1", + "head_sha": integration_head, + "state": "OPEN", + }, + ] client.workflow_runs_for_branch.return_value = [ { "id": 7, @@ -5000,6 +5058,7 @@ def test_resumed_integration_finishes_source_label_delegation(self) -> None: with redirect_stdout(io.StringIO()): command_resume(client, "99") + client.reopen_pull_request.assert_called_once_with(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], diff --git a/tests/test_skill.py b/tests/test_skill.py index 9805f13..97ad62e 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 = "de0819770c2496b5048488c2a6a207be0378af06" +RELEASE_COMMIT = "73004ea7c9dcb81e7f1281c0687aea0897d1571d" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"