diff --git a/.mergequeue.example.toml b/.mergequeue.example.toml index 8e5bd44..50c3815 100644 --- a/.mergequeue.example.toml +++ b/.mergequeue.example.toml @@ -35,3 +35,29 @@ asset_version_pattern = '\?v=[0-9a-f]{12}' # check_name = "Review Bot" # require_formal_review = true # require_resolved_threads = true + +[pipeline] +intent_label = "deploy-requested" +pause_label = "deploybot-paused" +registry_label = "deploybot-registry" +registry_title = "DeployBot delivery registry" +thread_active_hours = 72 +ci_workflows = ["CI"] +deploy_workflows = ["Deploy"] +ready_to_merge_target_minutes = 15 +merge_to_live_target_minutes = 10 +auto_promote = true +intent_scope = "head" # safest: a trusted source agent refreshes replacement heads +pause_on_failure = true +# webhook_url_env = "DEPLOYBOT_WEBHOOK_URL" + +# [[pipeline.verifications]] +# name = "Public login" +# url = "https://example.com/login" +# expected_status = 200 + +[integration] +# manual, overlap, or all +mode = "manual" +branch_prefix = "deploybot/integration" +title_prefix = "DeployBot integration" diff --git a/README.md b/README.md index d97d3a9..2b92e2a 100644 --- a/README.md +++ b/README.md @@ -4,27 +4,30 @@ DeployBot is a provider-neutral GitHub merge queue for coding agents. Codex, Claude Code, Cursor, or any MCP client can prepare and review a pull request; the user keeps the final merge decision by saying `deploy`. -The queue stores authority in GitHub labels and authenticated comments. It pins -the exact reviewed head, freezes bursts, merges independent work back-to-back, -skips blockers, and reports overlapping source that needs one integration PR. +DeployBot stores authority in GitHub labels and authenticated comments. It +records deploy intent immediately, promotes the final exact reviewed head, +freezes bursts, merges independent work back-to-back, scaffolds cumulative +integration PRs, follows `main` through production, and pauses after failures. ## Install -Install the reviewed `v0.1.4` source commit directly from GitHub: +Install the reviewed `v0.2.0` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@f18dce436bae2d6b7009d9aa76b4bfc8d0aac5a8' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@2e884f65f182f6a9ddc4c4785288045bdf98f188' deploybot init ``` Invoke the bundled `$deploybot` skill to inspect or operate the queue. Typical -requests include “show the DeployBot queue,” “why is PR 42 blocked?”, and -“deploy this PR.” Queue-only status is read-only: +requests include “show the delivery pipeline,” “why is PR 42 blocked?”, and +“deploy this PR.” Status and diagnostics are read-only: ```bash deploybot status deploybot status --json +deploybot doctor +deploybot metrics --json deploybot inspect 42 --json ``` @@ -40,6 +43,7 @@ comes from the GitHub CLI: ```bash gh auth login deploybot ensure-labels +deploybot doctor ``` The base installation has no review-service dependency. Repositories can use @@ -59,47 +63,34 @@ and never execute pull-request-head code in the privileged coordinator. The MCP server uses the local process's existing GitHub credentials and accepts explicit repository selectors, so run it only from a trusted coding client and workspace. -## Manual deploy gate +## Durable manual deploy gate The installed agent adapter treats the user's exact `deploy` instruction as -authority for that thread's PR only. It calls: +authority for that thread's PR only. It records the intent immediately—even if +CI or review is still running: ```bash -deploybot enqueue +deploybot request \ + --provider codex --thread-id "$CODEX_THREAD_ID" ``` -No timer is involved. Adding the `merge-queue` label can wake a GitHub Actions -coordinator immediately: +The event worker promotes only the intent-bound exact head after all checks and +review providers pass. If review fixes create a replacement head, the trusted +source agent runs `deploybot refresh-request ` after its fresh evidence; +the user does not repeat `deploy`. No polling timer is involved. + +Install `examples/github-workflow.yml` on the default branch. It reacts to +deploy labels, ready/synchronize events, reviews, named CI `workflow_run` +completions, and completed external check suites. Keep its `workflows` list +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 -name: DeployBot -on: - pull_request_target: - types: [labeled] - -permissions: - contents: write - pull-requests: write - checks: read - -concurrency: - group: deploybot-${{ github.repository }} - cancel-in-progress: false - -jobs: - drain: - if: github.event.label.name == 'merge-queue' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - # v0.1.4; keep the full commit so privileged workflows are immutable. - - uses: Forward-Future/DeployBot@f18dce436bae2d6b7009d9aa76b4bfc8d0aac5a8 +- uses: Forward-Future/DeployBot@2e884f65f182f6a9ddc4c4785288045bdf98f188 ``` -Keep this workflow on the default branch. Never check out or execute code from -the pull-request head in a privileged `pull_request_target` workflow. - -The workflow bot and each person allowed to enqueue must be explicitly listed: +The workflow bot and each person allowed to request deployment must be +explicitly listed: ```toml [queue] @@ -109,8 +100,48 @@ coordinator_actors = ["@repository-owner", "github-actions[bot]"] `@repository-owner` resolves to the owner in `owner/repository`. Organization repositories should replace it with the exact human or bot logins they trust. -Coordinator accounts may freeze and complete a batch, but they cannot create -the per-pull-request deploy authorization. +Coordinator accounts may promote, freeze, integrate, and complete batches, but +they cannot create the original per-pull-request deploy intent. + +## Delivery controller + +`deploybot status` reports active metadata-only agent threads, every PR stage, +deploy requests, queue order, overlaps, exact-`main` CI, deployment, and pipeline +pause state. It never stores prompts, transcripts, source, or credentials. + +`deploybot react` promotes ready intent, skips blockers, drains independent +work, and creates integration PRs when configured. A conflict produces a repair +handoff containing the source thread, base/head SHAs, source paths, and one +return command: + +```bash +deploybot resume +``` + +`resume` atomically verifies the replacement head, clears the block, requeues, +and emits a new wake-up event. `follow` tracks newer cumulative `main` revisions +until exact CI, deployment, and optional HTTP checks pass. A CI or deploy failure +can pause further merges until `deploybot unpause`. + +```toml +[pipeline] +ci_workflows = ["CI"] +deploy_workflows = ["Deploy"] +ready_to_merge_target_minutes = 15 +merge_to_live_target_minutes = 10 +auto_promote = true +intent_scope = "head" +pause_on_failure = true + +[[pipeline.verifications]] +name = "Login" +url = "https://example.com/login" +expected_status = 200 + +[integration] +# manual, overlap, or all (one cumulative pre-merge validation PR) +mode = "overlap" +``` ## Review providers @@ -156,6 +187,18 @@ The `mergeq` and `mergeq-mcp` command aliases remain for compatibility. ```text deploybot status --json deploybot plan --json +deploybot doctor --json +deploybot request --provider codex --thread-id THREAD +deploybot refresh-request +deploybot promote +deploybot react --follow +deploybot resume +deploybot integrate --all +deploybot follow --json +deploybot metrics --json +deploybot pause --reason "main CI failed" +deploybot unpause +deploybot thread update --provider codex --thread-id THREAD --phase ready --pr 42 deploybot inspect --json deploybot enqueue deploybot freeze --json @@ -166,11 +209,12 @@ deploybot dequeue --reason "..." deploybot merge --batch ``` -`status` is a read-only alias for `plan`. It reports queue order, readiness, -blockers, dependencies, and overlap groups without freezing a batch. +`status` is the read-only full delivery view. `plan` is the narrower queue-only +view. `doctor` verifies CLI auth, policy, labels, actors, check names, and branch +protection without changing the repository. -`drain` merges only independent, green, exact-head-reviewed PRs. Overlapping -source is returned as `integration_required` for an agent to resolve once. It -waits briefly while GitHub recomputes mergeability after a merge, then records -the pass complete so a later independent batch cannot be stranded behind a -waiting item. +`drain` merges only independent, green, exact-head-reviewed PRs. Integration +mode `overlap` creates one cumulative PR for shared source; mode `all` routes the +whole frozen batch through one cumulative PR before `main`. DeployBot never +invents a conflict resolution: it prepares the branch and hands the exact repair +back to an agent. diff --git a/action.yml b/action.yml index 64330d2..d734682 100644 --- a/action.yml +++ b/action.yml @@ -1,10 +1,18 @@ name: DeployBot -description: Drain reviewed pull requests after the merge-queue label is added +description: Promote, batch, merge, and follow agent-authored pull requests inputs: config: description: Repository-relative merge queue policy path required: false default: .mergequeue.toml + follow: + description: Follow cumulative main through CI and deployment + required: false + default: "false" + timeout: + description: Maximum release-follow time in seconds + required: false + default: "1800" runs: using: composite steps: @@ -17,4 +25,11 @@ runs: env: DEPLOYBOT_CONFIG: ${{ inputs.config }} GH_TOKEN: ${{ github.token }} - run: deploybot --config "$DEPLOYBOT_CONFIG" drain --json + DEPLOYBOT_FOLLOW: ${{ inputs.follow }} + DEPLOYBOT_TIMEOUT: ${{ inputs.timeout }} + run: | + args=(react --timeout "$DEPLOYBOT_TIMEOUT") + if [[ "$DEPLOYBOT_FOLLOW" == "true" ]]; then + args+=(--follow) + fi + deploybot --config "$DEPLOYBOT_CONFIG" "${args[@]}" diff --git a/adapters/claude-code/.claude-plugin/plugin.json b/adapters/claude-code/.claude-plugin/plugin.json index 956ffb9..6cbeac2 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.1.4", + "version": "0.2.0", "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 5d2d1cb..0de772c 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@f18dce436bae2d6b7009d9aa76b4bfc8d0aac5a8", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@2e884f65f182f6a9ddc4c4785288045bdf98f188", "deploybot-mcp" ] } diff --git a/adapters/claude-code/skills/deploybot/SKILL.md b/adapters/claude-code/skills/deploybot/SKILL.md index 6283f85..4a4fbed 100644 --- a/adapters/claude-code/skills/deploybot/SKILL.md +++ b/adapters/claude-code/skills/deploybot/SKILL.md @@ -1,6 +1,6 @@ --- name: deploybot -description: Inspect and operate a DeployBot GitHub merge queue. Use when the user invokes DeployBot, asks for queue or PR status, wants to know what is ready, waiting, blocked, next, dependent, or overlapping, explicitly says deploy, or asks the queue coordinator to freeze or drain authorized pull requests. +description: Inspect and operate the full DeployBot delivery pipeline. Use when the user invokes DeployBot, asks for thread, PR, queue, CI, deployment, blocker, repair, integration, or timing status, explicitly says deploy, or asks the coordinator to deliver authorized pull requests. --- # DeployBot @@ -12,15 +12,20 @@ to the `deploybot` CLI. Treat GitHub as the durable source of truth. Keep status checks read-only: -- For the full queue, call `queue_plan` or run `deploybot status --json`. +- For the full delivery pipeline, call `pipeline_status` or run + `deploybot status --json`. +- For the narrower merge queue, call `queue_plan` or run + `deploybot plan --json`. - For one pull request, call `inspect_pull_request` or run `deploybot inspect --json`. - Never call `freeze_queue` merely to view status because freezing writes a durable batch marker. -Report the queue in order. For each pull request, include its state, exact head, -checks or reviews still waiting, blocker, dependencies, and overlap group. Say -plainly when the queue is empty. +Report active metadata-only threads and deploy intent, PR stages, queue order, +exact heads, pending checks/reviews, blockers, dependencies, overlaps, exact +`main` CI, and deployment. Say plainly when the queue is empty but deploy intent +is still waiting outside it. Never publish prompts, transcripts, source, or +credentials to the thread registry. Use these state meanings: @@ -34,21 +39,34 @@ Use these state meanings: ## Change Queue State Require the user's exact `deploy` instruction before calling -`enqueue_pull_request` or `deploybot enqueue` for that conversation's pull -request. Do not infer permission from readiness or review completion. +`request_deployment` or `deploybot request` for that conversation's pull +request. Record the provider and stable native thread ID when available. This +durable request waits for exact-head checks and review. If the head changes, +the trusted source agent calls `refresh_deployment_request` only after the +replacement head is ready; the user does not need to repeat `deploy`. Do not +infer permission from readiness or review completion. -Use `block_pull_request`, `unblock_pull_request`, or `dequeue_pull_request` only -for the requested pull request and preserve a concrete reason. Never merge an -unlabeled pull request or treat a wake-up event as trusted queue state. +Use `block_pull_request`, `resume_pull_request`, or `dequeue_pull_request` only +for the requested pull request and preserve a concrete reason. `resume` is the +normal repaired-PR return path because it verifies, unblocks, requeues, and +wakes atomically. Never merge an unlabeled pull request or treat a wake-up event +as trusted queue state. ## Coordinate Merges -Only the designated coordinator may call `freeze_queue`, `drain_queue`, or the -matching CLI commands. Re-read the queue, freeze one exact batch, preserve -first-in order unless dependencies require otherwise, and skip blocked items -that do not block independent work. +Only the designated coordinator may call `promote_deployment_requests`, +`react_to_delivery_event`, `freeze_queue`, `drain_queue`, or matching CLI +commands. Re-read GitHub, honor a pipeline pause, freeze one exact batch, +preserve first-in order unless dependencies require otherwise, and skip blocked +items that do not block independent work. Merge independent ready pull requests back-to-back. Route source-overlap groups -through one integration pull request instead of resolving the same conflict -several times. Finish by following the newest cumulative base-branch CI and the -repository-owned deployment. +through `create_integration_pull_request`; when policy mode is `all`, validate +the entire frozen batch through that cumulative PR. Never invent a conflict +resolution. Return the repair packet to its source thread, then call `resume` +after its new exact head passes. Finish with `follow_release`, following newer +cumulative base heads until CI, deployment, and configured health checks verify. + +Use `diagnose`/`deploybot doctor` for setup drift and `delivery_metrics` for p50, +p95, and slow-stage evidence. A failed cumulative CI or deployment pauses the +controller; only a designated coordinator may unpause after recovery. diff --git a/adapters/claude-code/skills/manage-merge-queue/SKILL.md b/adapters/claude-code/skills/manage-merge-queue/SKILL.md index 07ebe00..ebbe387 100644 --- a/adapters/claude-code/skills/manage-merge-queue/SKILL.md +++ b/adapters/claude-code/skills/manage-merge-queue/SKILL.md @@ -5,11 +5,17 @@ description: Prepare, review, enqueue, and land GitHub pull requests through a p # Manage Merge Queue -Read `.mergequeue.toml` and use the `deploybot` MCP tools. Keep changing -PRs draft and make the final ready head immutable. Address valid feedback from -the configured providers; do not assume a specific review service. - -Only the user's exact `deploy` instruction authorizes enqueueing this thread's -PR. The queue label wakes GitHub immediately. Never poll or merge an unlabeled -PR. Drain independent ready work in order, skip blockers, honor dependencies, -and route overlapping source through one integration PR. +Read `.mergequeue.toml` and use the `deploybot` MCP tools. Keep changing PRs +draft and make the final ready head immutable. Address valid feedback from the +configured providers; do not assume a specific review service. + +Only the user's exact `deploy` instruction authorizes `request_deployment` for +this thread's PR. Include the stable Claude thread ID. If review fixes change +the head, call `refresh_deployment_request` after fresh exact-head gates. Never +poll or merge an unlabeled PR. + +Use `pipeline_status` and `react_to_delivery_event` for bursts. Skip blockers, +honor dependencies, route overlap or cumulative validation through +`create_integration_pull_request`, return repair packets to the source thread, +and use `resume_pull_request` after fresh review. Finish with `follow_release`; +a failed CI or deployment pauses the pipeline until verified recovery. diff --git a/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json b/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json index 594fb42..8cfbebf 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.1.4", + "version": "0.2.0", "description": "Review, queue, and land pull requests through a shared GitHub merge queue", "author": { "name": "DeployBot contributors" diff --git a/adapters/codex/agent-merge-queue/.mcp.json b/adapters/codex/agent-merge-queue/.mcp.json index 5d2d1cb..0de772c 100644 --- a/adapters/codex/agent-merge-queue/.mcp.json +++ b/adapters/codex/agent-merge-queue/.mcp.json @@ -4,7 +4,7 @@ "command": "uvx", "args": [ "--from", - "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@f18dce436bae2d6b7009d9aa76b4bfc8d0aac5a8", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@2e884f65f182f6a9ddc4c4785288045bdf98f188", "deploybot-mcp" ] } diff --git a/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md b/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md index 6283f85..4a4fbed 100644 --- a/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md +++ b/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md @@ -1,6 +1,6 @@ --- name: deploybot -description: Inspect and operate a DeployBot GitHub merge queue. Use when the user invokes DeployBot, asks for queue or PR status, wants to know what is ready, waiting, blocked, next, dependent, or overlapping, explicitly says deploy, or asks the queue coordinator to freeze or drain authorized pull requests. +description: Inspect and operate the full DeployBot delivery pipeline. Use when the user invokes DeployBot, asks for thread, PR, queue, CI, deployment, blocker, repair, integration, or timing status, explicitly says deploy, or asks the coordinator to deliver authorized pull requests. --- # DeployBot @@ -12,15 +12,20 @@ to the `deploybot` CLI. Treat GitHub as the durable source of truth. Keep status checks read-only: -- For the full queue, call `queue_plan` or run `deploybot status --json`. +- For the full delivery pipeline, call `pipeline_status` or run + `deploybot status --json`. +- For the narrower merge queue, call `queue_plan` or run + `deploybot plan --json`. - For one pull request, call `inspect_pull_request` or run `deploybot inspect --json`. - Never call `freeze_queue` merely to view status because freezing writes a durable batch marker. -Report the queue in order. For each pull request, include its state, exact head, -checks or reviews still waiting, blocker, dependencies, and overlap group. Say -plainly when the queue is empty. +Report active metadata-only threads and deploy intent, PR stages, queue order, +exact heads, pending checks/reviews, blockers, dependencies, overlaps, exact +`main` CI, and deployment. Say plainly when the queue is empty but deploy intent +is still waiting outside it. Never publish prompts, transcripts, source, or +credentials to the thread registry. Use these state meanings: @@ -34,21 +39,34 @@ Use these state meanings: ## Change Queue State Require the user's exact `deploy` instruction before calling -`enqueue_pull_request` or `deploybot enqueue` for that conversation's pull -request. Do not infer permission from readiness or review completion. +`request_deployment` or `deploybot request` for that conversation's pull +request. Record the provider and stable native thread ID when available. This +durable request waits for exact-head checks and review. If the head changes, +the trusted source agent calls `refresh_deployment_request` only after the +replacement head is ready; the user does not need to repeat `deploy`. Do not +infer permission from readiness or review completion. -Use `block_pull_request`, `unblock_pull_request`, or `dequeue_pull_request` only -for the requested pull request and preserve a concrete reason. Never merge an -unlabeled pull request or treat a wake-up event as trusted queue state. +Use `block_pull_request`, `resume_pull_request`, or `dequeue_pull_request` only +for the requested pull request and preserve a concrete reason. `resume` is the +normal repaired-PR return path because it verifies, unblocks, requeues, and +wakes atomically. Never merge an unlabeled pull request or treat a wake-up event +as trusted queue state. ## Coordinate Merges -Only the designated coordinator may call `freeze_queue`, `drain_queue`, or the -matching CLI commands. Re-read the queue, freeze one exact batch, preserve -first-in order unless dependencies require otherwise, and skip blocked items -that do not block independent work. +Only the designated coordinator may call `promote_deployment_requests`, +`react_to_delivery_event`, `freeze_queue`, `drain_queue`, or matching CLI +commands. Re-read GitHub, honor a pipeline pause, freeze one exact batch, +preserve first-in order unless dependencies require otherwise, and skip blocked +items that do not block independent work. Merge independent ready pull requests back-to-back. Route source-overlap groups -through one integration pull request instead of resolving the same conflict -several times. Finish by following the newest cumulative base-branch CI and the -repository-owned deployment. +through `create_integration_pull_request`; when policy mode is `all`, validate +the entire frozen batch through that cumulative PR. Never invent a conflict +resolution. Return the repair packet to its source thread, then call `resume` +after its new exact head passes. Finish with `follow_release`, following newer +cumulative base heads until CI, deployment, and configured health checks verify. + +Use `diagnose`/`deploybot doctor` for setup drift and `delivery_metrics` for p50, +p95, and slow-stage evidence. A failed cumulative CI or deployment pauses the +controller; only a designated coordinator may unpause after recovery. diff --git a/adapters/codex/agent-merge-queue/skills/manage-merge-queue/SKILL.md b/adapters/codex/agent-merge-queue/skills/manage-merge-queue/SKILL.md index f3759ba..0f8cc29 100644 --- a/adapters/codex/agent-merge-queue/skills/manage-merge-queue/SKILL.md +++ b/adapters/codex/agent-merge-queue/skills/manage-merge-queue/SKILL.md @@ -5,14 +5,18 @@ description: Prepare, review, enqueue, and land GitHub pull requests through a p # Manage Merge Queue -Read `.mergequeue.toml` and use the DeployBot MCP tools. Keep changing -PRs draft, make the final ready head immutable, and address every valid finding -from the configured review providers. +Read `.mergequeue.toml` and use the DeployBot MCP tools. Keep changing PRs +draft, make the final ready head immutable, and address every valid finding from +the configured review providers. -Only the user's exact `deploy` instruction authorizes `enqueue_pull_request` -for this thread's PR. Enqueueing wakes the GitHub coordinator; never poll and -never absorb an unlabeled PR. +Only the user's exact `deploy` instruction authorizes `request_deployment` for +this thread's PR. Include the stable Codex thread ID. If review fixes change the +head, call `refresh_deployment_request` after fresh exact-head gates; never poll +or merge an unlabeled PR. -Use `queue_plan` before a burst. Merge independent ready PRs back-to-back, skip -blocked work, honor explicit dependencies, and send overlapping source through -one integration PR. Follow the newest cumulative base-branch release. +Use `pipeline_status` before a burst and `react_to_delivery_event` to coordinate +it. Merge independent ready PRs back-to-back, skip blocked work, honor explicit +dependencies, and use `create_integration_pull_request` for overlaps or a +cumulative batch gate. Return repair packets to their source thread and use +`resume_pull_request` after fresh review. Finish with `follow_release`; a failed +CI or deployment pauses the pipeline until verified recovery. diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index 5d2d1cb..0de772c 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@f18dce436bae2d6b7009d9aa76b4bfc8d0aac5a8", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@2e884f65f182f6a9ddc4c4785288045bdf98f188", "deploybot-mcp" ] } diff --git a/adapters/cursor/.cursor/rules/deploybot.mdc b/adapters/cursor/.cursor/rules/deploybot.mdc index 16a5541..1143334 100644 --- a/adapters/cursor/.cursor/rules/deploybot.mdc +++ b/adapters/cursor/.cursor/rules/deploybot.mdc @@ -1,13 +1,16 @@ --- -description: Inspect DeployBot queue status, blockers, dependencies, and overlap groups +description: Inspect and operate the full DeployBot delivery pipeline alwaysApply: false --- -Read `.mergequeue.toml` and use the `deploybot` MCP tools. For queue status, -call `queue_plan`; for one pull request, call `inspect_pull_request`. Status -requests must stay read-only: never freeze a queue merely to inspect it. +Read `.mergequeue.toml` and use the `deploybot` MCP tools. For full status call +`pipeline_status`; for queue-only status call `queue_plan`; for one pull request +call `inspect_pull_request`. Status requests stay read-only: never freeze a +queue merely to inspect it. -Report queue order, readiness, exact heads, pending checks or reviews, blockers, -dependencies, and source overlaps. Only the user's exact `deploy` instruction -authorizes enqueueing that conversation's pull request. Only the designated -coordinator may freeze or drain the queue. +Report metadata-only active threads, deploy intent, PR stages, queue order, +exact heads, blockers, overlap, CI, and deployment. Only the user's exact +`deploy` instruction authorizes `request_deployment` for that thread. Publish +the stable Cursor thread ID, never prompts or transcripts. Refresh intent only +after replacement-head review. Only the coordinator may react, integrate, +drain, follow, pause, or resume repaired work. diff --git a/adapters/cursor/.cursor/rules/manage-merge-queue.mdc b/adapters/cursor/.cursor/rules/manage-merge-queue.mdc index 49d39e2..79e56f9 100644 --- a/adapters/cursor/.cursor/rules/manage-merge-queue.mdc +++ b/adapters/cursor/.cursor/rules/manage-merge-queue.mdc @@ -7,7 +7,10 @@ Read `.mergequeue.toml` and use the `deploybot` MCP tools. Keep changing PRs draft and make the final ready head immutable. Address valid feedback from whatever review providers the repository configures. -Only the user's exact `deploy` instruction authorizes enqueueing the current -thread's PR. The queue label wakes GitHub immediately; never poll or merge an -unlabeled PR. Merge independent ready work in order, skip blockers, honor -dependencies, and route overlapping source through one integration PR. +Only the user's exact `deploy` instruction authorizes `request_deployment` for +the current thread's PR. Include the stable Cursor thread ID. Durable intent +wakes GitHub and promotes only after fresh exact-head gates; never poll or merge +an unlabeled PR. Use `pipeline_status` and `react_to_delivery_event`, skip +blockers, honor dependencies, route overlaps or cumulative validation through +one integration PR, return repair packets to their source thread, atomically +resume after fresh review, and follow cumulative `main` through deployment. diff --git a/adapters/cursor/AGENTS.md b/adapters/cursor/AGENTS.md index 503d924..17c3125 100644 --- a/adapters/cursor/AGENTS.md +++ b/adapters/cursor/AGENTS.md @@ -4,12 +4,14 @@ Read `.mergequeue.toml` and use the `deploybot` MCP tools. Keep changing pull requests draft and make the final ready head immutable. Address valid feedback from the configured review providers. -For read-only status, use `queue_plan` for the full queue and -`inspect_pull_request` for one pull request. Report order, readiness, blockers, -dependencies, and overlap groups; never freeze the queue just to inspect it. +For read-only status, use `pipeline_status` for the full delivery path, +`queue_plan` for the merge queue, and `inspect_pull_request` for one PR. Report +thread intent, PR stages, order, blockers, overlaps, CI, and deployment; never +freeze the queue just to inspect it. -Only the user's exact `deploy` instruction authorizes enqueueing the current -thread's pull request. Never poll, merge an unlabeled pull request, or absorb -unrelated work. Merge independent ready work in order, skip blockers, honor -dependencies, and route overlapping source through one integration pull -request. +Only the user's exact `deploy` instruction authorizes `request_deployment` for +the current thread. Include the stable Cursor thread ID, never prompt contents. +Never poll, merge an unlabeled PR, or absorb unrelated work. Let the event worker +promote fresh exact heads, use one integration PR for overlaps or cumulative +validation, return repair packets to the source thread, atomically resume after +fresh review, and follow cumulative `main` through verified deployment. diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index d067e23..7bfaa37 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -2,22 +2,40 @@ name: DeployBot on: pull_request_target: - types: [labeled] + types: [labeled, ready_for_review, reopened, synchronize] + pull_request_review: + types: [submitted, dismissed] + # Keep this list aligned with pipeline.ci_workflows in .mergequeue.toml. + workflow_run: + workflows: [CI] + types: [completed] + check_suite: + types: [completed] + workflow_dispatch: permissions: contents: write pull-requests: write checks: read + issues: write + actions: read concurrency: group: deploybot-${{ github.repository }} cancel-in-progress: false jobs: - drain: - if: github.event.label.name == 'merge-queue' + react: + if: >- + github.event_name != 'pull_request_target' || + github.event.action != 'labeled' || + github.event.label.name == 'deploy-requested' || + github.event.label.name == 'merge-queue' runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - # v0.1.4; keep the full commit so privileged workflows are immutable. - - uses: Forward-Future/DeployBot@f18dce436bae2d6b7009d9aa76b4bfc8d0aac5a8 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + # v0.2.0 implementation; keep the full commit for privileged workflows. + - uses: Forward-Future/DeployBot@2e884f65f182f6a9ddc4c4785288045bdf98f188 diff --git a/pyproject.toml b/pyproject.toml index 256bb42..ffd52d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deploybot-merge-queue" -version = "0.1.4" +version = "0.2.0" description = "DeployBot: a provider-neutral GitHub merge queue for coding agents" readme = "README.md" license = "MIT" diff --git a/skills/deploybot/SKILL.md b/skills/deploybot/SKILL.md index 6283f85..4a4fbed 100644 --- a/skills/deploybot/SKILL.md +++ b/skills/deploybot/SKILL.md @@ -1,6 +1,6 @@ --- name: deploybot -description: Inspect and operate a DeployBot GitHub merge queue. Use when the user invokes DeployBot, asks for queue or PR status, wants to know what is ready, waiting, blocked, next, dependent, or overlapping, explicitly says deploy, or asks the queue coordinator to freeze or drain authorized pull requests. +description: Inspect and operate the full DeployBot delivery pipeline. Use when the user invokes DeployBot, asks for thread, PR, queue, CI, deployment, blocker, repair, integration, or timing status, explicitly says deploy, or asks the coordinator to deliver authorized pull requests. --- # DeployBot @@ -12,15 +12,20 @@ to the `deploybot` CLI. Treat GitHub as the durable source of truth. Keep status checks read-only: -- For the full queue, call `queue_plan` or run `deploybot status --json`. +- For the full delivery pipeline, call `pipeline_status` or run + `deploybot status --json`. +- For the narrower merge queue, call `queue_plan` or run + `deploybot plan --json`. - For one pull request, call `inspect_pull_request` or run `deploybot inspect --json`. - Never call `freeze_queue` merely to view status because freezing writes a durable batch marker. -Report the queue in order. For each pull request, include its state, exact head, -checks or reviews still waiting, blocker, dependencies, and overlap group. Say -plainly when the queue is empty. +Report active metadata-only threads and deploy intent, PR stages, queue order, +exact heads, pending checks/reviews, blockers, dependencies, overlaps, exact +`main` CI, and deployment. Say plainly when the queue is empty but deploy intent +is still waiting outside it. Never publish prompts, transcripts, source, or +credentials to the thread registry. Use these state meanings: @@ -34,21 +39,34 @@ Use these state meanings: ## Change Queue State Require the user's exact `deploy` instruction before calling -`enqueue_pull_request` or `deploybot enqueue` for that conversation's pull -request. Do not infer permission from readiness or review completion. +`request_deployment` or `deploybot request` for that conversation's pull +request. Record the provider and stable native thread ID when available. This +durable request waits for exact-head checks and review. If the head changes, +the trusted source agent calls `refresh_deployment_request` only after the +replacement head is ready; the user does not need to repeat `deploy`. Do not +infer permission from readiness or review completion. -Use `block_pull_request`, `unblock_pull_request`, or `dequeue_pull_request` only -for the requested pull request and preserve a concrete reason. Never merge an -unlabeled pull request or treat a wake-up event as trusted queue state. +Use `block_pull_request`, `resume_pull_request`, or `dequeue_pull_request` only +for the requested pull request and preserve a concrete reason. `resume` is the +normal repaired-PR return path because it verifies, unblocks, requeues, and +wakes atomically. Never merge an unlabeled pull request or treat a wake-up event +as trusted queue state. ## Coordinate Merges -Only the designated coordinator may call `freeze_queue`, `drain_queue`, or the -matching CLI commands. Re-read the queue, freeze one exact batch, preserve -first-in order unless dependencies require otherwise, and skip blocked items -that do not block independent work. +Only the designated coordinator may call `promote_deployment_requests`, +`react_to_delivery_event`, `freeze_queue`, `drain_queue`, or matching CLI +commands. Re-read GitHub, honor a pipeline pause, freeze one exact batch, +preserve first-in order unless dependencies require otherwise, and skip blocked +items that do not block independent work. Merge independent ready pull requests back-to-back. Route source-overlap groups -through one integration pull request instead of resolving the same conflict -several times. Finish by following the newest cumulative base-branch CI and the -repository-owned deployment. +through `create_integration_pull_request`; when policy mode is `all`, validate +the entire frozen batch through that cumulative PR. Never invent a conflict +resolution. Return the repair packet to its source thread, then call `resume` +after its new exact head passes. Finish with `follow_release`, following newer +cumulative base heads until CI, deployment, and configured health checks verify. + +Use `diagnose`/`deploybot doctor` for setup drift and `delivery_metrics` for p50, +p95, and slow-stage evidence. A failed cumulative CI or deployment pauses the +controller; only a designated coordinator may unpause after recovery. diff --git a/skills/manage-merge-queue/SKILL.md b/skills/manage-merge-queue/SKILL.md index d05f2de..f4953d6 100644 --- a/skills/manage-merge-queue/SKILL.md +++ b/skills/manage-merge-queue/SKILL.md @@ -19,27 +19,32 @@ the repository's policy; never assume a particular review vendor. Do not merge merely because review is complete. -## Enqueue On Deploy +## Record Deploy Intent Treat the user's exact `deploy` instruction as authority for this thread's PR -only. Call `enqueue_pull_request` through the DeployBot MCP server, or run -`deploybot enqueue `. The command fails closed on stale heads, -missing checks, unresolved findings, conflicts, or missing review evidence. +only. Call `request_deployment` through the DeployBot MCP server, or run +`deploybot request --provider --thread-id `. +This records intent immediately. If review fixes change the head, call +`refresh_deployment_request` only after its fresh checks and review pass; the +event worker then promotes that exact head without another user instruction. -The queue label wakes the GitHub coordinator. Do not create a polling timer and -do not merge the PR independently. +The intent label wakes the GitHub coordinator. Review, check, ready, and push +events retry promotion without a polling timer. Do not merge independently. ## Coordinate A Burst -Use `queue_plan`, then `freeze_queue` or `drain_queue`. Preserve first-in order -unless explicit dependencies require another order. Merge independent ready -PRs back-to-back without rebasing merely because the base branch moved. - -Skip blocked or waiting PRs so they do not stop independent work. If the plan -returns `integration_required`, combine that overlap group on one integration -branch, resolve source once, run tests once, and obtain one final review. Never -hand-merge generated output. - -Finish by following the newest cumulative base-branch CI and deployment owned -by the repository. Record exact heads, review verdicts, merged commits, waiting -items, and integration groups. +Use `pipeline_status`, then `react_to_delivery_event` or the narrower queue +tools. Preserve first-in order unless dependencies require another order. Merge +independent ready PRs back-to-back without rebasing merely because `main` moved. + +Skip blocked or waiting PRs so they do not stop independent work. A blocker +creates a structured repair handoff to the source thread. After the repair has +fresh checks and review, call `resume_pull_request` once. If policy requests an +integration PR, let DeployBot scaffold it, resolve source once, run tests once, +and obtain one final review. Never hand-merge generated output. + +Finish with `follow_release`, tracking newer cumulative base heads through CI, +deployment, and configured health checks. A failure pauses further merges until +the coordinator verifies recovery and unpauses. Record exact heads, review +verdicts, merged commits, waiting items, repair packets, integration groups, and +delivery timing. diff --git a/src/agent_merge_queue/__init__.py b/src/agent_merge_queue/__init__.py index 519f4da..7997dca 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.1.4" +__version__ = "0.2.0" diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index 5a88c84..f8995b0 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -19,6 +19,30 @@ from . import __version__ from .config import ConfigError, QueueConfig, initialize_config, load_config +from .doctor import diagnose +from .pipeline import ( + follow_release, + notify, + release_state, + seconds_between, + summarize_metrics, +) +from .records import ( + CONTROL_MARKER, + INTEGRATION_MARKER, + REPAIR_MARKER, + THREAD_PHASES, + ThreadRecord, + control_body, + integration_body, + intent_body, + latest_intent, + latest_payload, + latest_thread_records, + parse_time, + repair_body, + thread_record_body, +) from .reviews import ReviewVerdict, evaluate_reviews MARKER_PREFIX = "agent-merge-queue:v1" @@ -89,8 +113,11 @@ class QueueError(RuntimeError): def utc_now() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace( - "+00:00", "Z" + return ( + datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") ) @@ -185,9 +212,7 @@ def latest_marker( comment_id = int(comment.get("id") or 0) except (TypeError, ValueError): comment_id = 0 - found.append( - (str(comment.get("created_at") or ""), comment_id, index, value) - ) + found.append((str(comment.get("created_at") or ""), comment_id, index, value)) return max(found, key=lambda item: item[:3])[3] if found else None @@ -263,6 +288,8 @@ def queue_state_body( *, queued_at: str | None, reason: str | None = None, + intent_id: str | None = None, + integration_batch_id: str | None = None, ) -> str: if state not in {"queued", "blocked", "dequeued"}: raise ValueError(f"unsupported queue state: {state}") @@ -276,16 +303,72 @@ def queue_state_body( marker["queued_at"] = queued_at if reason: marker["reason"] = reason + if intent_id: + marker["intent_id"] = intent_id + if integration_batch_id: + marker["integration_batch_id"] = integration_batch_id return ( f"\n" f"Recorded merge queue state `{state}` on `{head_sha}`." ) +def effective_queue_marker( + comments: Iterable[dict[str, Any]], + trusted_logins: Iterable[str], + coordinator_logins: Iterable[str], + *, + intent_scope: str = "head", +) -> dict[str, Any] | None: + """Accept coordinator state only when rooted in a live trusted deploy intent.""" + values = list(comments) + direct = latest_marker(values, trusted_logins) + intent = latest_intent(values, trusted_logins) + delegated = latest_marker(values, coordinator_logins) + if ( + delegated + and intent + and intent.get("state") == "requested" + and delegated.get("intent_id") == intent.get("intent_id") + and intent_scope == "head" + and intent.get("requested_head") == delegated.get("head_sha") + ): + direct_time = str((direct or {}).get("recorded_at") or "") + delegated_time = str(delegated.get("recorded_at") or "") + return delegated if delegated_time >= direct_time else direct + integration = latest_payload(values, INTEGRATION_MARKER, coordinator_logins) + if ( + delegated + and integration + and delegated.get("integration_batch_id") == integration.get("batch_id") + ): + return delegated + return direct + + +def coordinator_logins(client: Any) -> set[str]: + value = getattr(client, "coordinator_logins", None) + if isinstance(value, (set, frozenset, list, tuple)): + return {str(item) for item in value} + trusted = getattr(client, "trusted_logins", set()) + if isinstance(trusted, (set, frozenset, list, tuple)): + return {str(item) for item in trusted} + return set() + + +def require_running_pipeline(client: Any) -> None: + reader = getattr(client, "pipeline_control", None) + if not callable(reader): + return + value = reader() + if isinstance(value, dict) and value.get("state") == "paused": + raise QueueError( + "DeployBot pipeline is paused: " + str(value.get("reason") or "unknown") + ) + + def structured_dependencies(body: str, directive: str) -> list[int]: - pattern = re.compile( - rf"^{re.escape(directive)}:\s*(.*?)\s*$", re.I | re.MULTILINE - ) + pattern = re.compile(rf"^{re.escape(directive)}:\s*(.*?)\s*$", re.I | re.MULTILINE) values: set[int] = set() for match in pattern.findall(body): values.update(int(value) for value in PULL_REQUEST_NUMBER.findall(match)) @@ -379,15 +462,11 @@ def new_batch(entries: list["QueueEntry"], *, frozen_at: str) -> dict[str, Any]: "batch_id": f"{compact_time}-{fingerprint}", "fingerprint": fingerprint, "frozen_at": frozen_at, - "dependencies": { - str(entry.number): entry.dependencies for entry in entries - }, + "dependencies": {str(entry.number): entry.dependencies for entry in entries}, "heads": {str(entry.number): entry.head_sha for entry in entries}, "pull_requests": [entry.number for entry in entries], "schema": 1, - "source_paths": { - str(entry.number): entry.source_paths for entry in entries - }, + "source_paths": {str(entry.number): entry.source_paths for entry in entries}, } @@ -611,9 +690,7 @@ def resolve_pr(self, selector: str | None) -> int: arguments = ["pr", "view"] if selector: arguments.append(selector) - arguments.extend( - ["--repo", self.repository, "--json", "number"] - ) + arguments.extend(["--repo", self.repository, "--json", "number"]) return int(self._json(*arguments)["number"]) def ensure_labels(self) -> None: @@ -628,6 +705,21 @@ def ensure_labels(self) -> None: "B60205", "Merge queue item needs agent attention", ), + ( + self.config.pipeline.intent_label, + "1D76DB", + "User requested deployment; waiting for exact-head gates", + ), + ( + self.config.pipeline.pause_label, + "D93F0B", + "DeployBot pipeline is paused after a delivery failure", + ), + ( + self.config.pipeline.registry_label, + "5319E7", + "DeployBot metadata registry (never transcript contents)", + ), ) for name, color, description in labels: self._run( @@ -643,6 +735,264 @@ def ensure_labels(self) -> None: "--force", ) + def viewer_login(self) -> str: + value = self._json("api", "user") + return str(value.get("login") or "") + + def require_actor(self, allowed: Iterable[str], action: str) -> str: + login = self.viewer_login() + if login.lower() not in {value.lower() for value in allowed}: + raise QueueError(f"{login or 'current GitHub identity'} cannot {action}") + return login + + def open_pull_request_numbers(self) -> list[int]: + return [ + int(value["number"]) + for value in self._paged_api( + f"repos/{self.repository}/pulls?state=open&per_page=100" + ) + ] + + def intent_numbers(self) -> list[int]: + values = self._paged_api( + f"repos/{self.repository}/pulls?state=open&per_page=100" + ) + return [ + int(value["number"]) + for value in values + if self.config.pipeline.intent_label + in {str(label.get("name") or "") for label in value.get("labels") or []} + ] + + def active_integration_sources(self) -> set[int]: + sources: set[int] = set() + for number in self.open_pull_request_numbers(): + marker = latest_payload( + self.comments(number), + INTEGRATION_MARKER, + self.coordinator_logins, + ) + if marker: + sources.update( + int(value) for value in marker.get("pull_requests") or [] + ) + return sources + + def base_sha(self) -> str: + value = self._json( + "api", f"repos/{self.repository}/commits/{self.config.base_branch}" + ) + return str(value["sha"]) + + def workflow_runs(self) -> list[dict[str, Any]]: + value = self._json( + "api", + f"repos/{self.repository}/actions/runs?branch={self.config.base_branch}&per_page=100", + ) + 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 recent_merged_pull_requests(self, limit: int) -> list[dict[str, Any]]: + values = self._paged_api( + "repos/" + f"{self.repository}/pulls?state=closed&sort=updated&direction=desc&per_page=100" + ) + return [value for value in values if value.get("merged_at")][:limit] + + def is_ancestor(self, ancestor: str, descendant: str) -> bool: + comparison = self._json( + "api", f"repos/{self.repository}/compare/{ancestor}...{descendant}" + ) + return comparison.get("status") in {"ahead", "identical"} + + def registry_issue_number(self, *, create: bool) -> int | None: + values = self._paged_api( + f"repos/{self.repository}/issues?state=all&labels={self.config.pipeline.registry_label}&per_page=100" + ) + matches = [ + value + for value in values + if "pull_request" not in value + and str(value.get("title") or "") == self.config.pipeline.registry_title + ] + if len(matches) > 1: + raise QueueError("more than one DeployBot registry issue exists") + if matches: + return int(matches[0]["number"]) + if not create: + return None + self.ensure_labels() + created = self._json( + "api", + "--method", + "POST", + f"repos/{self.repository}/issues", + "-f", + f"title={self.config.pipeline.registry_title}", + "-f", + "body=DeployBot stores minimal delivery metadata here. Never post prompts, transcripts, source code, or credentials.", + "-f", + f"labels[]={self.config.pipeline.registry_label}", + ) + return int(created["number"]) + + def issue_comment(self, number: int, body: str) -> None: + self._json( + "api", + "--method", + "POST", + f"repos/{self.repository}/issues/{number}/comments", + "-f", + f"body={body}", + ) + + def registry_comments(self) -> list[dict[str, Any]]: + number = self.registry_issue_number(create=False) + return self.comments(number) if number is not None else [] + + def record_thread(self, record: ThreadRecord) -> None: + number = self.registry_issue_number(create=True) + if number is None: # pragma: no cover - create owns this invariant. + raise QueueError("could not create DeployBot registry") + self.issue_comment(number, thread_record_body(record)) + + def thread_records(self) -> list[dict[str, Any]]: + trusted = self.trusted_logins | self.coordinator_logins + return [ + record.as_dict() + for record in latest_thread_records( + self.registry_comments(), + trusted, + active_hours=self.config.pipeline.thread_active_hours, + ) + ] + + def pipeline_control(self) -> dict[str, Any]: + value = latest_payload( + self.registry_comments(), + CONTROL_MARKER, + self.coordinator_logins, + ) + return value or {"state": "running"} + + def set_pipeline_control(self, state: str, reason: str | None = None) -> None: + number = self.registry_issue_number(create=True) + if number is None: # pragma: no cover + raise QueueError("could not create DeployBot registry") + self.issue_comment(number, control_body(state=state, reason=reason)) + + def create_integration_pull_request( + self, + *, + batch: dict[str, Any], + entries: list[QueueEntry], + ) -> dict[str, Any]: + batch_id = str(batch["batch_id"]) + safe_id = re.sub(r"[^a-zA-Z0-9._-]+", "-", batch_id)[-80:] + branch = f"{self.config.integration.branch_prefix}/{safe_id}" + base_sha = self.base_sha() + try: + self._json( + "api", + "--method", + "POST", + f"repos/{self.repository}/git/refs", + "-f", + f"ref=refs/heads/{branch}", + "-f", + f"sha={base_sha}", + ) + except QueueError as error: + if "Reference already exists" not in str(error): + raise + + merged_heads: list[str] = [] + conflict: dict[str, Any] | None = None + for entry in entries: + try: + self._json( + "api", + "--method", + "POST", + f"repos/{self.repository}/merges", + "-f", + f"base={branch}", + "-f", + f"head={entry.head_sha}", + "-f", + f"commit_message=DeployBot batch {batch_id}: PR #{entry.number}", + ) + merged_heads.append(entry.head_sha) + except QueueError as error: + conflict = { + "number": entry.number, + "head_sha": entry.head_sha, + "reason": str(error), + } + break + + existing = self._json( + "api", + f"repos/{self.repository}/pulls?state=open&head={self.owner}:{branch}", + ) + if isinstance(existing, list) and existing: + pull = existing[0] + else: + members = ", ".join(f"#{entry.number}" for entry in entries) + body = ( + f"DeployBot cumulative batch `{batch_id}` for {members}.\n\n" + "Every source head was frozen and independently authorized. " + "If a merge conflict remains, an agent must resolve it without " + "dropping either side before marking this PR ready." + ) + pull = self._json( + "api", + "--method", + "POST", + f"repos/{self.repository}/pulls", + "-f", + f"title={self.config.integration.title_prefix}: {safe_id}", + "-f", + f"head={branch}", + "-f", + f"base={self.config.base_branch}", + "-f", + f"body={body}", + "-F", + f"draft={'true' if conflict else 'false'}", + ) + number = int(pull["number"]) + marker = { + "base_sha": base_sha, + "batch_id": batch_id, + "conflict": conflict, + "created_at": utc_now(), + "heads": {str(entry.number): entry.head_sha for entry in entries}, + "merged_heads": merged_heads, + "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. + 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) + return { + "number": number, + "url": pull.get("html_url"), + "branch": branch, + "conflict": conflict, + "batch_id": batch_id, + } + def _paged_api(self, endpoint: str) -> list[dict[str, Any]]: data = self._json("api", "--paginate", "--slurp", endpoint) pages = data if isinstance(data, list) else [] @@ -736,9 +1086,11 @@ def snapshot( for provider in self.config.review_providers ) threads = self.review_threads(number) if needs_threads else [] - marker = latest_marker( + marker = effective_queue_marker( comments, self.trusted_logins, + self.coordinator_logins, + intent_scope=self.config.pipeline.intent_scope, ) head_sha = str(pull["headRefOid"]) checks = check_states(pull.get("statusCheckRollup") or []) @@ -841,9 +1193,7 @@ def labels(self, number: int) -> set[str]: "--json", "labels", ) - return { - str(label.get("name") or "") for label in data.get("labels") or [] - } + return {str(label.get("name") or "") for label in data.get("labels") or []} def remove_label(self, number: int, label: str) -> None: self._run( @@ -901,8 +1251,7 @@ def merge(self, number: int, head_sha: str) -> str: "headRefOid,isDraft,labels,state", ) labels = { - str(label.get("name") or "") - for label in authorization.get("labels") or [] + str(label.get("name") or "") for label in authorization.get("labels") or [] } if authorization.get("state") != "OPEN": raise QueueError(f"PR #{number} is no longer open") @@ -910,18 +1259,67 @@ def merge(self, number: int, head_sha: str) -> str: raise QueueError(f"PR #{number} returned to draft before merge") if authorization.get("headRefOid") != head_sha: raise QueueError(f"PR #{number} changed immediately before merge") - if ( - self.config.queue_label not in labels - or self.config.blocked_label in labels - ): + 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 = latest_marker(self.comments(number), self.trusted_logins) + marker = effective_queue_marker( + self.comments(number), + self.trusted_logins, + getattr(self, "coordinator_logins", self.trusted_logins), + intent_scope=self.config.pipeline.intent_scope, + ) if ( not marker or marker.get("state") != "queued" or marker.get("head_sha") != head_sha ): raise QueueError(f"PR #{number} durable queue authorization was revoked") + integration_batch_id = str(marker.get("integration_batch_id") or "") + if integration_batch_id: + integration = latest_payload( + self.comments(number), + INTEGRATION_MARKER, + getattr(self, "coordinator_logins", self.trusted_logins), + ) + if not integration or integration.get("batch_id") != integration_batch_id: + raise QueueError(f"PR #{number} integration authorization was revoked") + 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: + raise QueueError( + f"source PR #{source_number} deploy authorization was revoked" + ) + if not self.is_ancestor(expected, head_sha): + raise QueueError( + f"integration head does not contain source PR #{source_number}" + ) result = self._json( "api", @@ -934,7 +1332,9 @@ def merge(self, number: int, head_sha: str) -> str: f"merge_method={self.config.merge_method}", ) if not result.get("merged"): - raise QueueError(str(result.get("message") or f"PR #{number} was not merged")) + raise QueueError( + str(result.get("message") or f"PR #{number} was not merged") + ) return str(result["sha"]) @@ -963,15 +1363,11 @@ def print_plan(entries: list[QueueEntry], *, json_output: bool) -> None: return for position, entry in enumerate(entries, start=1): review = ", ".join( - f"{verdict.provider}: {verdict.state}" - for verdict in entry.review_verdicts + f"{verdict.provider}: {verdict.state}" for verdict in entry.review_verdicts ) review = review or "checks only" detail = "; ".join(entry.reasons or []) or "all merge gates passed" - print( - f"{position}. #{entry.number} {entry.state} " - f"({review}) - {detail}" - ) + print(f"{position}. #{entry.number} {entry.state} ({review}) - {detail}") for group in groups: numbers = ", ".join(f"#{value}" for value in group["pull_requests"]) paths = ", ".join(group["source_paths"]) @@ -994,8 +1390,499 @@ def command_inspect( return entry +def pipeline_status(client: GitHub) -> dict[str, Any]: + queued = client.queue() + queued_by_number = {entry.number: entry for entry in queued} + stages: dict[str, list[dict[str, Any]]] = { + "draft": [], + "reviewing": [], + "ready": [], + "deploy_requested": [], + "queued": [], + "blocked": [], + } + for number in client.open_pull_request_numbers(): + if number in queued_by_number: + value = entry_dict(queued_by_number[number]) + value["pipeline_stage"] = "queued" + stages["queued"].append(value) + continue + entry = client.snapshot( + number, + require_marker=False, + allow_blocked_label=True, + ) + labels = set(entry.labels) + if client.config.pipeline.intent_label in labels: + stage = "deploy_requested" + elif entry.is_draft: + stage = "draft" + elif entry.state == "ready": + stage = "ready" + elif entry.state == "waiting": + stage = "reviewing" + else: + stage = "blocked" + value = entry_dict(entry) + value["pipeline_stage"] = stage + stages[stage].append(value) + main_sha = client.base_sha() + delivery = release_state( + main_sha=main_sha, + runs=client.workflow_runs(), + config=client.config.pipeline, + ) + now = datetime.now(timezone.utc) + alerts: list[dict[str, Any]] = [] + queue_target = client.config.pipeline.ready_to_merge_target_minutes * 60 + for entry in queued: + timestamp = parse_time(entry.queued_at) + elapsed = (now - timestamp).total_seconds() if timestamp else None + if elapsed is not None and elapsed > queue_target: + 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", + } + ) + if delivery["state"] not in {"verified", "testing"}: + run = delivery.get("latest_ci") or delivery.get("latest_deploy") or {} + created = str(run.get("created_at") or "") + timestamp = parse_time(created) + elapsed = (now - timestamp).total_seconds() if timestamp else None + release_target = client.config.pipeline.merge_to_live_target_minutes * 60 + if elapsed is not None and elapsed > release_target: + alerts.append( + { + "stage": "merge-to-live", + "elapsed_seconds": elapsed, + "target_seconds": release_target, + "active_gate": delivery["state"], + } + ) + return { + "repository": client.repository, + "control": client.pipeline_control(), + "threads": client.thread_records(), + "pull_requests": stages, + "queue": [entry_dict(entry) for entry in queued], + "overlap_groups": overlap_groups(queued), + "release": delivery, + "alerts": alerts, + } + + +def print_pipeline_status(value: dict[str, Any], *, json_output: bool) -> None: + if json_output: + print(json.dumps(value, indent=2, sort_keys=True)) + return + stages = value["pull_requests"] + print( + "threads: " + f"{len(value['threads'])} active; " + f"deploy requests: {len(stages['deploy_requested'])}; " + f"queue: {len(value['queue'])}; " + f"release: {value['release']['state']}" + ) + if value["control"].get("state") == "paused": + print(f"pipeline paused: {value['control'].get('reason', 'unknown reason')}") + for position, entry in enumerate(value["queue"], start=1): + detail = "; ".join(entry.get("reasons") or []) or "ready" + print(f"{position}. PR #{entry['number']} {entry['state']} - {detail}") + for alert in value.get("alerts") or []: + print( + f"slow {alert['stage']}: {int(alert['elapsed_seconds'])}s; " + f"active gate: {alert['active_gate']}" + ) + + +def command_thread_update( + client: GitHub, + *, + provider: str, + thread_id: str, + phase: str, + title: str | None, + branch: str | None, + pull_request: int | None, + url: str | None, +) -> None: + client.record_thread( + ThreadRecord( + provider=provider, + thread_id=thread_id, + phase=phase, + updated_at=utc_now(), + title=title, + branch=branch, + pull_request=pull_request, + url=url, + ) + ) + print(f"recorded {provider} thread {thread_id} as {phase}") + + +def command_request( + client: GitHub, + selector: str | None, + *, + provider: str | None, + thread_id: str | None, + thread_url: str | None, +) -> dict[str, Any]: + number = client.resolve_pr(selector) + actor = client.require_actor(client.trusted_logins, "request deployment") + client.ensure_labels() + entry = client.snapshot(number, require_marker=False, allow_blocked_label=True) + requested_at = utc_now() + intent_id = hashlib.sha256( + f"{client.repository}:{number}:{actor}:{requested_at}".encode() + ).hexdigest()[:24] + client.comment( + number, + intent_body( + intent_id=intent_id, + state="requested", + requested_at=requested_at, + requested_head=entry.head_sha, + provider=provider, + thread_id=thread_id, + thread_url=thread_url, + ), + ) + if client.config.pipeline.intent_label not in entry.labels: + client.add_label(number, client.config.pipeline.intent_label) + if provider and thread_id: + client.record_thread( + ThreadRecord( + provider=provider, + thread_id=thread_id, + phase="deploy-requested", + updated_at=requested_at, + branch=None, + pull_request=number, + url=thread_url, + ) + ) + notify( + client.config.pipeline, + "deploy-requested", + { + "repository": client.repository, + "pull_request": number, + "head_sha": entry.head_sha, + "intent_id": intent_id, + "provider": provider, + "thread_id": thread_id, + }, + ) + promoted = False + if client.config.pipeline.auto_promote and entry.state == "ready": + queue_from_intent( + client, + client.snapshot(number, require_marker=False, allow_blocked_label=True), + latest_intent(client.comments(number), client.trusted_logins) or {}, + ) + promoted = True + result = { + "pull_request": number, + "intent_id": intent_id, + "head_sha": entry.head_sha, + "state": "queued" if promoted else "deploy-requested", + "waiting": [] if promoted else entry.reasons or [], + } + print(json.dumps(result, sort_keys=True)) + return result + + +def command_cancel_request(client: GitHub, selector: str | None) -> None: + number = client.resolve_pr(selector) + client.require_actor(client.trusted_logins, "cancel deployment") + comments = client.comments(number) + intent = latest_intent(comments, client.trusted_logins) + if not intent or intent.get("state") != "requested": + raise QueueError(f"PR #{number} has no active deploy intent") + client.comment( + number, + intent_body( + intent_id=str(intent["intent_id"]), + state="cancelled", + requested_at=str(intent["requested_at"]), + requested_head=str(intent["requested_head"]), + provider=str(intent.get("provider") or "") or None, + thread_id=str(intent.get("thread_id") or "") or None, + thread_url=str(intent.get("thread_url") or "") or None, + ), + ) + labels = client.labels(number) + for label in (client.config.pipeline.intent_label, client.config.queue_label): + if label in labels: + client.remove_label(number, label) + print(f"cancelled deploy request for PR #{number}") + + +def command_refresh_request(client: GitHub, selector: str | None) -> dict[str, Any]: + number = client.resolve_pr(selector) + actor = client.require_actor( + client.trusted_logins, "refresh deployment intent for a replacement head" + ) + comments = client.comments(number) + previous = latest_intent(comments, client.trusted_logins) + if not previous or previous.get("state") != "requested": + raise QueueError(f"PR #{number} has no active deploy intent to refresh") + entry = client.snapshot(number, require_marker=False, allow_blocked_label=True) + if entry.state != "ready": + raise QueueError( + f"PR #{number} replacement head is not ready: " + + "; ".join(entry.reasons or ["unknown gate"]) + ) + refreshed_at = utc_now() + intent_id = hashlib.sha256( + f"{client.repository}:{number}:{actor}:{refreshed_at}:{entry.head_sha}".encode() + ).hexdigest()[:24] + client.comment( + number, + intent_body( + intent_id=intent_id, + state="requested", + requested_at=refreshed_at, + requested_head=entry.head_sha, + provider=str(previous.get("provider") or "") or None, + thread_id=str(previous.get("thread_id") or "") or None, + thread_url=str(previous.get("thread_url") or "") or None, + parent_intent_id=str(previous.get("intent_id") or "") or None, + ), + ) + intent = latest_intent(client.comments(number), client.trusted_logins) + if not intent or intent.get("intent_id") != intent_id: + raise QueueError("GitHub did not confirm the refreshed trusted intent") + queue_from_intent(client, entry, intent) + result = { + "pull_request": number, + "head_sha": entry.head_sha, + "intent_id": intent_id, + "parent_intent_id": previous.get("intent_id"), + "state": "queued", + } + print(json.dumps(result, sort_keys=True)) + return result + + +def record_repair( + client: GitHub, + entry: QueueEntry, + intent: dict[str, Any] | None, + reason: str, +) -> dict[str, Any]: + comments = client.comments(entry.number) + previous = latest_payload( + comments, + REPAIR_MARKER, + coordinator_logins(client), + ) + if ( + previous + and previous.get("head_sha") == entry.head_sha + and previous.get("reason") == reason + ): + return previous + payload = { + "base_sha": client.base_sha(), + "created_at": utc_now(), + "head_sha": entry.head_sha, + "intent_id": (intent or {}).get("intent_id"), + "provider": (intent or {}).get("provider"), + "pull_request": entry.number, + "reason": reason, + "resume_command": f"deploybot resume {entry.number}", + "source_paths": entry.source_paths, + "thread_id": (intent or {}).get("thread_id"), + "thread_url": (intent or {}).get("thread_url"), + } + client.comment(entry.number, repair_body(payload)) + labels = client.labels(entry.number) + if client.config.blocked_label not in labels: + client.add_label(entry.number, client.config.blocked_label) + if payload.get("provider") and payload.get("thread_id"): + client.record_thread( + ThreadRecord( + provider=str(payload["provider"]), + thread_id=str(payload["thread_id"]), + phase="blocked", + updated_at=str(payload["created_at"]), + pull_request=entry.number, + url=str(payload.get("thread_url") or "") or None, + ) + ) + notify( + client.config.pipeline, + "repair-required", + {"repository": client.repository, **payload}, + ) + return payload + + +def command_promote(client: GitHub, *, emit: bool = True) -> dict[str, Any]: + promoted: list[int] = [] + waiting: list[dict[str, Any]] = [] + blocked: list[dict[str, Any]] = [] + integration_sources = client.active_integration_sources() + for number in client.intent_numbers(): + if number in integration_sources: + waiting.append( + {"number": number, "reasons": ["cumulative integration PR is active"]} + ) + continue + comments = client.comments(number) + intent = latest_intent(comments, client.trusted_logins) + if not intent or intent.get("state") != "requested": + continue + entry = client.snapshot(number, require_marker=False, allow_blocked_label=True) + if ( + client.config.pipeline.intent_scope == "head" + and intent.get("requested_head") != entry.head_sha + ): + waiting.append( + { + "number": number, + "reasons": [ + "deploy intent is bound to an older head; the trusted source " + "agent must run deploybot refresh-request" + ], + } + ) + continue + if entry.state == "ready": + queue_from_intent(client, entry, intent) + promoted.append(number) + elif entry.state == "blocked": + reason = "; ".join(entry.reasons or ["blocked"]) + record_repair(client, entry, intent, reason) + blocked.append({"number": number, "reason": reason}) + else: + waiting.append({"number": number, "reasons": entry.reasons or []}) + result = {"promoted": promoted, "waiting": waiting, "blocked": blocked} + if emit: + print(json.dumps(result, indent=2, sort_keys=True)) + return result + + +def promote_integrations(client: GitHub) -> list[int]: + promoted: list[int] = [] + for number in client.open_pull_request_numbers(): + comments = client.comments(number) + integration = latest_payload( + comments, + INTEGRATION_MARKER, + coordinator_logins(client), + ) + if not integration or integration.get("conflict"): + continue + labels = client.labels(number) + if client.config.queue_label in labels: + continue + entry = client.snapshot(number, require_marker=False, allow_blocked_label=True) + if entry.state != "ready": + continue + batch_id = str(integration.get("batch_id") or "") + client.comment( + number, + queue_state_body( + "queued", + entry.head_sha, + queued_at=utc_now(), + integration_batch_id=batch_id, + ), + ) + 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) + promoted.append(number) + notify( + client.config.pipeline, + "integration-queued", + { + "repository": client.repository, + "pull_request": number, + "batch_id": batch_id, + "head_sha": entry.head_sha, + }, + ) + return promoted + + +def command_resume(client: GitHub, selector: str | None) -> None: + number = client.resolve_pr(selector) + comments = client.comments(number) + integration = latest_payload( + comments, + INTEGRATION_MARKER, + coordinator_logins(client), + ) + if integration: + entry = client.snapshot(number, require_marker=False, allow_blocked_label=True) + for source_number, source_head in (integration.get("heads") or {}).items(): + if not client.is_ancestor(str(source_head), entry.head_sha): + raise QueueError( + f"integration head is still missing source PR #{source_number}" + ) + if entry.state != "ready": + raise QueueError( + f"integration PR #{number} is not ready: " + + "; ".join(entry.reasons or ["unknown gate"]) + ) + repaired = {key: value for key, value in integration.items() if key != "schema"} + repaired["conflict"] = None + repaired["resolved_at"] = utc_now() + client.comment(number, integration_body(repaired)) + client.comment( + number, + queue_state_body( + "queued", + entry.head_sha, + queued_at=utc_now(), + integration_batch_id=str(integration.get("batch_id") or ""), + ), + ) + labels = client.labels(number) + if client.config.queue_label not in labels: + client.add_label(number, client.config.queue_label) + if client.config.blocked_label in labels: + 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) + print(f"resumed and queued integration PR #{number} on {entry.head_sha}") + return + intent = latest_intent(comments, client.trusted_logins) + if intent and intent.get("state") == "requested": + entry = client.snapshot(number, require_marker=False, allow_blocked_label=True) + if ( + client.config.pipeline.intent_scope == "head" + and intent.get("requested_head") != entry.head_sha + ): + command_refresh_request(client, str(number)) + return + queue_from_intent(client, entry, intent) + print(f"resumed and queued PR #{number} on {entry.head_sha}") + return + if client.viewer_login().lower() in { + value.lower() for value in client.trusted_logins + }: + command_enqueue(client, str(number)) + return + raise QueueError(f"PR #{number} has no trusted deploy intent to resume") + + def command_enqueue(client: GitHub, selector: str | None) -> None: number = client.resolve_pr(selector) + client.require_actor(client.trusted_logins, "authorize a pull request") client.ensure_labels() entry = client.snapshot( number, @@ -1037,11 +1924,7 @@ def command_enqueue(client: GitHub, selector: str | None) -> None: already_queued=client.config.queue_label in entry.labels, now=utc_now(), ) - body = queue_state_body( - "queued", - entry.head_sha, - queued_at=queued_at, - ) + body = queue_state_body("queued", entry.head_sha, queued_at=queued_at) client.comment(number, body) if client.config.queue_label in entry.labels: # Re-adding the label emits a fresh `labeled` event for GitHub-hosted @@ -1053,6 +1936,66 @@ def command_enqueue(client: GitHub, selector: str | None) -> None: print(f"queued PR #{number} on {entry.head_sha}") +def queue_from_intent( + client: GitHub, + entry: QueueEntry, + intent: dict[str, Any], +) -> None: + if entry.state != "ready": + raise QueueError( + f"PR #{entry.number} is not ready to queue: " + + "; ".join(entry.reasons or ["unknown gate"]) + ) + intent_id = str(intent.get("intent_id") or "") + if intent.get("state") != "requested" or not intent_id: + raise QueueError(f"PR #{entry.number} has no active deploy intent") + if ( + client.config.pipeline.intent_scope == "head" + and intent.get("requested_head") != entry.head_sha + ): + raise QueueError( + f"PR #{entry.number} deploy intent is bound to an older head; " + "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, + ) + queued_at = queue_timestamp( + previous, + already_queued=client.config.queue_label in entry.labels, + now=utc_now(), + ) + client.comment( + entry.number, + queue_state_body( + "queued", + entry.head_sha, + queued_at=queued_at, + intent_id=intent_id, + ), + ) + labels = client.labels(entry.number) + if client.config.queue_label in labels: + client.remove_label(entry.number, client.config.queue_label) + client.add_label(entry.number, client.config.queue_label) + if client.config.blocked_label in labels: + client.remove_label(entry.number, client.config.blocked_label) + notify( + client.config.pipeline, + "queued", + { + "repository": client.repository, + "pull_request": entry.number, + "head_sha": entry.head_sha, + "intent_id": intent_id, + }, + ) + + @dataclass class FreezeResult: batch: dict[str, Any] | None @@ -1083,14 +2026,14 @@ def freeze_queue(client: GitHub) -> FreezeResult: latest = { number: latest_batch_marker( values, - client.coordinator_logins, + coordinator_logins(client), ) for number, values in comments.items() } completed = { batch_id for values in comments.values() - for batch_id in completed_batch_ids(values, client.coordinator_logins) + for batch_id in completed_batch_ids(values, coordinator_logins(client)) } batch = active_batch(entries, latest, completed) if batch is None: @@ -1104,7 +2047,7 @@ def freeze_queue(client: GitHub) -> FreezeResult: for entry in selected if latest_batch_marker( comments[entry.number], - client.coordinator_logins, + coordinator_logins(client), batch_id=str(batch["batch_id"]), ) is None @@ -1173,7 +2116,13 @@ def command_block(client: GitHub, selector: str | None, reason: str) -> None: number = client.resolve_pr(selector) client.ensure_labels() entry = client.snapshot(number, require_marker=False, allow_blocked_label=True) - previous = latest_marker(client.comments(number), client.trusted_logins) + comments = client.comments(number) + previous = effective_queue_marker( + comments, + client.trusted_logins, + coordinator_logins(client), + intent_scope=client.config.pipeline.intent_scope, + ) if ( not previous or previous.get("state") != "queued" @@ -1183,8 +2132,6 @@ def command_block(client: GitHub, selector: str | None, reason: str) -> None: labels = client.labels(number) if client.config.queue_label not in labels: raise QueueError(f"PR #{number} is not in the merge queue") - if client.config.blocked_label not in labels: - client.add_label(number, client.config.blocked_label) client.comment( number, queue_state_body( @@ -1192,15 +2139,27 @@ def command_block(client: GitHub, selector: str | None, reason: str) -> None: entry.head_sha, queued_at=str(previous.get("queued_at") or "") or None, reason=reason, + intent_id=str(previous.get("intent_id") or "") or None, ), ) + record_repair( + client, + entry, + latest_intent(comments, client.trusted_logins), + reason, + ) print(f"blocked PR #{number}: {reason}") 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 = latest_marker(client.comments(number), client.trusted_logins) + previous = effective_queue_marker( + client.comments(number), + client.trusted_logins, + coordinator_logins(client), + intent_scope=client.config.pipeline.intent_scope, + ) if ( not previous or previous.get("state") != "blocked" @@ -1213,6 +2172,7 @@ def command_unblock(client: GitHub, selector: str | None) -> None: "queued", entry.head_sha, queued_at=str(previous.get("queued_at") or "") or None, + intent_id=str(previous.get("intent_id") or "") or None, ), ) labels = client.labels(number) @@ -1227,7 +2187,12 @@ 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 = latest_marker(client.comments(number), client.trusted_logins) + previous = effective_queue_marker( + client.comments(number), + client.trusted_logins, + coordinator_logins(client), + intent_scope=client.config.pipeline.intent_scope, + ) labels = client.labels(number) if client.config.blocked_label not in labels: client.add_label(number, client.config.blocked_label) @@ -1241,6 +2206,9 @@ def command_dequeue(client: GitHub, selector: str | None, reason: str) -> None: str(previous.get("queued_at") or "") or None if previous else None ), reason=reason, + intent_id=( + str(previous.get("intent_id") or "") or None if previous else None + ), ), ) for label in (client.config.queue_label, client.config.blocked_label): @@ -1256,10 +2224,11 @@ def command_merge( *, emit: bool = True, ) -> str: + require_running_pipeline(client) number = client.resolve_pr(selector) batch = latest_batch_marker( client.comments(number), - client.coordinator_logins, + coordinator_logins(client), batch_id=batch_id, ) if batch is None: @@ -1288,21 +2257,20 @@ def command_merge( batch, number, {value.number for value in frozen_entries} ) if peers: - peers = ", ".join( - f"#{value}" for value in peers - ) + peers = ", ".join(f"#{value}" for value in peers) raise QueueError( f"PR #{number} overlaps {peers}; create one cumulative integration PR" ) expected_dependencies = sorted( - int(value) - for value in (batch.get("dependencies") or {}).get(str(number), []) + int(value) for value in (batch.get("dependencies") or {}).get(str(number), []) ) if entry.dependencies != expected_dependencies: raise QueueError(f"PR #{number} dependencies changed after batch freeze") missing_dependencies = [ - value for value in expected_dependencies if not client.dependency_is_merged(value) + value + for value in expected_dependencies + if not client.dependency_is_merged(value) ] if missing_dependencies: raise QueueError( @@ -1324,6 +2292,55 @@ def command_merge( raise QueueError(f"batch {batch_id} predates the current queue authorization") merge_sha = client.merge(number, fresh.head_sha) + merged_comments = client.comments(number) + intent = latest_intent(merged_comments, client.trusted_logins) + integration = latest_payload( + merged_comments, + INTEGRATION_MARKER, + coordinator_logins(client), + ) + if intent and intent.get("provider") and intent.get("thread_id"): + client.record_thread( + ThreadRecord( + provider=str(intent["provider"]), + thread_id=str(intent["thread_id"]), + phase="merged", + updated_at=utc_now(), + pull_request=number, + url=str(intent.get("thread_url") or "") or None, + ) + ) + notify( + client.config.pipeline, + "merged", + { + "repository": client.repository, + "pull_request": number, + "head_sha": fresh.head_sha, + "merge_sha": merge_sha, + "intent_id": (intent or {}).get("intent_id"), + }, + ) + if integration: + for source_number in integration.get("pull_requests") or []: + source_intent = latest_intent( + client.comments(int(source_number)), client.trusted_logins + ) + if ( + source_intent + and source_intent.get("provider") + and source_intent.get("thread_id") + ): + client.record_thread( + ThreadRecord( + provider=str(source_intent["provider"]), + thread_id=str(source_intent["thread_id"]), + phase="merged", + updated_at=utc_now(), + pull_request=int(source_number), + url=str(source_intent.get("thread_url") or "") or None, + ) + ) if emit: print(f"merged PR #{number} as {merge_sha}") return merge_sha @@ -1343,9 +2360,7 @@ def drain_frozen_batch( raise QueueError("cannot drain an empty frozen batch") batch_id = str(frozen.batch["batch_id"]) overlap_numbers = { - number - for group in frozen.overlap_groups - for number in group["pull_requests"] + number for group in frozen.overlap_groups for number in group["pull_requests"] } pending = {entry.number: entry for entry in frozen.queue} merged: list[dict[str, Any]] = [] @@ -1364,9 +2379,7 @@ def drain_frozen_batch( if any(dependency in pending for dependency in entry.dependencies): continue try: - merge_sha = command_merge( - client, str(number), batch_id, emit=False - ) + merge_sha = command_merge(client, str(number), batch_id, emit=False) except QueueError as error: if "GitHub is still computing mergeability" in str(error): retryable = True @@ -1408,14 +2421,15 @@ def drain_frozen_batch( "next_batch": [entry.number for entry in frozen.next_batch], } merged_numbers = {value["number"] for value in merged} - unmerged = [ - entry for entry in frozen.queue if entry.number not in merged_numbers - ] + unmerged = [entry for entry in frozen.queue if entry.number not in merged_numbers] complete_batch(client, frozen.batch, unmerged) return result, unmerged -def command_drain(client: GitHub, *, json_output: bool) -> dict[str, Any]: +def command_drain( + client: GitHub, *, json_output: bool, emit: bool = True +) -> dict[str, Any]: + require_running_pipeline(client) batch_ids: list[str] = [] merged: list[dict[str, Any]] = [] waiting_by_number: dict[int, dict[str, Any]] = {} @@ -1451,6 +2465,8 @@ def command_drain(client: GitHub, *, json_output: bool) -> dict[str, Any]: "waiting": list(waiting_by_number.values()), "next_batch": next_batch, } + if not emit: + return result if json_output: print(json.dumps(result, indent=2, sort_keys=True)) else: @@ -1462,15 +2478,240 @@ def command_drain(client: GitHub, *, json_output: bool) -> dict[str, Any]: for value in result["waiting"]: print(f"waiting: PR #{value['number']} - {value['reason']}") if next_batch: - print( - "next batch: " - + ", ".join(f"#{number}" for number in next_batch) - ) + print("next batch: " + ", ".join(f"#{number}" for number in next_batch)) if not batch_ids: print("merge queue is empty") return result +def command_integrate(client: GitHub, *, all_entries: bool) -> dict[str, Any]: + require_running_pipeline(client) + frozen = freeze_queue(client) + if frozen.batch is None: + raise QueueError("merge queue is empty") + selected = frozen.queue + if not all_entries: + members = { + number + for group in frozen.overlap_groups + for number in group["pull_requests"] + } + selected = [entry for entry in selected if entry.number in members] + if len(selected) < 2: + raise QueueError("an integration pull request needs at least two queue items") + result = client.create_integration_pull_request( + 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"]), + ) + notify( + client.config.pipeline, + "integration-created", + {"repository": client.repository, **result}, + ) + print(json.dumps(result, indent=2, sort_keys=True)) + return result + + +def command_follow( + client: GitHub, + *, + timeout_seconds: int, + poll_seconds: int, + json_output: bool, + emit: bool = True, +) -> dict[str, Any]: + result = follow_release( + client, + timeout_seconds=timeout_seconds, + poll_seconds=poll_seconds, + ) + if ( + result["state"] in {"ci-failed", "deploy-failed"} + and client.config.pipeline.pause_on_failure + ): + client.set_pipeline_control( + "paused", + f"{result['state']} on {result['main_sha']}", + ) + if result["state"] == "verified": + for record in client.thread_records(): + if record.get("phase") != "merged": + continue + client.record_thread( + ThreadRecord( + provider=str(record["provider"]), + thread_id=str(record["thread_id"]), + phase="completed", + updated_at=utc_now(), + title=str(record.get("title") or "") or None, + branch=str(record.get("branch") or "") or None, + pull_request=record.get("pull_request"), + url=str(record.get("url") or "") or None, + ) + ) + notify( + client.config.pipeline, + "verified", + {"repository": client.repository, **result}, + ) + if not emit: + return result + if json_output: + print(json.dumps(result, indent=2, sort_keys=True)) + else: + print(f"release {result['state']} on {result['main_sha']}") + return result + + +def command_react( + client: GitHub, + *, + follow: bool, + timeout_seconds: int, +) -> dict[str, Any]: + control = client.pipeline_control() + if control.get("state") == "paused": + result = {"state": "paused", "reason": control.get("reason")} + print(json.dumps(result, indent=2, sort_keys=True)) + return result + promoted_integrations = promote_integrations(client) + promoted = command_promote(client, emit=False) + frozen = freeze_queue(client) + integrations: list[dict[str, Any]] = [] + if ( + frozen.batch is not None + and client.config.integration.mode == "all" + and not promoted_integrations + ): + integrations.append( + client.create_integration_pull_request( + batch=frozen.batch, + entries=frozen.queue, + ) + ) + drained: dict[str, Any] = { + "batch_id": frozen.batch["batch_id"], + "merged": [], + "waiting": [], + "integration_required": frozen.overlap_groups, + "next_batch": [], + } + else: + drained = command_drain(client, json_output=False, emit=False) + if ( + frozen.batch is not None + and client.config.integration.mode == "overlap" + and frozen.overlap_groups + ): + overlap_members = { + number + for group in frozen.overlap_groups + for number in group["pull_requests"] + } + 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, + ) + ) + release: dict[str, Any] | None = None + if follow and drained.get("merged"): + release = command_follow( + client, + timeout_seconds=timeout_seconds, + poll_seconds=10, + json_output=False, + emit=False, + ) + result = { + "state": "complete", + "promoted": promoted, + "promoted_integrations": promoted_integrations, + "drain": drained, + "integrations": integrations, + "release": release, + } + print(json.dumps(result, indent=2, sort_keys=True)) + return result + + +def command_control(client: GitHub, *, state: str, reason: str | None) -> None: + client.set_pipeline_control(state, reason) + print(f"DeployBot pipeline is {state}") + + +def delivery_metrics(client: GitHub, *, limit: int) -> dict[str, Any]: + deploy_runs = [ + run + for run in client.workflow_runs() + if str(run.get("name") or "") in set(client.config.pipeline.deploy_workflows) + and run.get("conclusion") == "success" + ] + samples: list[dict[str, Any]] = [] + for pull in client.recent_merged_pull_requests(limit): + 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, + ) + merged_at = str(pull.get("merged_at") or "") or None + merge_sha = str(pull.get("merge_commit_sha") or "") + live_at: str | None = None + if merge_sha: + for run in sorted( + deploy_runs, key=lambda item: str(item.get("updated_at") or "") + ): + deployed_sha = str(run.get("head_sha") or "") + if deployed_sha and client.is_ancestor(merge_sha, deployed_sha): + live_at = str(run.get("updated_at") or "") or None + break + requested_at = str((intent or {}).get("requested_at") or "") or None + queued_at = str((marker or {}).get("queued_at") or "") or None + samples.append( + { + "pull_request": number, + "requested_at": requested_at, + "queued_at": queued_at, + "merged_at": merged_at, + "live_at": live_at, + "request_to_queue_seconds": seconds_between(requested_at, queued_at), + "queue_to_merge_seconds": seconds_between(queued_at, merged_at), + "merge_to_live_seconds": seconds_between(merged_at, live_at), + "request_to_live_seconds": seconds_between(requested_at, live_at), + } + ) + return summarize_metrics(samples) + + +def print_doctor(rows: list[dict[str, Any]], *, json_output: bool) -> None: + if json_output: + print(json.dumps(rows, indent=2, sort_keys=True)) + return + symbols = {"ok": "✓", "warn": "⚠", "fail": "✗"} + for value in rows: + line = f"{symbols[value['status']]} {value['check']}: {value['detail']}" + if value.get("hint"): + line += f" — {value['hint']}" + print(line) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--version", action="version", version=__version__) @@ -1487,15 +2728,29 @@ def build_parser() -> argparse.ArgumentParser: initialize = subparsers.add_parser("init", help="create a safe starter policy") initialize.add_argument("--force", action="store_true") subparsers.add_parser("ensure-labels", help="create or refresh queue labels") - inspect = subparsers.add_parser("inspect", help="evaluate one PR without queueing it") + inspect = subparsers.add_parser( + "inspect", help="evaluate one PR without queueing it" + ) inspect.add_argument("pr", nargs="?", help="PR number, URL, or branch") inspect.add_argument("--json", action="store_true", dest="json_output") plan = subparsers.add_parser("plan", help="show the current ordered queue") plan.add_argument("--json", action="store_true", dest="json_output") status = subparsers.add_parser( - "status", help="show queue readiness, blockers, and overlaps" + "status", help="show threads, PRs, queue, CI, and deployments" ) status.add_argument("--json", action="store_true", dest="json_output") + doctor = subparsers.add_parser("doctor", help="diagnose installation and policy") + doctor.add_argument("--json", action="store_true", dest="json_output") + thread = subparsers.add_parser("thread", help="record cross-client thread state") + thread_commands = thread.add_subparsers(dest="thread_command", required=True) + thread_update = thread_commands.add_parser("update") + thread_update.add_argument("--provider", required=True) + thread_update.add_argument("--thread-id", required=True) + thread_update.add_argument("--phase", required=True, choices=sorted(THREAD_PHASES)) + thread_update.add_argument("--title") + thread_update.add_argument("--branch") + thread_update.add_argument("--pr", type=int, dest="pull_request") + thread_update.add_argument("--url") freeze = subparsers.add_parser("freeze", help="persist one exact queue pass") freeze.add_argument("--json", action="store_true", dest="json_output") drain = subparsers.add_parser( @@ -1503,7 +2758,39 @@ def build_parser() -> argparse.ArgumentParser: ) drain.add_argument("--json", action="store_true", dest="json_output") - for name in ("enqueue", "unblock"): + request = subparsers.add_parser( + "request", help="record the user's durable deploy intent" + ) + request.add_argument("pr", nargs="?", help="PR number, URL, or branch") + request.add_argument("--provider") + request.add_argument("--thread-id") + request.add_argument("--thread-url") + cancel_request = subparsers.add_parser("cancel-request") + cancel_request.add_argument("pr", nargs="?", help="PR number, URL, or branch") + refresh_request = subparsers.add_parser("refresh-request") + refresh_request.add_argument("pr", nargs="?", help="PR number, URL, or branch") + subparsers.add_parser("promote", help="promote ready deploy requests into queue") + react = subparsers.add_parser("react", help="event-driven promote and drain worker") + react.add_argument("--follow", action="store_true") + react.add_argument("--timeout", type=int, default=1800) + integrate = subparsers.add_parser("integrate", help="scaffold a cumulative PR") + integrate.add_argument("--all", action="store_true", dest="all_entries") + follow = subparsers.add_parser( + "follow", help="follow exact main through deployment" + ) + follow.add_argument("--timeout", type=int, default=1800) + follow.add_argument("--poll", type=int, default=10) + follow.add_argument("--json", action="store_true", dest="json_output") + pause = subparsers.add_parser( + "pause", help="pause merging after a delivery failure" + ) + pause.add_argument("--reason", required=True) + subparsers.add_parser("unpause", help="resume a paused delivery pipeline") + metrics = subparsers.add_parser("metrics", help="show delivery timing percentiles") + metrics.add_argument("--limit", type=int, default=25) + metrics.add_argument("--json", action="store_true", dest="json_output") + + for name in ("enqueue", "unblock", "resume"): command = subparsers.add_parser(name) command.add_argument("pr", nargs="?", help="PR number, URL, or branch") @@ -1532,6 +2819,13 @@ def main(argv: list[str] | None = None) -> int: path = initialize_config(arguments.config, force=arguments.force) print(f"created {path}") return 0 + if arguments.command == "doctor": + rows = diagnose( + config_path=arguments.config, + repository=arguments.repository, + ) + print_doctor(rows, json_output=arguments.json_output) + return 1 if any(value["status"] == "fail" for value in rows) else 0 config = load_config(arguments.config) client = GitHub(config, arguments.repository) if arguments.command == "ensure-labels": @@ -1539,14 +2833,82 @@ def main(argv: list[str] | None = None) -> int: print("merge queue labels are ready") elif arguments.command == "inspect": command_inspect(client, arguments.pr, json_output=arguments.json_output) - elif arguments.command in {"plan", "status"}: + elif arguments.command == "plan": print_plan(client.queue(), json_output=arguments.json_output) + elif arguments.command == "status": + print_pipeline_status( + pipeline_status(client), json_output=arguments.json_output + ) + elif arguments.command == "thread": + command_thread_update( + client, + provider=arguments.provider, + thread_id=arguments.thread_id, + phase=arguments.phase, + title=arguments.title, + branch=arguments.branch, + pull_request=arguments.pull_request, + url=arguments.url, + ) elif arguments.command == "freeze": command_freeze(client, json_output=arguments.json_output) elif arguments.command == "drain": command_drain(client, json_output=arguments.json_output) elif arguments.command == "enqueue": command_enqueue(client, arguments.pr) + elif arguments.command == "request": + command_request( + client, + arguments.pr, + provider=arguments.provider, + thread_id=arguments.thread_id, + thread_url=arguments.thread_url, + ) + elif arguments.command == "cancel-request": + command_cancel_request(client, arguments.pr) + elif arguments.command == "refresh-request": + command_refresh_request(client, arguments.pr) + elif arguments.command == "promote": + command_promote(client) + elif arguments.command == "resume": + command_resume(client, arguments.pr) + elif arguments.command == "react": + if arguments.timeout < 1: + raise QueueError("--timeout must be positive") + command_react( + client, + follow=arguments.follow, + timeout_seconds=arguments.timeout, + ) + elif arguments.command == "integrate": + command_integrate(client, all_entries=arguments.all_entries) + elif arguments.command == "follow": + if arguments.timeout < 1 or arguments.poll < 1: + raise QueueError("--timeout and --poll must be positive") + command_follow( + client, + timeout_seconds=arguments.timeout, + poll_seconds=arguments.poll, + json_output=arguments.json_output, + ) + elif arguments.command == "pause": + command_control(client, state="paused", reason=arguments.reason) + elif arguments.command == "unpause": + command_control(client, state="running", reason=None) + elif arguments.command == "metrics": + if arguments.limit < 1: + raise QueueError("--limit must be positive") + value = delivery_metrics(client, limit=arguments.limit) + if arguments.json_output: + print(json.dumps(value, indent=2, sort_keys=True)) + else: + print(f"delivery samples: {value['sample_count']}") + for name, summary in value.items(): + if name.endswith("_seconds"): + print( + f"{name}: p50={summary['p50']}s " + f"p95={summary['p95']}s max={summary['max']}s" + ) elif arguments.command == "block": command_block(client, arguments.pr, arguments.reason) elif arguments.command == "unblock": diff --git a/src/agent_merge_queue/config.py b/src/agent_merge_queue/config.py index 11a1599..99d7571 100644 --- a/src/agent_merge_queue/config.py +++ b/src/agent_merge_queue/config.py @@ -28,6 +28,38 @@ class ReviewProviderConfig: require_resolved_threads: bool = False +@dataclass(frozen=True) +class VerificationConfig: + name: str + url: str + expected_status: int = 200 + + +@dataclass(frozen=True) +class PipelineConfig: + intent_label: str + pause_label: str + registry_label: str + registry_title: str + thread_active_hours: int + ci_workflows: tuple[str, ...] + deploy_workflows: tuple[str, ...] + ready_to_merge_target_minutes: int + merge_to_live_target_minutes: int + auto_promote: bool + intent_scope: str + pause_on_failure: bool + webhook_url_env: str | None + verifications: tuple[VerificationConfig, ...] + + +@dataclass(frozen=True) +class IntegrationConfig: + mode: str + branch_prefix: str + title_prefix: str + + @dataclass(frozen=True) class QueueConfig: base_branch: str @@ -42,10 +74,14 @@ class QueueConfig: generated_version_paths: frozenset[str] asset_version_pattern: str review_providers: tuple[ReviewProviderConfig, ...] + pipeline: PipelineConfig + integration: IntegrationConfig ALLOWED_REVIEW_PROVIDERS = {"bot", "check", "github-approvals"} ALLOWED_MERGE_METHODS = {"merge", "squash", "rebase"} +ALLOWED_INTEGRATION_MODES = {"manual", "overlap", "all"} +ALLOWED_INTENT_SCOPES = {"head"} DEFAULT_CONFIG = """\ [queue] base_branch = "main" @@ -71,6 +107,28 @@ class QueueConfig: # name = "Human approval" # allowed_reviewers = ["reviewer-login"] # minimum_approvals = 1 + +[pipeline] +intent_label = "deploy-requested" +pause_label = "deploybot-paused" +registry_label = "deploybot-registry" +registry_title = "DeployBot delivery registry" +thread_active_hours = 72 +ci_workflows = ["CI"] +deploy_workflows = ["Deploy"] +ready_to_merge_target_minutes = 15 +merge_to_live_target_minutes = 10 +auto_promote = true +intent_scope = "head" +pause_on_failure = true +# webhook_url_env = "DEPLOYBOT_WEBHOOK_URL" + +[integration] +# manual: report overlap; overlap: scaffold one PR for overlap groups; +# all: validate every frozen batch through one cumulative integration PR. +mode = "manual" +branch_prefix = "deploybot/integration" +title_prefix = "DeployBot integration" """ @@ -92,6 +150,49 @@ def _string_tuple(value: Any, field: str) -> tuple[str, ...]: return tuple(dict.fromkeys(item.strip() for item in value)) +def _positive_int(value: Any, field: str, default: int) -> int: + if value is None: + return default + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise ConfigError(f"{field} must be a positive integer") + return value + + +def _boolean(value: Any, field: str, default: bool) -> bool: + if value is None: + return default + if not isinstance(value, bool): + raise ConfigError(f"{field} must be true or false") + return value + + +def _verifications(value: Any) -> tuple[VerificationConfig, ...]: + if value is None: + return () + if not isinstance(value, list): + raise ConfigError("pipeline.verifications must be an array of tables") + result: list[VerificationConfig] = [] + for index, item in enumerate(value): + field = f"pipeline.verifications[{index}]" + if not isinstance(item, dict): + raise ConfigError(f"{field} must be a table") + status = item.get("expected_status", 200) + if ( + not isinstance(status, int) + or isinstance(status, bool) + or not 100 <= status <= 599 + ): + raise ConfigError(f"{field}.expected_status must be an HTTP status") + result.append( + VerificationConfig( + name=_require_string(item.get("name"), f"{field}.name"), + url=_require_string(item.get("url"), f"{field}.url"), + expected_status=status, + ) + ) + return tuple(result) + + def _provider(value: Any, index: int) -> ReviewProviderConfig: field = f"review.providers[{index}]" if not isinstance(value, dict): @@ -172,10 +273,14 @@ def parse_config(payload: dict[str, Any]) -> QueueConfig: queue = payload.get("queue") or {} files = payload.get("files") or {} review = payload.get("review") or {} + pipeline = payload.get("pipeline") or {} + integration = payload.get("integration") or {} if not isinstance(queue, dict) or not isinstance(files, dict): raise ConfigError("queue and files must be tables") if not isinstance(review, dict): raise ConfigError("review must be a table") + if not isinstance(pipeline, dict) or not isinstance(integration, dict): + raise ConfigError("pipeline and integration must be tables") merge_method = _require_string( queue.get("merge_method"), "queue.merge_method", "merge" @@ -186,9 +291,7 @@ def parse_config(payload: dict[str, Any]) -> QueueConfig: required_checks = _string_tuple( queue.get("required_checks"), "queue.required_checks" ) - trusted_actors = _string_tuple( - queue.get("trusted_actors"), "queue.trusted_actors" - ) + trusted_actors = _string_tuple(queue.get("trusted_actors"), "queue.trusted_actors") if not trusted_actors or "YOUR_GITHUB_LOGIN" in trusted_actors: raise ConfigError( "queue.trusted_actors must explicitly name every user or bot whose " @@ -199,9 +302,10 @@ def parse_config(payload: dict[str, Any]) -> QueueConfig: "queue.trusted_actors cannot include github-actions[bot]; list it under " "queue.coordinator_actors so shared workflows cannot authorize a PR" ) - coordinator_actors = _string_tuple( - queue.get("coordinator_actors"), "queue.coordinator_actors" - ) or trusted_actors + coordinator_actors = ( + _string_tuple(queue.get("coordinator_actors"), "queue.coordinator_actors") + or trusted_actors + ) if not coordinator_actors or "YOUR_GITHUB_LOGIN" in coordinator_actors: raise ConfigError( "queue.coordinator_actors must explicitly name every user or bot " @@ -229,8 +333,26 @@ def parse_config(payload: dict[str, Any]) -> QueueConfig: except re.error as error: raise ConfigError(f"files.asset_version_pattern is invalid: {error}") from error + integration_mode = _require_string( + integration.get("mode"), "integration.mode", "manual" + ) + if integration_mode not in ALLOWED_INTEGRATION_MODES: + allowed = ", ".join(sorted(ALLOWED_INTEGRATION_MODES)) + raise ConfigError(f"integration.mode must be one of: {allowed}") + webhook_url_env = pipeline.get("webhook_url_env") + if webhook_url_env is not None: + webhook_url_env = _require_string(webhook_url_env, "pipeline.webhook_url_env") + intent_scope = _require_string( + pipeline.get("intent_scope"), "pipeline.intent_scope", "head" + ) + if intent_scope not in ALLOWED_INTENT_SCOPES: + allowed = ", ".join(sorted(ALLOWED_INTENT_SCOPES)) + raise ConfigError(f"pipeline.intent_scope must be one of: {allowed}") + return QueueConfig( - base_branch=_require_string(queue.get("base_branch"), "queue.base_branch", "main"), + base_branch=_require_string( + queue.get("base_branch"), "queue.base_branch", "main" + ), queue_label=_require_string( queue.get("queue_label"), "queue.queue_label", "merge-queue" ), @@ -259,6 +381,75 @@ def parse_config(payload: dict[str, Any]) -> QueueConfig: ), asset_version_pattern=version_pattern, review_providers=providers, + pipeline=PipelineConfig( + intent_label=_require_string( + pipeline.get("intent_label"), + "pipeline.intent_label", + "deploy-requested", + ), + pause_label=_require_string( + pipeline.get("pause_label"), + "pipeline.pause_label", + "deploybot-paused", + ), + registry_label=_require_string( + pipeline.get("registry_label"), + "pipeline.registry_label", + "deploybot-registry", + ), + registry_title=_require_string( + pipeline.get("registry_title"), + "pipeline.registry_title", + "DeployBot delivery registry", + ), + thread_active_hours=_positive_int( + pipeline.get("thread_active_hours"), + "pipeline.thread_active_hours", + 72, + ), + ci_workflows=_string_tuple( + pipeline.get("ci_workflows", ["CI"]), + "pipeline.ci_workflows", + ), + deploy_workflows=_string_tuple( + pipeline.get("deploy_workflows", ["Deploy"]), + "pipeline.deploy_workflows", + ), + ready_to_merge_target_minutes=_positive_int( + pipeline.get("ready_to_merge_target_minutes"), + "pipeline.ready_to_merge_target_minutes", + 15, + ), + merge_to_live_target_minutes=_positive_int( + pipeline.get("merge_to_live_target_minutes"), + "pipeline.merge_to_live_target_minutes", + 10, + ), + auto_promote=_boolean( + pipeline.get("auto_promote"), "pipeline.auto_promote", True + ), + intent_scope=intent_scope, + pause_on_failure=_boolean( + pipeline.get("pause_on_failure"), + "pipeline.pause_on_failure", + True, + ), + webhook_url_env=webhook_url_env, + verifications=_verifications(pipeline.get("verifications")), + ), + integration=IntegrationConfig( + mode=integration_mode, + branch_prefix=_require_string( + integration.get("branch_prefix"), + "integration.branch_prefix", + "deploybot/integration", + ).rstrip("/"), + title_prefix=_require_string( + integration.get("title_prefix"), + "integration.title_prefix", + "DeployBot integration", + ), + ), ) @@ -282,7 +473,9 @@ def load_config(path: str | None = None, *, cwd: Path | None = None) -> QueueCon try: payload = tomllib.loads(raw.decode("utf-8")) except (UnicodeDecodeError, tomllib.TOMLDecodeError) as error: - raise ConfigError(f"invalid merge queue config {config_path}: {error}") from error + raise ConfigError( + f"invalid merge queue config {config_path}: {error}" + ) from error return parse_config(payload) diff --git a/src/agent_merge_queue/doctor.py b/src/agent_merge_queue/doctor.py new file mode 100644 index 0000000..bb74625 --- /dev/null +++ b/src/agent_merge_queue/doctor.py @@ -0,0 +1,252 @@ +"""Read-only DeployBot installation and policy diagnostics.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path +from typing import Any + +from .config import ConfigError, QueueConfig, load_config + + +def row( + check: str, status: str, detail: str, hint: str | None = None +) -> dict[str, Any]: + return {"check": check, "status": status, "detail": detail, "hint": hint} + + +def _gh(*arguments: str, cwd: Path) -> tuple[int, str]: + completed = subprocess.run( + ["gh", *arguments], + cwd=cwd, + text=True, + capture_output=True, + check=False, + ) + output = completed.stdout.strip() or completed.stderr.strip() + return completed.returncode, output + + +def _json(*arguments: str, cwd: Path) -> tuple[int, Any, str]: + code, output = _gh(*arguments, cwd=cwd) + if code: + return code, None, output + try: + return 0, json.loads(output), "" + except json.JSONDecodeError: + return 1, None, "GitHub returned invalid JSON" + + +def diagnose( + *, + config_path: str | None, + repository: str | None, + cwd: Path | None = None, +) -> list[dict[str, Any]]: + root = (cwd or Path.cwd()).resolve() + rows: list[dict[str, Any]] = [] + if shutil.which("gh") is None: + return [ + row( + "tooling", + "fail", + "GitHub CLI not found", + "Install gh from https://cli.github.com/.", + ) + ] + rows.append(row("tooling", "ok", "GitHub CLI is installed")) + + code, auth = _gh("auth", "status", cwd=root) + authenticated = code == 0 + rows.append( + row( + "authentication", + "ok" if authenticated else "fail", + auth + or ( + "GitHub authentication is active" + if authenticated + else "Not authenticated" + ), + None if authenticated else "Run `gh auth login`.", + ) + ) + + config: QueueConfig | None = None + try: + config = load_config(config_path, cwd=root) + rows.append(row("configuration", "ok", "DeployBot policy is valid")) + except ConfigError as exc: + rows.append(row("configuration", "fail", str(exc), "Fix .mergequeue.toml.")) + + if not authenticated: + rows.append(row("repository", "warn", "Skipped: GitHub is not authenticated")) + return rows + + repo = repository + if not repo: + code, value, detail = _json("repo", "view", "--json", "nameWithOwner", cwd=root) + if code: + rows.append( + row( + "repository", + "fail", + detail, + "Run inside a GitHub checkout or pass --repository.", + ) + ) + return rows + repo = str(value.get("nameWithOwner") or "") + code, _, detail = _json("repo", "view", repo, "--json", "nameWithOwner", cwd=root) + if code: + rows.append(row("repository", "fail", detail, "Check repository access.")) + return rows + rows.append(row("repository", "ok", f"Can read {repo}")) + if config is None: + return rows + + code, workflows, detail = _json( + "workflow", "list", "--repo", repo, "--json", "name,path,state", cwd=root + ) + if code: + rows.append(row("event-worker", "warn", detail)) + else: + installed = [ + value + for value in workflows + if str(value.get("name") or "").lower() == "deploybot" + and value.get("state") == "active" + ] + rows.append( + row( + "event-worker", + "ok" if installed else "warn", + "DeployBot workflow is active" + if installed + else "No active DeployBot workflow was found", + None + if installed + else "Install examples/github-workflow.yml on the default branch.", + ) + ) + + code, labels, detail = _json( + "label", "list", "--repo", repo, "--limit", "1000", "--json", "name", cwd=root + ) + if code: + rows.append(row("labels", "warn", detail, "Check token metadata permissions.")) + else: + names = {str(item.get("name") or "") for item in labels} + required = { + config.queue_label, + config.blocked_label, + config.pipeline.intent_label, + config.pipeline.pause_label, + config.pipeline.registry_label, + } + missing = sorted(required - names) + rows.append( + row( + "labels", + "warn" if missing else "ok", + "Missing: " + ", ".join(missing) + if missing + else "All DeployBot labels exist", + "Run `deploybot ensure-labels`." if missing else None, + ) + ) + + owner = repo.split("/", 1)[0] + actors = set(config.trusted_actors) | set(config.coordinator_actors) + actors.update( + reviewer + for provider in config.review_providers + for reviewer in provider.allowed_reviewers + ) + unknown: list[str] = [] + skipped: list[str] = [] + for actor in sorted(actors): + login = owner if actor == "@repository-owner" else actor + if login.lower() == "github-actions[bot]": + skipped.append(login) + continue + code, _, _ = _json("api", f"users/{login}", cwd=root) + if code: + unknown.append(login) + rows.append( + row( + "actors", + "fail" if unknown else "ok", + ( + "Unknown GitHub login(s): " + ", ".join(unknown) + if unknown + else "Trusted identities resolve" + ) + + (f"; built-in bot skipped: {', '.join(skipped)}" if skipped else ""), + "Correct the actor and reviewer allowlists." if unknown else None, + ) + ) + + code, pulls, detail = _json( + "pr", + "list", + "--repo", + repo, + "--state", + "all", + "--limit", + "20", + "--json", + "statusCheckRollup", + cwd=root, + ) + if code: + rows.append(row("required-checks", "warn", detail)) + else: + observed = { + str(check.get("name") or check.get("context") or "") + for pull in pulls + for check in pull.get("statusCheckRollup") or [] + } + missing = sorted(set(config.required_checks) - observed) + rows.append( + row( + "required-checks", + "warn" if missing else "ok", + "Never observed: " + ", ".join(missing) + if missing + else "Configured check names were observed", + "Use the exact GitHub check display names." if missing else None, + ) + ) + + code, protection, detail = _json( + "api", f"repos/{repo}/branches/{config.base_branch}/protection", cwd=root + ) + if code: + rows.append( + row( + "branch-protection", + "warn", + detail or "Could not read branch protection", + "Confirm rulesets independently enforce required checks and forbid bypass.", + ) + ) + else: + contexts = set( + (protection.get("required_status_checks") or {}).get("contexts") or [] + ) + missing = sorted(set(config.required_checks) - contexts) + rows.append( + row( + "branch-protection", + "warn" if missing else "ok", + "Not independently required: " + ", ".join(missing) + if missing + else "Required checks are protected", + "Update the base-branch ruleset." if missing else None, + ) + ) + return rows diff --git a/src/agent_merge_queue/mcp_server.py b/src/agent_merge_queue/mcp_server.py index 1c86ded..cbde1b8 100644 --- a/src/agent_merge_queue/mcp_server.py +++ b/src/agent_merge_queue/mcp_server.py @@ -21,6 +21,7 @@ def _run( *arguments: str, repository: str | None = None, config: str | None = None, + allow_nonzero: bool = False, ) -> str: argv = [sys.executable, "-m", "agent_merge_queue.cli"] if config: @@ -30,7 +31,7 @@ def _run( argv.append(command) argv.extend(arguments) completed = subprocess.run(argv, text=True, capture_output=True, check=False) - if completed.returncode: + if completed.returncode and not allow_nonzero: raise RuntimeError(completed.stderr.strip() or completed.stdout.strip()) return completed.stdout.strip() @@ -41,6 +42,24 @@ def queue_plan(repository: str | None = None, config: str | None = None) -> str: return _run("plan", "--json", repository=repository, config=config) +@mcp.tool() +def pipeline_status(repository: str | None = None, config: str | None = None) -> str: + """Read active threads, PR stages, queue, CI, and deployment status.""" + return _run("status", "--json", repository=repository, config=config) + + +@mcp.tool() +def diagnose(repository: str | None = None, config: str | None = None) -> str: + """Run read-only installation, policy, and GitHub health checks.""" + return _run( + "doctor", + "--json", + repository=repository, + config=config, + allow_nonzero=True, + ) + + @mcp.tool() def inspect_pull_request( pull_request: str, @@ -61,6 +80,55 @@ def enqueue_pull_request( return _run("enqueue", pull_request, repository=repository, config=config) +@mcp.tool() +def request_deployment( + pull_request: str, + provider: str | None = None, + thread_id: str | None = None, + thread_url: str | None = None, + repository: str | None = None, + config: str | None = None, +) -> str: + """Persist the user's deploy intent even while exact-head gates are pending.""" + arguments = [pull_request] + for flag, value in ( + ("--provider", provider), + ("--thread-id", thread_id), + ("--thread-url", thread_url), + ): + if value: + arguments.extend((flag, value)) + return _run("request", *arguments, repository=repository, config=config) + + +@mcp.tool() +def cancel_deployment_request( + pull_request: str, + repository: str | None = None, + config: str | None = None, +) -> str: + """Cancel one durable deploy request before it merges.""" + return _run("cancel-request", pull_request, repository=repository, config=config) + + +@mcp.tool() +def refresh_deployment_request( + pull_request: str, + repository: str | None = None, + config: str | None = None, +) -> str: + """Bind existing user intent to a freshly reviewed replacement head.""" + return _run("refresh-request", pull_request, repository=repository, config=config) + + +@mcp.tool() +def promote_deployment_requests( + repository: str | None = None, config: str | None = None +) -> str: + """Promote every exact-head-ready deploy request into the merge queue.""" + return _run("promote", repository=repository, config=config) + + @mcp.tool() def freeze_queue(repository: str | None = None, config: str | None = None) -> str: """Freeze the current queue membership and exact head SHAs.""" @@ -73,6 +141,93 @@ def drain_queue(repository: str | None = None, config: str | None = None) -> str return _run("drain", "--json", repository=repository, config=config) +@mcp.tool() +def react_to_delivery_event( + follow: bool = False, + timeout_seconds: int = 1800, + repository: str | None = None, + config: str | None = None, +) -> str: + """Run the event-driven promotion, batching, merge, and optional release flow.""" + arguments = ["--timeout", str(timeout_seconds)] + if follow: + arguments.append("--follow") + return _run("react", *arguments, repository=repository, config=config) + + +@mcp.tool() +def create_integration_pull_request( + include_all: bool = False, + repository: str | None = None, + config: str | None = None, +) -> str: + """Scaffold a cumulative PR for overlaps or full-batch validation.""" + arguments = ["--all"] if include_all else [] + return _run("integrate", *arguments, repository=repository, config=config) + + +@mcp.tool() +def follow_release( + timeout_seconds: int = 1800, + repository: str | None = None, + config: str | None = None, +) -> str: + """Follow newest exact main through CI, deployment, and health verification.""" + return _run( + "follow", + "--timeout", + str(timeout_seconds), + "--json", + repository=repository, + config=config, + ) + + +@mcp.tool() +def delivery_metrics( + limit: int = 25, + repository: str | None = None, + config: str | None = None, +) -> str: + """Read p50/p95 delivery timing for recent merged pull requests.""" + return _run( + "metrics", "--limit", str(limit), "--json", repository=repository, config=config + ) + + +@mcp.tool() +def update_agent_thread( + provider: str, + thread_id: str, + phase: str, + pull_request: int | None = None, + title: str | None = None, + branch: str | None = None, + url: str | None = None, + repository: str | None = None, + config: str | None = None, +) -> str: + """Publish metadata-only thread state for cross-client coordination.""" + arguments = [ + "update", + "--provider", + provider, + "--thread-id", + thread_id, + "--phase", + phase, + ] + for flag, value in ( + ("--pr", str(pull_request) if pull_request is not None else None), + ("--title", title), + ("--branch", branch), + ("--url", url), + ): + if value: + arguments.extend((flag, value)) + return _run("thread", *arguments, repository=repository, config=config) + + @mcp.tool() def block_pull_request( pull_request: str, @@ -101,6 +256,16 @@ def unblock_pull_request( return _run("unblock", pull_request, repository=repository, config=config) +@mcp.tool() +def resume_pull_request( + pull_request: str, + repository: str | None = None, + config: str | None = None, +) -> str: + """Atomically verify, unblock, requeue, and wake a repaired pull request.""" + return _run("resume", pull_request, repository=repository, config=config) + + @mcp.tool() def dequeue_pull_request( pull_request: str, diff --git a/src/agent_merge_queue/pipeline.py b/src/agent_merge_queue/pipeline.py new file mode 100644 index 0000000..abdeb84 --- /dev/null +++ b/src/agent_merge_queue/pipeline.py @@ -0,0 +1,208 @@ +"""Delivery status, release following, notifications, and timing metrics.""" + +from __future__ import annotations + +import json +import math +import os +import time +from typing import Any, Iterable +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from .config import PipelineConfig +from .records import parse_time + + +def workflow_run(run: dict[str, Any]) -> dict[str, Any]: + return { + "id": run.get("id"), + "name": run.get("name"), + "head_sha": run.get("head_sha"), + "status": run.get("status"), + "conclusion": run.get("conclusion"), + "event": run.get("event"), + "created_at": run.get("created_at"), + "updated_at": run.get("updated_at"), + "url": run.get("html_url"), + } + + +def latest_run( + runs: Iterable[dict[str, Any]], names: Iterable[str], head_sha: str +) -> dict[str, Any] | None: + allowed = set(names) + matching = [ + run + for run in runs + if str(run.get("name") or "") in allowed + and str(run.get("head_sha") or "") == head_sha + ] + return max( + matching, + key=lambda run: (str(run.get("created_at") or ""), int(run.get("id") or 0)), + default=None, + ) + + +def release_state( + *, main_sha: str, runs: list[dict[str, Any]], config: PipelineConfig +) -> dict[str, Any]: + ci = latest_run(runs, config.ci_workflows, main_sha) + deploy = latest_run(runs, config.deploy_workflows, main_sha) + active_ci = [ + workflow_run(run) + for run in runs + if str(run.get("name") or "") in set(config.ci_workflows) + and str(run.get("status") or "") != "completed" + ] + active_deploys = [ + workflow_run(run) + for run in runs + if str(run.get("name") or "") in set(config.deploy_workflows) + and str(run.get("status") or "") != "completed" + ] + ci_status = str((ci or {}).get("status") or "") + ci_conclusion = str((ci or {}).get("conclusion") or "") + deploy_status = str((deploy or {}).get("status") or "") + deploy_conclusion = str((deploy or {}).get("conclusion") or "") + if ci is None or ci_status != "completed": + state = "testing" + elif ci_conclusion != "success": + state = "ci-failed" + elif deploy is None: + state = "awaiting-deploy" + elif deploy_status != "completed": + state = "deploying" + elif deploy_conclusion != "success": + state = "deploy-failed" + else: + state = "verified" + return { + "state": state, + "main_sha": main_sha, + "latest_ci": workflow_run(ci) if ci else None, + "latest_deploy": workflow_run(deploy) if deploy else None, + "active_ci": active_ci, + "active_deployments": active_deploys, + } + + +def http_verifications(config: PipelineConfig) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + for verification in config.verifications: + request = Request( + verification.url, + headers={"User-Agent": "DeployBot/verification"}, + ) + status: int | None = None + error: str | None = None + try: + with urlopen(request, timeout=10) as response: # noqa: S310 + status = int(response.status) + except HTTPError as exc: + status = int(exc.code) + except (OSError, URLError) as exc: + error = str(exc) + results.append( + { + "name": verification.name, + "url": verification.url, + "expected_status": verification.expected_status, + "status": status, + "passed": status == verification.expected_status, + "error": error, + } + ) + return results + + +def notify(config: PipelineConfig, event: str, payload: dict[str, Any]) -> bool: + env_name = config.webhook_url_env + if not env_name: + return False + url = os.environ.get(env_name) + if not url: + return False + body = json.dumps({"event": event, **payload}, sort_keys=True).encode() + request = Request( + url, + data=body, + headers={"Content-Type": "application/json", "User-Agent": "DeployBot"}, + method="POST", + ) + try: + with urlopen(request, timeout=10): # noqa: S310 + return True + except (HTTPError, OSError, URLError): + # GitHub records remain the durable source of truth. Notifications are + # a convenience and must never block or roll back a safe queue action. + return False + + +def seconds_between(start: str | None, end: str | None) -> float | None: + left = parse_time(start) + right = parse_time(end) + if left is None or right is None or right < left: + return None + return (right - left).total_seconds() + + +def percentile(values: Iterable[float], fraction: float) -> float | None: + ordered = sorted(values) + if not ordered: + return None + index = max(0, math.ceil(fraction * len(ordered)) - 1) + return ordered[index] + + +def summarize_metrics(samples: list[dict[str, Any]]) -> dict[str, Any]: + stages = ( + "request_to_queue_seconds", + "queue_to_merge_seconds", + "merge_to_live_seconds", + "request_to_live_seconds", + ) + summary: dict[str, Any] = {"sample_count": len(samples), "samples": samples} + for stage in stages: + values = [ + float(sample[stage]) for sample in samples if sample.get(stage) is not None + ] + summary[stage] = { + "count": len(values), + "p50": percentile(values, 0.50), + "p95": percentile(values, 0.95), + "max": max(values) if values else None, + } + return summary + + +def follow_release( + client: Any, + *, + timeout_seconds: int, + poll_seconds: int, +) -> dict[str, Any]: + deadline = time.monotonic() + timeout_seconds + observed_sha = "" + while True: + main_sha = client.base_sha() + runs = client.workflow_runs() + value = release_state( + main_sha=main_sha, runs=runs, config=client.config.pipeline + ) + observed_sha = main_sha + if value["state"] in {"ci-failed", "deploy-failed"}: + return {**value, "verifications": []} + if value["state"] == "verified": + checks = http_verifications(client.config.pipeline) + if all(item["passed"] for item in checks): + return {**value, "verifications": checks} + if time.monotonic() >= deadline: + return { + **value, + "state": "timed-out", + "observed_sha": observed_sha, + "verifications": [], + } + time.sleep(poll_seconds) diff --git a/src/agent_merge_queue/records.py b/src/agent_merge_queue/records.py new file mode 100644 index 0000000..f17bd20 --- /dev/null +++ b/src/agent_merge_queue/records.py @@ -0,0 +1,248 @@ +"""Strict GitHub-comment records used by DeployBot's delivery controller.""" + +from __future__ import annotations + +import json +import re +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from typing import Any, Iterable + + +THREAD_PREFIX = "deploybot-thread:v1" +INTENT_PREFIX = "deploybot-intent:v1" +REPAIR_PREFIX = "deploybot-repair:v1" +CONTROL_PREFIX = "deploybot-control:v1" +INTEGRATION_PREFIX = "deploybot-integration:v1" + +THREAD_PHASES = { + "working", + "pr-draft", + "pr-review", + "ready", + "deploy-requested", + "queued", + "merged", + "blocked", + "completed", + "abandoned", +} + + +def utc_now() -> str: + return ( + datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + +def parse_time(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def _marker(prefix: str, prose: str) -> re.Pattern[str]: + return re.compile( + rf"\A\n" + rf"{re.escape(prose)}\s*\Z", + re.DOTALL, + ) + + +THREAD_MARKER = _marker(THREAD_PREFIX, "Recorded DeployBot thread metadata.") +INTENT_MARKER = _marker(INTENT_PREFIX, "Recorded DeployBot deploy intent.") +REPAIR_MARKER = _marker(REPAIR_PREFIX, "Recorded DeployBot repair handoff.") +CONTROL_MARKER = _marker(CONTROL_PREFIX, "Recorded DeployBot pipeline control.") +INTEGRATION_MARKER = _marker( + INTEGRATION_PREFIX, "Recorded DeployBot integration pull request." +) + + +def marker_body(prefix: str, payload: dict[str, Any], prose: str) -> str: + return f"\n{prose}" + + +def _payload(body: str, pattern: re.Pattern[str]) -> dict[str, Any] | None: + match = pattern.search(body) + if not match: + return None + try: + value = json.loads(match.group(1)) + except json.JSONDecodeError: + return None + return value if isinstance(value, dict) and value.get("schema") == 1 else None + + +def comment_login(comment: dict[str, Any]) -> str: + return str((comment.get("user") or {}).get("login") or "").lower() + + +def _comment_key(comment: dict[str, Any], index: int) -> tuple[str, int, int]: + try: + comment_id = int(comment.get("id") or 0) + except (TypeError, ValueError): + comment_id = 0 + return str(comment.get("created_at") or ""), comment_id, index + + +def latest_payload( + comments: Iterable[dict[str, Any]], + pattern: re.Pattern[str], + trusted_logins: Iterable[str], +) -> dict[str, Any] | None: + trusted = {value.lower() for value in trusted_logins} + found: list[tuple[tuple[str, int, int], dict[str, Any]]] = [] + for index, comment in enumerate(comments): + if comment_login(comment) not in trusted: + continue + value = _payload(str(comment.get("body") or ""), pattern) + if value is not None: + found.append((_comment_key(comment, index), value)) + return max(found, key=lambda item: item[0])[1] if found else None + + +@dataclass(frozen=True) +class ThreadRecord: + provider: str + thread_id: str + phase: str + updated_at: str + title: str | None = None + branch: str | None = None + pull_request: int | None = None + url: str | None = None + + def as_dict(self) -> dict[str, Any]: + return asdict(self) + + +def thread_record_body(record: ThreadRecord) -> str: + if record.phase not in THREAD_PHASES: + raise ValueError(f"unsupported thread phase: {record.phase}") + if not record.provider.strip() or not record.thread_id.strip(): + raise ValueError("provider and thread_id are required") + payload = {"schema": 1, **record.as_dict()} + return marker_body(THREAD_PREFIX, payload, "Recorded DeployBot thread metadata.") + + +def latest_thread_records( + comments: Iterable[dict[str, Any]], + trusted_logins: Iterable[str], + *, + active_hours: int, + now: datetime | None = None, +) -> list[ThreadRecord]: + trusted = {value.lower() for value in trusted_logins} + latest: dict[tuple[str, str], tuple[tuple[str, int, int], ThreadRecord]] = {} + current = now or datetime.now(timezone.utc) + for index, comment in enumerate(comments): + if comment_login(comment) not in trusted: + continue + value = _payload(str(comment.get("body") or ""), THREAD_MARKER) + if value is None or value.get("phase") not in THREAD_PHASES: + continue + try: + record = ThreadRecord( + provider=str(value["provider"]), + thread_id=str(value["thread_id"]), + phase=str(value["phase"]), + updated_at=str(value["updated_at"]), + title=str(value["title"]) if value.get("title") else None, + branch=str(value["branch"]) if value.get("branch") else None, + pull_request=( + int(value["pull_request"]) + if value.get("pull_request") is not None + else None + ), + url=str(value["url"]) if value.get("url") else None, + ) + except (KeyError, TypeError, ValueError): + continue + timestamp = parse_time(record.updated_at) + if timestamp is None: + continue + key = (record.provider.lower(), record.thread_id) + candidate = (_comment_key(comment, index), record) + if key not in latest or candidate[0] > latest[key][0]: + latest[key] = candidate + terminal = {"completed", "abandoned"} + result = [] + for _, record in latest.values(): + timestamp = parse_time(record.updated_at) + age_hours = ( + (current - timestamp).total_seconds() / 3600 if timestamp else 999999 + ) + if record.phase not in terminal and age_hours <= active_hours: + result.append(record) + return sorted( + result, key=lambda value: (value.updated_at, value.provider), reverse=True + ) + + +def intent_body( + *, + intent_id: str, + state: str, + requested_at: str, + requested_head: str, + provider: str | None = None, + thread_id: str | None = None, + thread_url: str | None = None, + parent_intent_id: str | None = None, +) -> str: + if state not in {"requested", "cancelled"}: + raise ValueError(f"unsupported intent state: {state}") + payload: dict[str, Any] = { + "intent_id": intent_id, + "requested_at": requested_at, + "requested_head": requested_head, + "schema": 1, + "state": state, + } + for key, value in ( + ("provider", provider), + ("thread_id", thread_id), + ("thread_url", thread_url), + ("parent_intent_id", parent_intent_id), + ): + if value: + payload[key] = value + return marker_body(INTENT_PREFIX, payload, "Recorded DeployBot deploy intent.") + + +def latest_intent( + comments: Iterable[dict[str, Any]], trusted_logins: Iterable[str] +) -> dict[str, Any] | None: + value = latest_payload(comments, INTENT_MARKER, trusted_logins) + if value is None or value.get("state") not in {"requested", "cancelled"}: + return None + return value + + +def repair_body(payload: dict[str, Any]) -> str: + value = {"schema": 1, **payload} + return marker_body(REPAIR_PREFIX, value, "Recorded DeployBot repair handoff.") + + +def control_body(*, state: str, reason: str | None = None) -> str: + if state not in {"running", "paused"}: + raise ValueError(f"unsupported pipeline control state: {state}") + payload = {"recorded_at": utc_now(), "schema": 1, "state": state} + if reason: + payload["reason"] = reason + return marker_body(CONTROL_PREFIX, payload, "Recorded DeployBot pipeline control.") + + +def integration_body(payload: dict[str, Any]) -> str: + value = {"schema": 1, **payload} + return marker_body( + INTEGRATION_PREFIX, + value, + "Recorded DeployBot integration pull request.", + ) diff --git a/src/agent_merge_queue/reviews.py b/src/agent_merge_queue/reviews.py index 84efde2..b752a4c 100644 --- a/src/agent_merge_queue/reviews.py +++ b/src/agent_merge_queue/reviews.py @@ -33,9 +33,7 @@ def _check_verdict( if status == "passed": return ReviewVerdict(policy.name, "passed") if status == "failed": - return ReviewVerdict( - policy.name, "blocked", (f"{policy.check_name} failed",) - ) + return ReviewVerdict(policy.name, "blocked", (f"{policy.check_name} failed",)) return ReviewVerdict( policy.name, "waiting", (f"{policy.check_name} is not complete",) ) @@ -50,18 +48,14 @@ def _approval_verdict( for index, review in enumerate(reviews): if str(review.get("commit_id") or "").lower() != head_sha.lower(): continue - login = normalize_login( - str((review.get("user") or {}).get("login") or "") - ) + login = normalize_login(str((review.get("user") or {}).get("login") or "")) if not login: continue timestamp = str(review.get("submitted_at") or "") candidate = (timestamp, index, str(review.get("state") or "").upper()) if login not in latest or candidate[:2] > latest[login][:2]: latest[login] = candidate - approved = { - login for login, value in latest.items() if value[2] == "APPROVED" - } + approved = {login for login, value in latest.items() if value[2] == "APPROVED"} allowed = {normalize_login(value) for value in policy.allowed_reviewers} approved.intersection_update(allowed) if len(approved) >= policy.minimum_approvals: @@ -69,9 +63,7 @@ def _approval_verdict( return ReviewVerdict( policy.name, "waiting", - ( - f"{len(approved)}/{policy.minimum_approvals} exact-head approvals complete", - ), + (f"{len(approved)}/{policy.minimum_approvals} exact-head approvals complete",), ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2a06721..f4037d5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,9 +19,17 @@ command_dequeue, command_drain, command_enqueue, + command_integrate, + command_merge, + command_promote, + command_react, + command_refresh_request, + command_request, + command_resume, command_unblock, completed_batch_ids, entries_in_batch, + effective_queue_marker, generated_only_change, latest_batch_marker, latest_marker, @@ -35,6 +43,7 @@ structured_dependencies, ) from agent_merge_queue.config import parse_config +from agent_merge_queue.records import integration_body, intent_body from agent_merge_queue.reviews import ReviewVerdict @@ -75,18 +84,18 @@ def entry(number: int, *paths: str, state: str = "ready") -> QueueEntry: class QueueCoreTest(unittest.TestCase): - def test_status_is_a_read_only_plan_alias(self) -> None: - queued = [entry(1, "src/app.py")] + def test_status_is_a_read_only_pipeline_view(self) -> None: + status = {"repository": "example/repo"} with ( patch("agent_merge_queue.cli.load_config", return_value=CONFIG), - patch("agent_merge_queue.cli.GitHub") as github, - patch("agent_merge_queue.cli.print_plan") as print_plan, + patch("agent_merge_queue.cli.GitHub"), + patch("agent_merge_queue.cli.pipeline_status", return_value=status), + patch("agent_merge_queue.cli.print_pipeline_status") as print_status, ): - github.return_value.queue.return_value = queued result = main(["status", "--json"]) self.assertEqual(result, 0) - print_plan.assert_called_once_with(queued, json_output=True) + print_status.assert_called_once_with(status, json_output=True) def test_dependency_directive_is_configurable(self) -> None: body = "Queue-after: #12, #14\nBlocked by #99" @@ -190,16 +199,16 @@ def test_undated_queued_rerun_hides_older_success(self) -> None: def test_review_verdicts_are_classified_generically(self) -> None: value = entry(1) - value.review_verdicts = ( - ReviewVerdict("Any bot", "blocked", ("one finding",)), - ) + value.review_verdicts = (ReviewVerdict("Any bot", "blocked", ("one finding",)),) value.classify(CONFIG) self.assertEqual(value.state, "blocked") self.assertIn("one finding", value.reasons) - def test_github_blocked_state_fails_closed_but_mergeable_states_do_not(self) -> None: + def test_github_blocked_state_fails_closed_but_mergeable_states_do_not( + self, + ) -> None: blocked = entry(1) blocked.merge_state = "BLOCKED" blocked.classify(CONFIG) @@ -274,7 +283,9 @@ def test_queue_marker_rejects_forgery_and_free_text_injection(self) -> None: } self.assertIsNone(latest_marker([forged, injected], "trusted")) - self.assertEqual(latest_marker([forged, injected, valid], "trusted")["head_sha"], sha) + self.assertEqual( + latest_marker([forged, injected, valid], "trusted")["head_sha"], sha + ) collaborator = { "created_at": "2026-06-20T00:04:00Z", @@ -337,9 +348,7 @@ def test_legacy_astro_markers_remain_readable(self) -> None: {**queue, "state": "queued"}, ) self.assertEqual( - latest_batch_marker( - [batch_comment], "trusted", batch_id=batch["batch_id"] - ), + latest_batch_marker([batch_comment], "trusted", batch_id=batch["batch_id"]), batch, ) @@ -379,13 +388,14 @@ def test_active_batch_excludes_late_arrivals(self) -> None: late = entry(3, "c.py") batch = new_batch([first, second], frozen_at="2026-06-20T00:01:00Z") - selected = active_batch( - [first, second, late], {1: batch, 2: batch, 3: None} - ) + selected = active_batch([first, second, late], {1: batch, 2: batch, 3: None}) self.assertEqual(selected["pull_requests"], [1, 2]) self.assertEqual( - [value.number for value in entries_in_batch([first, second, late], selected)], + [ + value.number + for value in entries_in_batch([first, second, late], selected) + ], [1, 2], ) @@ -442,14 +452,14 @@ def test_drain_merges_independent_ready_entries_and_skips_overlap(self) -> None: queue=[first, overlap_left, overlap_right, waiting], blocked_queue=[], next_batch=[], - overlap_groups=[ - {"pull_requests": [2, 3], "source_paths": ["shared.py"]} - ], + overlap_groups=[{"pull_requests": [2, 3], "source_paths": ["shared.py"]}], ) client = Mock() with ( patch("agent_merge_queue.cli.freeze_queue", return_value=frozen), - patch("agent_merge_queue.cli.command_merge", return_value="m" * 40) as merge, + patch( + "agent_merge_queue.cli.command_merge", return_value="m" * 40 + ) as merge, redirect_stdout(io.StringIO()), ): result = command_drain(client, json_output=True) @@ -507,10 +517,10 @@ def test_drain_completes_waiting_pass_then_promotes_next_batch(self) -> None: ) client = Mock() with ( + patch("agent_merge_queue.cli.freeze_queue", side_effect=[first, second]), patch( - "agent_merge_queue.cli.freeze_queue", side_effect=[first, second] - ), - patch("agent_merge_queue.cli.command_merge", return_value="m" * 40) as merge, + "agent_merge_queue.cli.command_merge", return_value="m" * 40 + ) as merge, redirect_stdout(io.StringIO()), ): result = command_drain(client, json_output=True) @@ -530,11 +540,13 @@ def test_reenqueue_toggles_label_to_wake_event_coordinator(self) -> None: "user": {"login": "owner"}, "body": ( "\n" + f"Queued for the agent-managed merge queue on `{old_head}`." ), @@ -552,7 +564,9 @@ def test_reenqueue_toggles_label_to_wake_event_coordinator(self) -> None: client.remove_label.assert_called_once_with(1, "merge-queue") client.add_label.assert_called_once_with(1, "merge-queue") - def test_reenabling_blocked_pr_toggles_queue_label_to_wake_coordinator(self) -> None: + def test_reenabling_blocked_pr_toggles_queue_label_to_wake_coordinator( + self, + ) -> None: value = entry(1) value.labels.append("merge-queue-blocked") marker = { @@ -654,16 +668,19 @@ def test_block_and_dequeue_write_durable_state_before_labels_change(self) -> Non client.snapshot.return_value = value client.comments.return_value = [queued] client.trusted_logins = {"trusted"} + client.coordinator_logins = {"trusted"} + client.repository = "example/repo" + client.base_sha.return_value = "b" * 40 client.labels.return_value = {"merge-queue"} with redirect_stdout(io.StringIO()): command_block(client, "1", "investigate") - block_body = client.comment.call_args.args[1] + block_body = client.comment.call_args_list[0].args[1] self.assertIn('"state": "blocked"', block_body) client.add_label.assert_called_once_with(1, "merge-queue-blocked") names = [call[0] for call in client.method_calls] - self.assertLess(names.index("add_label"), names.index("comment")) + self.assertLess(names.index("comment"), names.index("add_label")) client.reset_mock() client.config = CONFIG @@ -671,6 +688,7 @@ def test_block_and_dequeue_write_durable_state_before_labels_change(self) -> Non client.snapshot.return_value = value client.comments.return_value = [queued] client.trusted_logins = {"trusted"} + client.coordinator_logins = {"trusted"} client.labels.return_value = {"merge-queue"} with redirect_stdout(io.StringIO()): command_dequeue(client, "1", "superseded") @@ -716,6 +734,376 @@ def test_final_merge_rechecks_durable_authorization(self) -> None: client.merge(1, value.head_sha) self.assertEqual(client._json.call_count, 1) + def test_final_integration_merge_requires_current_exact_head_intent(self) -> None: + integration_head = "f" * 40 + source_head = "a" * 40 + client = object.__new__(GitHub) + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client._json = Mock( + side_effect=[ + { + "headRefOid": integration_head, + "isDraft": False, + "labels": [{"name": "merge-queue"}], + "state": "OPEN", + }, + {"headRefOid": source_head}, + ] + ) + integration_comments = [ + { + "id": 1, + "created_at": "2026-06-20T00:00:00Z", + "user": {"login": "coordinator"}, + "body": integration_body( + { + "batch_id": "batch-1", + "heads": {"1": source_head}, + "pull_requests": [1], + } + ), + }, + { + "id": 2, + "created_at": "2026-06-20T00:00:01Z", + "user": {"login": "coordinator"}, + "body": queue_state_body( + "queued", + integration_head, + queued_at="2026-06-20T00:00:01Z", + integration_batch_id="batch-1", + ), + }, + ] + stale_source_intent = { + "id": 3, + "created_at": "2026-06-20T00:00:02Z", + "user": {"login": "trusted"}, + "body": intent_body( + intent_id="intent-2", + state="requested", + requested_at="2026-06-20T00:00:02Z", + requested_head="b" * 40, + ), + } + client.comments = Mock( + side_effect=lambda number: ( + integration_comments if number == 99 else [stale_source_intent] + ) + ) + + with self.assertRaisesRegex(QueueError, "authorization was revoked"): + client.merge(99, integration_head) + + def test_coordinator_queue_marker_requires_trusted_deploy_intent(self) -> None: + sha = "a" * 40 + 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=sha, + ), + } + delegated = { + "id": 2, + "created_at": "2026-06-20T00:01:00Z", + "user": {"login": "coordinator"}, + "body": queue_state_body( + "queued", + sha, + queued_at="2026-06-20T00:01:00Z", + intent_id="intent-1", + ), + } + marker = effective_queue_marker( + [intent, delegated], {"trusted"}, {"coordinator"} + ) + self.assertEqual(marker["head_sha"], sha) + self.assertIsNone( + effective_queue_marker([delegated], {"trusted"}, {"coordinator"}) + ) + stale_intent = dict(intent) + stale_intent["body"] = intent_body( + intent_id="intent-1", + state="requested", + requested_at="2026-06-20T00:00:00Z", + requested_head="b" * 40, + ) + self.assertIsNone( + effective_queue_marker( + [stale_intent, delegated], {"trusted"}, {"coordinator"} + ) + ) + + def test_request_records_intent_before_gates_and_adds_intent_label(self) -> None: + value = entry(1, state="waiting") + value.labels = [] + value.reasons = ["CI is not complete"] + client = Mock() + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.resolve_pr.return_value = 1 + client.require_actor.return_value = "trusted" + client.snapshot.return_value = value + with ( + patch("agent_merge_queue.cli.utc_now", return_value="2026-06-20T00:00:00Z"), + redirect_stdout(io.StringIO()), + ): + result = command_request( + client, + "1", + provider="codex", + thread_id="thread-1", + thread_url=None, + ) + self.assertEqual(result["state"], "deploy-requested") + self.assertIn("deploybot-intent:v1", client.comment.call_args.args[1]) + client.add_label.assert_called_with(1, "deploy-requested") + client.record_thread.assert_called_once() + + def test_promote_queues_ready_intent_but_leaves_waiting_visible(self) -> None: + ready = entry(1) + ready.labels = ["deploy-requested"] + waiting = entry(2, state="waiting") + waiting.labels = ["deploy-requested"] + waiting.reasons = ["CI is not complete"] + intent = intent_body( + intent_id="intent-1", + state="requested", + requested_at="2026-06-20T00:00:00Z", + requested_head=ready.head_sha, + ) + intent_comment = { + "id": 1, + "created_at": "2026-06-20T00:00:00Z", + "user": {"login": "trusted"}, + "body": intent, + } + client = Mock() + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.intent_numbers.return_value = [1, 2] + client.active_integration_sources.return_value = set() + client.comments.return_value = [intent_comment] + client.snapshot.side_effect = [ready, waiting] + client.labels.return_value = {"deploy-requested"} + with redirect_stdout(io.StringIO()): + result = command_promote(client) + self.assertEqual(result["promoted"], [1]) + self.assertEqual(result["waiting"][0]["number"], 2) + self.assertIn("intent-1", client.comment.call_args.args[1]) + client.add_label.assert_called_with(1, "merge-queue") + + def test_resume_atomically_requeues_repaired_exact_head(self) -> None: + value = entry(1) + value.labels = ["deploy-requested", "merge-queue-blocked"] + intent_comment = { + "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=value.head_sha, + ), + } + client = Mock() + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.resolve_pr.return_value = 1 + client.comments.return_value = [intent_comment] + client.snapshot.return_value = value + client.labels.return_value = set(value.labels) + with redirect_stdout(io.StringIO()): + command_resume(client, "1") + client.add_label.assert_called_with(1, "merge-queue") + client.remove_label.assert_called_with(1, "merge-queue-blocked") + + def test_promote_does_not_duplicate_sources_under_active_integration(self) -> None: + client = Mock() + client.config = CONFIG + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.intent_numbers.return_value = [1] + client.active_integration_sources.return_value = {1} + with redirect_stdout(io.StringIO()): + result = command_promote(client) + self.assertEqual(result["promoted"], []) + self.assertIn("integration PR", result["waiting"][0]["reasons"][0]) + client.snapshot.assert_not_called() + + def test_refresh_request_requires_ready_head_and_chains_intent(self) -> None: + value = entry(1) + previous_body = intent_body( + intent_id="old-intent", + state="requested", + requested_at="2026-06-20T00:00:00Z", + requested_head="a" * 40, + provider="codex", + thread_id="thread-1", + ) + previous_comment = { + "id": 1, + "created_at": "2026-06-20T00:00:00Z", + "user": {"login": "trusted"}, + "body": previous_body, + } + refreshed_comments: list[dict] = [previous_comment] + client = Mock() + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.resolve_pr.return_value = 1 + client.require_actor.return_value = "trusted" + client.snapshot.return_value = value + client.labels.return_value = {"deploy-requested"} + + def comments(_: int): + return list(refreshed_comments) + + def add_comment(_: int, body: str): + refreshed_comments.append( + { + "id": len(refreshed_comments) + 1, + "created_at": "2026-06-20T01:00:00Z", + "user": {"login": "trusted"}, + "body": body, + } + ) + + client.comments.side_effect = comments + client.comment.side_effect = add_comment + with ( + patch("agent_merge_queue.cli.utc_now", return_value="2026-06-20T01:00:00Z"), + redirect_stdout(io.StringIO()), + ): + result = command_refresh_request(client, "1") + self.assertEqual(result["parent_intent_id"], "old-intent") + self.assertEqual(result["head_sha"], value.head_sha) + self.assertTrue( + any( + '"parent_intent_id": "old-intent"' in item["body"] + for item in refreshed_comments + ) + ) + + def test_integration_pr_contains_every_frozen_head(self) -> None: + first = entry(1, "shared.py") + second = entry(2, "shared.py") + batch = new_batch([first, second], frozen_at="2026-06-20T00:00:00Z") + client = object.__new__(GitHub) + client.config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "integration": {"mode": "all"}, + } + ) + client.repository = "example/repo" + client.owner = "example" + client.name = "repo" + client.base_sha = Mock(return_value="b" * 40) + client._json = Mock( + side_effect=[ + {}, + {"sha": "1" * 40}, + {"sha": "2" * 40}, + [], + {"number": 99, "html_url": "https://example.test/99"}, + ] + ) + client.comment = Mock() + client.labels = Mock(return_value={"merge-queue", "deploy-requested"}) + client.remove_label = Mock() + result = client.create_integration_pull_request( + batch=batch, entries=[first, second] + ) + self.assertEqual(result["number"], 99) + marker = client.comment.call_args.args[1] + self.assertIn(first.head_sha, marker) + self.assertIn(second.head_sha, marker) + merge_calls = [ + call + for call in client._json.call_args_list + if "repos/example/repo/merges" in call.args + ] + self.assertEqual(len(merge_calls), 2) + self.assertEqual( + [call.args for call in client.remove_label.call_args_list], + [ + (1, "merge-queue"), + (1, "deploy-requested"), + (2, "merge-queue"), + (2, "deploy-requested"), + ], + ) + + def test_reactor_uses_cumulative_pr_in_all_mode(self) -> None: + config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + "coordinator_actors": ["coordinator"], + }, + "integration": {"mode": "all"}, + } + ) + first = entry(1, "a.py") + second = entry(2, "b.py") + batch = new_batch([first, second], frozen_at="2026-06-20T00:00:00Z") + frozen = FreezeResult(batch, [first, second], [], [], []) + client = Mock() + client.config = config + client.coordinator_logins = {"coordinator"} + client.pipeline_control.return_value = {"state": "running"} + client.create_integration_pull_request.return_value = {"number": 99} + with ( + patch("agent_merge_queue.cli.promote_integrations", return_value=[]), + patch("agent_merge_queue.cli.command_promote", return_value={}), + patch("agent_merge_queue.cli.freeze_queue", return_value=frozen), + patch("agent_merge_queue.cli.command_drain") as drain, + redirect_stdout(io.StringIO()), + ): + result = command_react(client, follow=False, timeout_seconds=10) + drain.assert_not_called() + self.assertEqual(result["integrations"], [{"number": 99}]) + + def test_pipeline_pause_blocks_every_merge_path(self) -> None: + client = Mock() + client.pipeline_control.return_value = { + "state": "paused", + "reason": "main CI failed", + } + commands = ( + lambda: command_merge(client, "1", "batch-1"), + lambda: command_drain(client, json_output=True), + lambda: command_integrate(client, all_entries=True), + ) + for command in commands: + with ( + self.subTest(command=command), + self.assertRaisesRegex(QueueError, "main CI failed"), + ): + command() + client.resolve_pr.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py index 85db22e..ffab40b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -68,9 +68,7 @@ def test_rejects_bot_without_login(self) -> None: "required_checks": ["CI"], "trusted_actors": ["trusted"], }, - "review": { - "providers": [{"kind": "bot", "name": "Broken"}] - }, + "review": {"providers": [{"kind": "bot", "name": "Broken"}]}, } ) @@ -83,9 +81,7 @@ def test_approval_provider_requires_explicit_reviewers(self) -> None: "trusted_actors": ["trusted"], }, "review": { - "providers": [ - {"kind": "github-approvals", "name": "Humans"} - ] + "providers": [{"kind": "github-approvals", "name": "Humans"}] }, } ) @@ -141,9 +137,7 @@ def test_bot_provider_requires_positive_evidence(self) -> None: "trusted_actors": ["trusted"], }, "review": { - "providers": [ - {"kind": "bot", "name": "Bot", "login": "bot"} - ] + "providers": [{"kind": "bot", "name": "Bot", "login": "bot"}] }, } ) @@ -179,6 +173,65 @@ def test_shared_actions_identity_cannot_authorize_pull_requests(self) -> None: } ) + def test_parses_pipeline_integration_and_health_policy(self) -> None: + config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "pipeline": { + "ci_workflows": ["Main CI"], + "deploy_workflows": ["Production"], + "auto_promote": False, + "verifications": [ + { + "name": "Login boundary", + "url": "https://example.test/login", + "expected_status": 200, + } + ], + }, + "integration": {"mode": "all"}, + } + ) + self.assertEqual(config.pipeline.ci_workflows, ("Main CI",)) + self.assertFalse(config.pipeline.auto_promote) + self.assertEqual(config.pipeline.verifications[0].expected_status, 200) + self.assertEqual(config.integration.mode, "all") + + def test_rejects_invalid_integration_mode_and_boolean(self) -> None: + with self.assertRaisesRegex(ConfigError, "integration.mode"): + parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "integration": {"mode": "magic"}, + } + ) + with self.assertRaisesRegex(ConfigError, "auto_promote"): + parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "pipeline": {"auto_promote": "yes"}, + } + ) + with self.assertRaisesRegex(ConfigError, "intent_scope"): + parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "pipeline": {"intent_scope": "pull-request"}, + } + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..f9b1238 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from agent_merge_queue.doctor import diagnose + + +class DoctorTest(unittest.TestCase): + def test_missing_github_cli_is_one_clean_failure(self) -> None: + with patch("agent_merge_queue.doctor.shutil.which", return_value=None): + rows = diagnose(config_path=None, repository=None) + self.assertEqual(rows[0]["status"], "fail") + self.assertIn("not found", rows[0]["detail"]) + + def test_bad_config_is_reported_without_traceback(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / ".mergequeue.toml").write_text("not = [valid", encoding="utf-8") + with ( + patch( + "agent_merge_queue.doctor.shutil.which", return_value="/usr/bin/gh" + ), + patch( + "agent_merge_queue.doctor._gh", return_value=(0, "authenticated") + ), + patch( + "agent_merge_queue.doctor._json", + return_value=(0, {"nameWithOwner": "owner/repo"}, ""), + ), + ): + rows = diagnose(config_path=None, repository="owner/repo", cwd=root) + config = next(value for value in rows if value["check"] == "configuration") + self.assertEqual(config["status"], "fail") + self.assertIn("invalid merge queue config", config["detail"]) + + def test_missing_labels_and_check_name_are_advisory(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + (root / ".mergequeue.toml").write_text( + """ +[queue] +required_checks = ["Expected CI"] +trusted_actors = ["trusted"] +""", + encoding="utf-8", + ) + + def fake_json(*arguments: str, cwd: Path): + joined = " ".join(arguments) + if "label list" in joined: + return 0, [], "" + if "workflow list" in joined: + return 0, [], "" + if "pr list" in joined: + return 0, [{"statusCheckRollup": [{"name": "Different CI"}]}], "" + if "users/trusted" in joined: + return 0, {"login": "trusted"}, "" + if "protection" in joined: + return 1, None, "not available" + return 0, {"nameWithOwner": "owner/repo"}, "" + + with ( + patch( + "agent_merge_queue.doctor.shutil.which", return_value="/usr/bin/gh" + ), + patch( + "agent_merge_queue.doctor._gh", return_value=(0, "authenticated") + ), + patch("agent_merge_queue.doctor._json", side_effect=fake_json), + ): + rows = diagnose(config_path=None, repository="owner/repo", cwd=root) + labels = next(value for value in rows if value["check"] == "labels") + checks = next(value for value in rows if value["check"] == "required-checks") + self.assertEqual(labels["status"], "warn") + self.assertEqual(checks["status"], "warn") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..8d699ee --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import unittest +from subprocess import CompletedProcess +from unittest.mock import patch + +from agent_merge_queue.mcp_server import _run + + +class McpTest(unittest.TestCase): + def test_diagnose_can_return_failed_report_payload(self) -> None: + completed = CompletedProcess( + ["deploybot"], + 1, + stdout='[{"check":"auth","status":"fail"}]\n', + stderr="", + ) + with patch( + "agent_merge_queue.mcp_server.subprocess.run", return_value=completed + ): + value = _run("doctor", "--json", allow_nonzero=True) + self.assertIn('"status":"fail"', value) + + def test_other_mcp_commands_still_fail_closed(self) -> None: + completed = CompletedProcess(["deploybot"], 1, stdout="", stderr="unsafe") + with ( + patch( + "agent_merge_queue.mcp_server.subprocess.run", return_value=completed + ), + self.assertRaisesRegex(RuntimeError, "unsafe"), + ): + _run("drain", "--json") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..87adef1 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import unittest +from unittest.mock import Mock, patch + +from agent_merge_queue.config import parse_config +from agent_merge_queue.pipeline import ( + follow_release, + notify, + percentile, + release_state, + summarize_metrics, +) + + +CONFIG = parse_config( + {"queue": {"required_checks": ["CI"], "trusted_actors": ["trusted"]}} +) + + +class PipelineTest(unittest.TestCase): + def test_release_state_requires_exact_main_ci_then_deploy(self) -> None: + sha = "a" * 40 + runs = [ + { + "id": 1, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "created_at": "2026-06-20T00:00:00Z", + }, + { + "id": 2, + "name": "Deploy", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "created_at": "2026-06-20T00:01:00Z", + }, + ] + self.assertEqual( + release_state(main_sha=sha, runs=runs, config=CONFIG.pipeline)["state"], + "verified", + ) + self.assertEqual( + release_state(main_sha="b" * 40, runs=runs, config=CONFIG.pipeline)[ + "state" + ], + "testing", + ) + + def test_follow_switches_to_newer_cumulative_main(self) -> None: + old = "a" * 40 + new = "b" * 40 + client = Mock() + client.config = CONFIG + client.base_sha.side_effect = [old, new] + client.workflow_runs.side_effect = [ + [], + [ + { + "id": 1, + "name": "CI", + "head_sha": new, + "status": "completed", + "conclusion": "success", + }, + { + "id": 2, + "name": "Deploy", + "head_sha": new, + "status": "completed", + "conclusion": "success", + }, + ], + ] + with ( + patch("agent_merge_queue.pipeline.time.sleep"), + patch("agent_merge_queue.pipeline.time.monotonic", side_effect=[0, 1, 2]), + ): + result = follow_release(client, timeout_seconds=10, poll_seconds=1) + self.assertEqual(result["state"], "verified") + self.assertEqual(result["main_sha"], new) + + def test_metrics_report_p50_and_p95(self) -> None: + self.assertEqual(percentile([1, 2, 3, 100], 0.50), 2) + self.assertEqual(percentile([1, 2, 3, 100], 0.95), 100) + summary = summarize_metrics( + [ + {"request_to_queue_seconds": 1}, + {"request_to_queue_seconds": 3}, + ] + ) + self.assertEqual(summary["request_to_queue_seconds"]["p50"], 1) + self.assertEqual(summary["request_to_queue_seconds"]["p95"], 3) + + def test_webhook_failure_never_blocks_delivery_state(self) -> None: + config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "pipeline": {"webhook_url_env": "DEPLOYBOT_TEST_WEBHOOK"}, + } + ) + with ( + patch.dict( + "os.environ", + {"DEPLOYBOT_TEST_WEBHOOK": "https://example.invalid/hook"}, + ), + patch("agent_merge_queue.pipeline.urlopen", side_effect=OSError("offline")), + ): + self.assertFalse(notify(config.pipeline, "queued", {"pull_request": 1})) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_records.py b/tests/test_records.py new file mode 100644 index 0000000..e8afdd4 --- /dev/null +++ b/tests/test_records.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timezone + +from agent_merge_queue.records import ( + ThreadRecord, + intent_body, + latest_intent, + latest_thread_records, + thread_record_body, +) + + +def comment(login: str, body: str, created_at: str, comment_id: int = 1) -> dict: + return { + "id": comment_id, + "created_at": created_at, + "user": {"login": login}, + "body": body, + } + + +class RecordTest(unittest.TestCase): + def test_thread_registry_keeps_latest_active_metadata_only(self) -> None: + old = ThreadRecord( + provider="codex", + thread_id="thread-1", + phase="working", + updated_at="2026-06-20T00:00:00Z", + title="Safe title", + ) + latest = ThreadRecord( + provider="codex", + thread_id="thread-1", + phase="deploy-requested", + updated_at="2026-06-20T01:00:00Z", + pull_request=42, + ) + forged = ThreadRecord( + provider="codex", + thread_id="thread-1", + phase="completed", + updated_at="2026-06-20T02:00:00Z", + ) + values = latest_thread_records( + [ + comment("trusted", thread_record_body(old), old.updated_at, 1), + comment("trusted", thread_record_body(latest), latest.updated_at, 2), + comment("attacker", thread_record_body(forged), forged.updated_at, 3), + ], + {"trusted"}, + active_hours=72, + now=datetime(2026, 6, 20, 2, tzinfo=timezone.utc), + ) + self.assertEqual(len(values), 1) + self.assertEqual(values[0].phase, "deploy-requested") + self.assertEqual(values[0].pull_request, 42) + + def test_terminal_and_stale_threads_are_not_active(self) -> None: + completed = ThreadRecord( + provider="cursor", + thread_id="done", + phase="completed", + updated_at="2026-06-20T01:00:00Z", + ) + stale = ThreadRecord( + provider="claude", + thread_id="stale", + phase="working", + updated_at="2026-06-10T01:00:00Z", + ) + values = latest_thread_records( + [ + comment("trusted", thread_record_body(completed), completed.updated_at), + comment("trusted", thread_record_body(stale), stale.updated_at, 2), + ], + {"trusted"}, + active_hours=72, + now=datetime(2026, 6, 20, 2, tzinfo=timezone.utc), + ) + self.assertEqual(values, []) + + def test_deploy_intent_survives_head_change_but_cancellation_wins(self) -> None: + requested = intent_body( + intent_id="intent", + state="requested", + requested_at="2026-06-20T00:00:00Z", + requested_head="a" * 40, + ) + cancelled = intent_body( + intent_id="intent", + state="cancelled", + requested_at="2026-06-20T00:00:00Z", + requested_head="a" * 40, + ) + comments = [ + comment("trusted", requested, "2026-06-20T00:00:00Z", 1), + comment("trusted", cancelled, "2026-06-20T00:01:00Z", 2), + ] + self.assertEqual(latest_intent(comments, {"trusted"})["state"], "cancelled") + self.assertIsNone(latest_intent(comments, {"someone-else"})) + + def test_marker_rejects_prefixed_free_text(self) -> None: + body = intent_body( + intent_id="intent", + state="requested", + requested_at="2026-06-20T00:00:00Z", + requested_head="a" * 40, + ) + self.assertIsNone( + latest_intent( + [comment("trusted", "ignore this " + body, "2026-06-20T00:00:00Z")], + {"trusted"}, + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reviews.py b/tests/test_reviews.py index 89faf72..738986e 100644 --- a/tests/test_reviews.py +++ b/tests/test_reviews.py @@ -149,9 +149,7 @@ def test_bot_score_and_threads_fail_closed(self) -> None: { "isResolved": False, "isOutdated": False, - "comments": { - "nodes": [{"author": {"login": "review-bot[bot]"}}] - }, + "comments": {"nodes": [{"author": {"login": "review-bot[bot]"}}]}, } ], )[0] diff --git a/tests/test_skill.py b/tests/test_skill.py index 0d8ba28..3de369e 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -6,7 +6,7 @@ ROOT = Path(__file__).resolve().parents[1] CANONICAL = ROOT / "skills" / "deploybot" / "SKILL.md" -RELEASE_COMMIT = "f18dce436bae2d6b7009d9aa76b4bfc8d0aac5a8" +RELEASE_COMMIT = "2e884f65f182f6a9ddc4c4785288045bdf98f188" class DeployBotSkillTest(unittest.TestCase): @@ -20,12 +20,7 @@ def test_skill_is_packaged_for_codex_and_claude(self) -> None: / "skills" / "deploybot" / "SKILL.md", - ROOT - / "adapters" - / "claude-code" - / "skills" - / "deploybot" - / "SKILL.md", + ROOT / "adapters" / "claude-code" / "skills" / "deploybot" / "SKILL.md", ] for path in copies: with self.subTest(path=path): @@ -34,20 +29,32 @@ def test_skill_is_packaged_for_codex_and_claude(self) -> None: def test_status_guidance_is_read_only(self) -> None: skill = CANONICAL.read_text(encoding="utf-8") self.assertIn("deploybot status --json", skill) + self.assertIn("pipeline_status", skill) + self.assertIn("request_deployment", skill) + self.assertIn("resume_pull_request", skill) + self.assertIn("follow_release", skill) + self.assertIn("Never publish prompts, transcripts", skill) self.assertIn("Never call `freeze_queue` merely to view status", skill) self.assertIn("exact `deploy` instruction", skill) def test_cursor_adapter_exposes_status_workflow(self) -> None: rule = ( - ROOT - / "adapters" - / "cursor" - / ".cursor" - / "rules" - / "deploybot.mdc" + ROOT / "adapters" / "cursor" / ".cursor" / "rules" / "deploybot.mdc" ).read_text(encoding="utf-8") self.assertIn("queue_plan", rule) - self.assertIn("never freeze a queue merely to inspect it", rule) + self.assertIn("pipeline_status", rule) + self.assertIn("request_deployment", rule) + self.assertIn("never freeze a", rule) + self.assertIn("queue merely to inspect it", rule) + + def test_github_workflow_wakes_after_named_ci_finishes(self) -> None: + workflow = (ROOT / "examples" / "github-workflow.yml").read_text( + encoding="utf-8" + ) + self.assertIn("workflow_run:", workflow) + self.assertIn("workflows: [CI]", workflow) + self.assertIn("github.event.repository.default_branch", workflow) + self.assertIn("persist-credentials: false", workflow) def test_clients_pin_the_immutable_status_release(self) -> None: paths = [