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.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
```

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@1170353958fb31718977dfff465ce9e1a89b8db4
- uses: Forward-Future/DeployBot@415f0a360bad2e54b156b330c2d21e3a64823a5a
```

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.15",
"version": "0.2.16",
"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@1170353958fb31718977dfff465ce9e1a89b8db4",
"deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@415f0a360bad2e54b156b330c2d21e3a64823a5a",
"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.15",
"version": "0.2.16",
"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@1170353958fb31718977dfff465ce9e1a89b8db4",
"deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@415f0a360bad2e54b156b330c2d21e3a64823a5a",
"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.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
Expand Down
4 changes: 2 additions & 2 deletions examples/github-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.15"
version = "0.2.16"
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.15"
__version__ = "0.2.16"
132 changes: 97 additions & 35 deletions src/agent_merge_queue/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"]],
Expand Down Expand Up @@ -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
Expand Down
Loading