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
11 changes: 8 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.14` source commit directly from GitHub:
Install the reviewed `v0.2.15` source commit directly from GitHub:

```bash
python3 -m pip install \
'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@a5f80423f5168873d0801694baaf9d8b35d96ebd'
'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@1170353958fb31718977dfff465ce9e1a89b8db4'
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@a5f80423f5168873d0801694baaf9d8b35d96ebd
- uses: Forward-Future/DeployBot@1170353958fb31718977dfff465ce9e1a89b8db4
```

The Action uses GitHub's built-in workflow token. GitHub intentionally does not
Expand Down Expand Up @@ -181,6 +181,11 @@ controller recognizes them:
In `overlap` mode, a ready source waits when another active, near-ready intent
belongs to the same source-overlap component. Unrelated ready work still drains,
and the held component freezes together once its remaining gates pass.
When more than one cumulative integration pull request needs controller-owned
exact-head CI, DeployBot dispatches every missing workflow before it waits. The
workflows then run in parallel instead of making later batches wait for an
earlier runner delay. Slow-queue status names the missing, queued, or failed
exact integration workflow instead of reporting only a generic merge worker.

```bash
deploybot resume <pr-number>
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.14",
"version": "0.2.15",
"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@a5f80423f5168873d0801694baaf9d8b35d96ebd",
"deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@1170353958fb31718977dfff465ce9e1a89b8db4",
"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.14",
"version": "0.2.15",
"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@a5f80423f5168873d0801694baaf9d8b35d96ebd",
"deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@1170353958fb31718977dfff465ce9e1a89b8db4",
"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.14. GitHub labels and authenticated comments are the durable state;
DeployBot v0.2.15. 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.14 implementation; keep the full commit for privileged workflows.
- uses: Forward-Future/DeployBot@a5f80423f5168873d0801694baaf9d8b35d96ebd
# v0.2.15 implementation; keep the full commit for privileged workflows.
- uses: Forward-Future/DeployBot@1170353958fb31718977dfff465ce9e1a89b8db4
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.14"
version = "0.2.15"
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.14"
__version__ = "0.2.15"
205 changes: 141 additions & 64 deletions src/agent_merge_queue/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,34 @@ def check_states(checks: Iterable[dict[str, Any]]) -> dict[str, str]:
return result


def latest_exact_workflow_runs(
runs: Iterable[dict[str, Any]],
names: Iterable[str],
*,
head_sha: str,
event: str = "workflow_dispatch",
) -> dict[str, dict[str, Any]]:
configured = set(names)
latest: dict[str, dict[str, Any]] = {}
for run in runs:
name = str(run.get("name") or "")
if (
name not in configured
or str(run.get("head_sha") or "") != head_sha
or str(run.get("event") or "") != event
):
continue
previous = latest.get(name)
key = (str(run.get("created_at") or ""), int(run.get("id") or 0))
previous_key = (
str((previous or {}).get("created_at") or ""),
int((previous or {}).get("id") or 0),
)
if previous is None or key > previous_key:
latest[name] = run
return latest


def trusted_comment(
comment: dict[str, Any],
trusted_logins: str | Iterable[str],
Expand Down Expand Up @@ -2312,6 +2340,37 @@ def command_inspect(
return entry


def integration_ci_active_gate(client: GitHub, entry: QueueEntry) -> str | None:
pull = client.pull_head(entry.number)
branch = str(pull.get("branch") or "")
head_sha = str(pull.get("head_sha") or "")
if pull.get("state") != "OPEN" or not branch:
return "integration pull request is no longer open"
if head_sha != entry.head_sha:
return "integration head changed while waiting for exact CI"
configured = tuple(client.config.pipeline.ci_workflows)
latest = latest_exact_workflow_runs(
client.workflow_runs_for_branch(branch),
configured,
head_sha=head_sha,
)
waiting: list[str] = []
for name in configured:
run = latest.get(name)
if run is None:
waiting.append(f"{name} has not been dispatched")
continue
status = str(run.get("status") or "pending")
conclusion = str(run.get("conclusion") or "")
if status != "completed":
waiting.append(f"{name} is {status.replace('_', ' ')}")
elif conclusion != "success":
waiting.append(f"{name} {conclusion or 'failed'}")
if waiting:
return "waiting for exact integration CI: " + "; ".join(waiting)
return None


def pipeline_status(client: GitHub) -> dict[str, Any]:
queued = client.queue()
queued_by_number = {entry.number: entry for entry in queued}
Expand Down Expand Up @@ -2430,6 +2489,12 @@ def inspect(
now = datetime.now(timezone.utc)
alerts: list[dict[str, Any]] = []
queue_target = client.config.pipeline.ready_to_merge_target_minutes * 60
integration_values = client.integration_pull_request_numbers()
integration_numbers = (
{int(value) for value in integration_values}
if isinstance(integration_values, list)
else set()
)
for entry, intent, value in active_intents:
timestamp = parse_time(str(intent.get("requested_at") or ""))
elapsed = (now - timestamp).total_seconds() if timestamp else None
Expand Down Expand Up @@ -2458,13 +2523,16 @@ def inspect(
timestamp = parse_time(entry.queued_at)
elapsed = (now - timestamp).total_seconds() if timestamp else None
if elapsed is not None and elapsed > queue_target:
active_gate = "; ".join(entry.reasons or []) or "merge worker"
if not entry.reasons and entry.number in integration_numbers:
active_gate = integration_ci_active_gate(client, entry) or active_gate
alerts.append(
{
"stage": "queued-to-merge",
"pull_request": entry.number,
"elapsed_seconds": elapsed,
"target_seconds": queue_target,
"active_gate": "; ".join(entry.reasons or []) or "merge worker",
"active_gate": active_gate,
}
)
if delivery["state"] not in {"verified", "testing"}:
Expand Down Expand Up @@ -3210,8 +3278,8 @@ def settle_integration_checks(
selected = [int(value) for value in values]
else:
selected = [int(value) for value in numbers]
results: list[dict[str, Any]] = []
configured = tuple(client.config.pipeline.ci_workflows)
targets: dict[int, dict[str, Any]] = {}
for number in selected:
comments = client.comments(number)
integration = latest_payload(
Expand All @@ -3226,9 +3294,20 @@ def settle_integration_checks(
head_sha = str(pull.get("head_sha") or "")
if pull.get("state") != "OPEN" or not branch or not head_sha:
raise QueueError(f"integration PR #{number} is no longer open")
deadline = time.monotonic() + timeout_seconds
dispatched: list[dict[str, Any]] = []
while True:
targets[number] = {
"branch": branch,
"dispatched": [],
"head_sha": head_sha,
"requested": set(),
}

results: list[dict[str, Any]] = []
pending = dict(targets)
deadline = time.monotonic() + timeout_seconds
while pending:
for number, target in list(pending.items()):
branch = str(target["branch"])
head_sha = str(target["head_sha"])
current = client.pull_head(number)
if current.get("state") != "OPEN":
raise QueueError(f"integration PR #{number} is no longer open")
Expand All @@ -3237,71 +3316,69 @@ def settle_integration_checks(
f"integration PR #{number} changed while CI ownership was active"
)
runs = client.workflow_runs_for_branch(branch)
latest: dict[str, dict[str, Any]] = {}
for run in runs:
name = str(run.get("name") or "")
if (
name not in configured
or str(run.get("head_sha") or "") != head_sha
or str(run.get("event") or "") != "workflow_dispatch"
):
continue
previous = latest.get(name)
key = (str(run.get("created_at") or ""), int(run.get("id") or 0))
previous_key = (
str((previous or {}).get("created_at") or ""),
int((previous or {}).get("id") or 0),
latest = latest_exact_workflow_runs(
runs,
configured,
head_sha=head_sha,
)
failed = [
name
for name, run in latest.items()
if str(run.get("status") or "") == "completed"
and str(run.get("conclusion") or "") != "success"
]
if failed:
raise QueueError(
f"integration PR #{number} CI failed: " + ", ".join(failed)
)
if previous is None or key > previous_key:
latest[name] = run
missing = [name for name in configured if name not in latest]
undispatched = [
name for name in missing if name not in target["requested"]
]
if undispatched:
target["dispatched"].extend(
client.dispatch_ci_workflows(ref=branch, names=undispatched)
)
target["requested"].update(undispatched)
if missing:
dispatched.extend(
client.dispatch_ci_workflows(ref=branch, names=missing)
continue
if all(
str(run.get("status") or "") == "completed"
and str(run.get("conclusion") or "") == "success"
for run in latest.values()
):
exact_checks = check_states(client.commit_check_runs(head_sha))
entry = client.snapshot(
number,
require_marker=False,
allow_blocked_label=True,
known_checks=exact_checks,
)
else:
failed = [
name
for name, run in latest.items()
if str(run.get("status") or "") == "completed"
and str(run.get("conclusion") or "") != "success"
]
if failed:
raise QueueError(
f"integration PR #{number} CI failed: " + ", ".join(failed)
if entry.state == "ready":
results.append(
{
"branch": branch,
"dispatched_ci": target["dispatched"],
"checks": exact_checks,
"head_sha": head_sha,
"pull_request": number,
"state": "ready",
}
)
if all(
str(run.get("status") or "") == "completed"
and str(run.get("conclusion") or "") == "success"
for run in latest.values()
):
exact_checks = check_states(client.commit_check_runs(head_sha))
entry = client.snapshot(
number,
require_marker=False,
allow_blocked_label=True,
known_checks=exact_checks,
del pending[number]
continue
if entry.state == "blocked":
raise QueueError(
f"integration PR #{number} is blocked: "
+ "; ".join(entry.reasons or ["unknown gate"])
)
if entry.state == "ready":
results.append(
{
"branch": branch,
"dispatched_ci": dispatched,
"checks": exact_checks,
"head_sha": head_sha,
"pull_request": number,
"state": "ready",
}
)
break
if entry.state == "blocked":
raise QueueError(
f"integration PR #{number} is blocked: "
+ "; ".join(entry.reasons or ["unknown gate"])
)
if time.monotonic() >= deadline:
raise QueueError(f"integration PR #{number} CI timed out")
time.sleep(poll_seconds)
if not pending:
break
if time.monotonic() >= deadline:
numbers = ", ".join(f"#{number}" for number in pending)
noun = "PR" if len(pending) == 1 else "PRs"
raise QueueError(f"integration {noun} {numbers} CI timed out")
time.sleep(poll_seconds)
return results


Expand Down
Loading