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.3` source commit directly from GitHub:
Install the reviewed `v0.2.4` source commit directly from GitHub:

```bash
python3 -m pip install \
'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@c9e3d6976b372ef5bcbb2986b0afd6bb1afe9470'
'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@9986210b077ebb4c53f2c5b1bbd461ba02dfce19'
deploybot init
```

Expand Down Expand Up @@ -86,7 +86,7 @@ aligned with `pipeline.ci_workflows`. The privileged worker never checks out or
executes pull-request code. Pin the Action to the full reviewed release commit:

```yaml
- uses: Forward-Future/DeployBot@c9e3d6976b372ef5bcbb2986b0afd6bb1afe9470
- uses: Forward-Future/DeployBot@9986210b077ebb4c53f2c5b1bbd461ba02dfce19
```

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.3",
"version": "0.2.4",
"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@c9e3d6976b372ef5bcbb2986b0afd6bb1afe9470",
"deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@9986210b077ebb4c53f2c5b1bbd461ba02dfce19",
"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.3",
"version": "0.2.4",
"description": "Review, queue, and land pull requests through a shared GitHub merge queue",
"author": {
"name": "DeployBot contributors"
Expand Down
2 changes: 1 addition & 1 deletion adapters/codex/agent-merge-queue/.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@c9e3d6976b372ef5bcbb2986b0afd6bb1afe9470",
"deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@9986210b077ebb4c53f2c5b1bbd461ba02dfce19",
"deploybot-mcp"
]
}
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@c9e3d6976b372ef5bcbb2986b0afd6bb1afe9470",
"deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@9986210b077ebb4c53f2c5b1bbd461ba02dfce19",
"deploybot-mcp"
]
}
Expand Down
30 changes: 24 additions & 6 deletions examples/github-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,33 @@ concurrency:
jobs:
react:
if: >-
github.event_name != 'pull_request_target' ||
github.event.action != 'labeled' ||
github.event.label.name == 'deploy-requested' ||
github.event.label.name == 'merge-queue'
(
github.event_name != 'pull_request_target' ||
(
github.event.action == 'labeled' &&
(
github.event.label.name == 'deploy-requested' ||
github.event.label.name == 'merge-queue'
)
) ||
(
github.event.action != 'labeled' &&
github.event.pull_request.head.repo.full_name == github.repository
)
) &&
(
github.event_name != 'check_suite' ||
(
github.event.check_suite.app.slug != 'github-actions' &&
github.event.check_suite.pull_requests[0].base.ref ==
github.event.repository.default_branch
)
)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
# v0.2.3 implementation; keep the full commit for privileged workflows.
- uses: Forward-Future/DeployBot@c9e3d6976b372ef5bcbb2986b0afd6bb1afe9470
# v0.2.4 implementation; keep the full commit for privileged workflows.
- uses: Forward-Future/DeployBot@9986210b077ebb4c53f2c5b1bbd461ba02dfce19
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.3"
version = "0.2.4"
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.3"
__version__ = "0.2.4"
165 changes: 88 additions & 77 deletions src/agent_merge_queue/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from typing import Any, Callable, Iterable

from . import __version__
from .config import ConfigError, QueueConfig, initialize_config, load_config
Expand Down Expand Up @@ -319,6 +319,7 @@ def effective_queue_marker(
coordinator_logins: Iterable[str],
*,
intent_scope: str = "head",
integration_authorized: Callable[[dict[str, Any]], bool] | None = None,
) -> dict[str, Any] | None:
"""Accept coordinator state only when rooted in a live trusted deploy intent."""
values = list(comments)
Expand All @@ -341,6 +342,10 @@ def effective_queue_marker(
delegated
and integration
and delegated.get("integration_batch_id") == integration.get("batch_id")
and (
integration_authorized is None
or integration_authorized(integration)
)
):
return delegated
return direct
Expand All @@ -356,6 +361,19 @@ def coordinator_logins(client: Any) -> set[str]:
return set()


def queue_marker_for_client(
client: Any, comments: Iterable[dict[str, Any]]
) -> dict[str, Any] | None:
validator = getattr(client, "integration_sources_authorized", None)
return effective_queue_marker(
comments,
client.trusted_logins,
coordinator_logins(client),
intent_scope=client.config.pipeline.intent_scope,
integration_authorized=validator if callable(validator) else None,
)


def require_running_pipeline(client: Any) -> None:
reader = getattr(client, "pipeline_control", None)
if not callable(reader):
Expand Down Expand Up @@ -785,14 +803,53 @@ def base_sha(self) -> str:
return str(value["sha"])

def workflow_runs(self) -> list[dict[str, Any]]:
value = self._json(
"api",
return self._paged_object_items(
f"repos/{self.repository}/actions/runs?branch={self.config.base_branch}&per_page=100",
"workflow_runs",
)

def source_deploy_authorized(self, number: int, expected_head: str) -> bool:
source = self._json(
"pr",
"view",
str(number),
"--repo",
self.repository,
"--json",
"headRefOid",
)
if source.get("headRefOid") != expected_head:
return False
comments = self.comments(number)
intent = latest_intent(comments, self.trusted_logins)
direct = latest_marker(comments, self.trusted_logins)
return bool(
(
intent
and intent.get("state") == "requested"
and intent.get("requested_head") == expected_head
)
or (
direct
and direct.get("state") == "queued"
and direct.get("head_sha") == expected_head
)
)
runs = value.get("workflow_runs") if isinstance(value, dict) else None
if not isinstance(runs, list):
raise QueueError("GitHub returned invalid workflow-run data")
return [run for run in runs if isinstance(run, dict)]

def integration_sources_authorized(self, integration: dict[str, Any]) -> bool:
expected_heads = integration.get("heads") or {}
sources = integration.get("pull_requests") or []
if not sources:
return False
for value in sources:
try:
number = int(value)
except (TypeError, ValueError):
return False
expected = str(expected_heads.get(str(number)) or "")
if not expected or not self.source_deploy_authorized(number, expected):
return False
return True

def dispatch_ci_workflows(self) -> list[dict[str, Any]]:
configured = self.config.pipeline.ci_workflows
Expand Down Expand Up @@ -1044,6 +1101,19 @@ def _paged_api(self, endpoint: str) -> list[dict[str, Any]]:
values.extend(item for item in page if isinstance(item, dict))
return values

def _paged_object_items(
self, endpoint: str, key: str
) -> list[dict[str, Any]]:
data = self._json("api", "--paginate", "--slurp", endpoint)
pages = data if isinstance(data, list) else []
values: list[dict[str, Any]] = []
for page in pages:
items = page.get(key) if isinstance(page, dict) else None
if not isinstance(items, list):
raise QueueError(f"unexpected GitHub response for {endpoint}")
values.extend(item for item in items if isinstance(item, dict))
return values

def comments(self, number: int) -> list[dict[str, Any]]:
return self._paged_api(
f"repos/{self.repository}/issues/{number}/comments?per_page=100"
Expand Down Expand Up @@ -1127,12 +1197,7 @@ def snapshot(
for provider in self.config.review_providers
)
threads = self.review_threads(number) if needs_threads else []
marker = effective_queue_marker(
comments,
self.trusted_logins,
self.coordinator_logins,
intent_scope=self.config.pipeline.intent_scope,
)
marker = queue_marker_for_client(self, comments)
head_sha = str(pull["headRefOid"])
checks = check_states(pull.get("statusCheckRollup") or [])
changed_files = self.files(number)
Expand Down Expand Up @@ -1302,12 +1367,7 @@ def merge(self, number: int, head_sha: str) -> str:
raise QueueError(f"PR #{number} changed immediately before merge")
if self.config.queue_label not in labels or self.config.blocked_label in labels:
raise QueueError(f"PR #{number} queue authorization was revoked")
marker = effective_queue_marker(
self.comments(number),
self.trusted_logins,
getattr(self, "coordinator_logins", self.trusted_logins),
intent_scope=self.config.pipeline.intent_scope,
)
marker = queue_marker_for_client(self, self.comments(number))
if (
not marker
or marker.get("state") != "queued"
Expand All @@ -1326,34 +1386,10 @@ def merge(self, number: int, head_sha: str) -> str:
expected_heads = integration.get("heads") or {}
for source_number in integration.get("pull_requests") or []:
source_number = int(source_number)
source = self._json(
"pr",
"view",
str(source_number),
"--repo",
self.repository,
"--json",
"headRefOid",
)
expected = str(expected_heads.get(str(source_number)) or "")
if not expected or source.get("headRefOid") != expected:
raise QueueError(
f"source PR #{source_number} changed after integration freeze"
)
source_comments = self.comments(source_number)
intent = latest_intent(source_comments, self.trusted_logins)
direct_marker = latest_marker(source_comments, self.trusted_logins)
has_intent = bool(
intent
and intent.get("state") == "requested"
and intent.get("requested_head") == expected
)
has_direct_authorization = bool(
direct_marker
and direct_marker.get("state") == "queued"
and direct_marker.get("head_sha") == expected
)
if not has_intent and not has_direct_authorization:
if not expected or not self.source_deploy_authorized(
source_number, expected
):
raise QueueError(
f"source PR #{source_number} deploy authorization was revoked"
)
Expand Down Expand Up @@ -1999,12 +2035,7 @@ def queue_from_intent(
"run deploybot refresh-request after fresh review"
)
comments = client.comments(entry.number)
previous = effective_queue_marker(
comments,
client.trusted_logins,
coordinator_logins(client),
intent_scope=client.config.pipeline.intent_scope,
)
previous = queue_marker_for_client(client, comments)
queued_at = queue_timestamp(
previous,
already_queued=client.config.queue_label in entry.labels,
Expand Down Expand Up @@ -2158,12 +2189,7 @@ def command_block(client: GitHub, selector: str | None, reason: str) -> None:
client.ensure_labels()
entry = client.snapshot(number, require_marker=False, allow_blocked_label=True)
comments = client.comments(number)
previous = effective_queue_marker(
comments,
client.trusted_logins,
coordinator_logins(client),
intent_scope=client.config.pipeline.intent_scope,
)
previous = queue_marker_for_client(client, comments)
if (
not previous
or previous.get("state") != "queued"
Expand Down Expand Up @@ -2195,12 +2221,7 @@ def command_block(client: GitHub, selector: str | None, reason: str) -> None:
def command_unblock(client: GitHub, selector: str | None) -> None:
number = client.resolve_pr(selector)
entry = client.snapshot(number, require_marker=False, allow_blocked_label=True)
previous = effective_queue_marker(
client.comments(number),
client.trusted_logins,
coordinator_logins(client),
intent_scope=client.config.pipeline.intent_scope,
)
previous = queue_marker_for_client(client, client.comments(number))
if (
not previous
or previous.get("state") != "blocked"
Expand Down Expand Up @@ -2228,12 +2249,7 @@ def command_unblock(client: GitHub, selector: str | None) -> None:
def command_dequeue(client: GitHub, selector: str | None, reason: str) -> None:
number = client.resolve_pr(selector)
entry = client.snapshot(number, require_marker=False, allow_blocked_label=True)
previous = effective_queue_marker(
client.comments(number),
client.trusted_logins,
coordinator_logins(client),
intent_scope=client.config.pipeline.intent_scope,
)
previous = queue_marker_for_client(client, client.comments(number))
labels = client.labels(number)
if client.config.blocked_label not in labels:
client.add_label(number, client.config.blocked_label)
Expand Down Expand Up @@ -2577,7 +2593,7 @@ def command_follow(
poll_seconds=poll_seconds,
)
if (
result["state"] in {"ci-failed", "deploy-failed"}
result["state"] in {"ci-failed", "deploy-failed", "verify-failed"}
and client.config.pipeline.pause_on_failure
):
client.set_pipeline_control(
Expand Down Expand Up @@ -2718,12 +2734,7 @@ def delivery_metrics(client: GitHub, *, limit: int) -> dict[str, Any]:
number = int(pull["number"])
comments = client.comments(number)
intent = latest_intent(comments, client.trusted_logins)
marker = effective_queue_marker(
comments,
client.trusted_logins,
coordinator_logins(client),
intent_scope=client.config.pipeline.intent_scope,
)
marker = queue_marker_for_client(client, comments)
merged_at = str(pull.get("merged_at") or "") or None
merge_sha = str(pull.get("merge_commit_sha") or "")
live_at: str | None = None
Expand Down
Loading