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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion adapters/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion adapters/claude-code/.mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
Expand Down
2 changes: 1 addition & 1 deletion adapters/codex/agent-merge-queue/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion adapters/cursor/.cursor/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
Expand Down
2 changes: 1 addition & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions examples/github-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/agent_merge_queue/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""DeployBot: a provider-neutral GitHub merge queue for coding agents."""

__version__ = "0.2.23"
__version__ = "0.2.24"
46 changes: 38 additions & 8 deletions src/agent_merge_queue/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
79 changes: 69 additions & 10 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 = {
Expand All @@ -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,
Expand Down Expand Up @@ -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],
Expand Down
2 changes: 1 addition & 1 deletion tests/test_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down