diff --git a/.mergequeue.example.toml b/.mergequeue.example.toml index bb34eeb..7d9b18a 100644 --- a/.mergequeue.example.toml +++ b/.mergequeue.example.toml @@ -45,6 +45,8 @@ thread_active_hours = 72 ci_workflows = ["CI"] deploy_workflows = ["Deploy"] repair_hold_minutes = 60 +hold_merges_while_releasing = true +repair_branch_prefix = "deploybot/repair" ready_to_merge_target_minutes = 15 merge_to_live_target_minutes = 10 auto_promote = true @@ -63,3 +65,5 @@ pause_on_failure = true mode = "manual" branch_prefix = "deploybot/integration" title_prefix = "DeployBot integration" +max_batch_size = 3 +require_non_actions_author = false diff --git a/README.md b/README.md index 4ebc540..e96769e 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ integration PRs, follows `main` through production, and pauses after failures. ## Install -Install the reviewed `v0.2.11` source commit directly from GitHub: +Install the reviewed `v0.2.12` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@bf1ccadb3a4110dd29b70efe4f621f822110bfcc' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@01c8c6e48c3a92155803cd4232b56b0c1d3363c2' deploybot init ``` @@ -95,7 +95,7 @@ worker can dispatch deployment when GitHub suppresses the `workflow_run` event for token-dispatched CI. Pin the Action to the full reviewed release commit: ```yaml -- uses: Forward-Future/DeployBot@bf1ccadb3a4110dd29b70efe4f621f822110bfcc +- uses: Forward-Future/DeployBot@01c8c6e48c3a92155803cd4232b56b0c1d3363c2 ``` The Action uses GitHub's built-in workflow token. GitHub intentionally does not @@ -111,6 +111,13 @@ from pull-request CI are ignored. Set Action input `dispatch_ci: "false"` only when a caller supplies a different merge identity that already triggers push CI. +When integration PR checks depend on normal PR-authored events, mint a GitHub +App installation token in an earlier workflow step and pass it as the Action's +`token` input. Set `integration.require_non_actions_author = true` so a missing +App token fails before it freezes or creates an unusable integration PR. Add +the App's exact bot login (for example, `deploybot-app[bot]`) to +`queue.coordinator_actors` so its integration records are trusted. + The deployment workflow keeps its normal `workflow_run` trigger for push CI and adds this exact-input recovery path for DeployBot-dispatched CI: @@ -158,7 +165,13 @@ ready-to-merge target and names the current gate. It never stores prompts, transcripts, source, or credentials. `deploybot react` promotes ready intent, skips blockers, drains independent -work, and creates integration PRs when configured. Draft status and incomplete +work, and creates integration PRs when configured. New batches contain at most +`integration.max_batch_size` entries; later FIFO work remains in the next batch. +A larger indivisible source-overlap or dependency closure is the sole exception: +it ships alone, never mixed with unrelated work. +After any merge, admission stays closed until the cumulative exact-main release +is verified live, preventing newer merges from starving an older deployment. +Draft status and incomplete checks or reviews remain waiting states; they do not create a repair latch. A conflict, failed gate, unresolved review, manual block, or stale authorized head produces a repair handoff containing the source thread, base/head SHAs, source @@ -178,6 +191,11 @@ 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`. +Before starting an exact-main recovery, an agent runs +`deploybot claim-release-repair --provider CLIENT --thread-id ID`. A +deterministic branch ref elects exactly one repair owner for that failed SHA; +other threads receive the recorded owner instead of creating duplicate PRs. + At merge time, DeployBot records a non-expiring notification obligation. At exact-main verification, it promotes every contained obligation to `pending`, moves the matching source thread to `deployed` when that thread has not moved @@ -212,6 +230,8 @@ deploy_workflows = ["Deploy"] batch_settle_seconds = 15 ci_failure_grace_seconds = 90 promotion_workers = 4 +hold_merges_while_releasing = true +repair_branch_prefix = "deploybot/repair" ready_to_merge_target_minutes = 15 merge_to_live_target_minutes = 10 auto_promote = true @@ -226,6 +246,8 @@ expected_status = 200 [integration] # manual, overlap, or all (one cumulative pre-merge validation PR) mode = "overlap" +max_batch_size = 3 +# require_non_actions_author = true ``` For `overlap` or `all` mode with the hosted coordinator, enable **Allow GitHub diff --git a/action.yml b/action.yml index 9574a17..70341cf 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,10 @@ inputs: description: Maximum release-follow time in seconds required: false default: "1800" + token: + description: GitHub App installation token used to author integration PRs + required: false + default: "" runs: using: composite steps: @@ -28,7 +32,7 @@ runs: - shell: bash env: DEPLOYBOT_CONFIG: ${{ inputs.config }} - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ inputs.token || github.token }} DEPLOYBOT_FOLLOW: ${{ inputs.follow }} DEPLOYBOT_DISPATCH_CI: ${{ inputs.dispatch_ci }} DEPLOYBOT_TIMEOUT: ${{ inputs.timeout }} diff --git a/adapters/claude-code/.claude-plugin/plugin.json b/adapters/claude-code/.claude-plugin/plugin.json index 67c889e..810ed19 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.2.11", + "version": "0.2.12", "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 c8d7b1a..49ebaf9 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@bf1ccadb3a4110dd29b70efe4f621f822110bfcc", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@01c8c6e48c3a92155803cd4232b56b0c1d3363c2", "deploybot-mcp" ] } diff --git a/adapters/claude-code/skills/deploybot/SKILL.md b/adapters/claude-code/skills/deploybot/SKILL.md index fb3c508..cf88e38 100644 --- a/adapters/claude-code/skills/deploybot/SKILL.md +++ b/adapters/claude-code/skills/deploybot/SKILL.md @@ -79,6 +79,22 @@ 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. +Before opening or editing an exact-main recovery PR, call +`claim_release_repair` with the native provider and thread ID. Work only when it +returns `owned`, using its deterministic branch. If it returns `claimed`, the +named thread already owns that failed SHA; wait for that repair and never create +a competing PR. The owner is encoded in the atomic branch ref, so a registry +write failure is recovered by calling the same tool again. + +New batches are FIFO-bounded by `integration.max_batch_size`, and a merged batch +closes admission until its cumulative main revision is verified live. Do not +override either boundary for later work. Never execute merged PR code inside +the privileged coordinator; generated-artifact conflicts go to the elected +repair owner for a normal reviewed rebuild. When PR-authored checks are +required, use a GitHub App installation token, list its bot login in +`queue.coordinator_actors`, and enable `require_non_actions_author`; never manually re-author a +workflow-token integration PR. + ## Notify Source Threads After exact-main verification, `follow_release` returns one diff --git a/adapters/claude-code/skills/manage-merge-queue/SKILL.md b/adapters/claude-code/skills/manage-merge-queue/SKILL.md index e381d14..8d92cb7 100644 --- a/adapters/claude-code/skills/manage-merge-queue/SKILL.md +++ b/adapters/claude-code/skills/manage-merge-queue/SKILL.md @@ -24,6 +24,11 @@ A genuine repair remains merge-ineligible, but DeployBot may temporarily hold overlapping ready work for the configured bounded repair window so concurrent merges do not repeatedly invalidate the replacement head. +Before creating an exact-main recovery, call `claim_release_repair`; only the +returned `owned` thread may use the deterministic repair branch. Respect the +maximum batch size and keep new merges closed while an earlier release is +unfinished. + When `follow_release` returns `thread_notifications`, send each supplied message to its native source thread. The source thread calls `acknowledge_thread_deployment` with the matching `notification_id`. Present the diff --git a/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json b/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json index 180764d..1dcbe9f 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.2.11", + "version": "0.2.12", "description": "Coordinate exact-head pull requests through verified deployment and thread notification", "author": { "name": "DeployBot contributors" diff --git a/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md b/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md index ba5b3e2..3d9f285 100644 --- a/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md +++ b/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md @@ -76,6 +76,22 @@ Use `deploybot doctor --json` for setup drift and `deploybot metrics --json` for p50, p95, and slow-stage evidence. A failed cumulative CI or deployment pauses the controller; only a designated coordinator may unpause after recovery. +Before opening or editing an exact-main recovery PR, run +`deploybot claim-release-repair` with the native provider and thread ID. Work only when it +returns `owned`, using its deterministic branch. If it returns `claimed`, the +named thread already owns that failed SHA; wait for that repair and never create +a competing PR. The owner is encoded in the atomic branch ref, so a registry +write failure is recovered by calling the same tool again. + +New batches are FIFO-bounded by `integration.max_batch_size`, and a merged batch +closes admission until its cumulative main revision is verified live. Do not +override either boundary for later work. Never execute merged PR code inside +the privileged coordinator; generated-artifact conflicts go to the elected +repair owner for a normal reviewed rebuild. When PR-authored checks are +required, use a GitHub App installation token, list its bot login in +`queue.coordinator_actors`, and enable `require_non_actions_author`; never manually re-author a +workflow-token integration PR. + ## Notify Source Threads After exact-main verification, `deploybot follow --json` returns one 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 b534e85..a8d0937 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 @@ -25,6 +25,11 @@ A genuine repair remains merge-ineligible, but DeployBot may temporarily hold overlapping ready work for the configured bounded repair window so concurrent merges do not repeatedly invalidate the replacement head. +Before creating an exact-main recovery, run `deploybot claim-release-repair`; +only the returned `owned` thread may use the deterministic repair branch. Respect the +maximum batch size and keep new merges closed while an earlier release is +unfinished. + When `deploybot follow --json` returns `thread_notifications`, send each supplied message to its native source thread. In Codex use `send_message_to_thread`; the source thread runs `deploybot thread acknowledge` with the matching diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index c8d7b1a..49ebaf9 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@bf1ccadb3a4110dd29b70efe4f621f822110bfcc", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@01c8c6e48c3a92155803cd4232b56b0c1d3363c2", "deploybot-mcp" ] } diff --git a/docs/reference.md b/docs/reference.md index 0d2e8a9..a1f3347 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,7 +1,7 @@ # DeployBot reference This reference describes the CLI, MCP server, policy file, and GitHub Action in -DeployBot v0.2.11. GitHub labels and authenticated comments are the durable state; +DeployBot v0.2.12. GitHub labels and authenticated comments are the durable state; the CLI and MCP tools are two interfaces to the same operations. ## CLI @@ -59,6 +59,7 @@ has fresh evidence; the user does not need to repeat the instruction. | `deploybot follow [--timeout SECONDS] [--poll SECONDS] [--json]` | Follow the newest exact base-branch head through CI, deployment, and HTTP verification. Defaults: 1800-second timeout and 10-second poll. | | `deploybot pause --reason TEXT` | Pause merging after a delivery failure. | | `deploybot unpause` | Resume a pipeline after verified recovery. | +| `deploybot claim-release-repair --provider CLIENT --thread-id ID [--thread-url URL] [--sha SHA]` | Atomically claim the owner-encoded deterministic repair branch for the current failed exact-main release. Other threads recover the same owner from the ref instead of creating duplicate repair PRs. | Only a configured coordinator should run these operations. `react --dispatch-ci` is required when the merge identity does not trigger ordinary @@ -96,6 +97,7 @@ arguments shown below are the tool-specific arguments. | `react_to_delivery_event` | `react` | optional `follow`, `dispatch_ci`, `timeout_seconds` | | `create_integration_pull_request` | `integrate` | optional `include_all` | | `follow_release` | `follow --json` | optional `timeout_seconds` | +| `claim_release_repair` | `claim-release-repair` | `provider`, `thread_id`; optional `thread_url`, `main_sha` | | `delivery_metrics` | `metrics --json` | optional `limit` | | `update_agent_thread` | `thread update` | `provider`, `thread_id`, `phase`; optional `pull_request`, `title`, `branch`, `url` | | `acknowledge_thread_deployment` | `thread acknowledge` | `provider`, `thread_id`, `notification_id` | @@ -165,6 +167,8 @@ Provider fields are: | `ci_failure_grace_seconds` | Non-negative window for an exact-main CI retry to replace a failed attempt before the release fails. Default: 90. | | `promotion_workers` | Positive maximum number of deploy requests promoted concurrently. Default: 4. | | `repair_hold_minutes` | Positive maximum time that a genuine repair may hold overlapping ready work without becoming merge-eligible. Default: 60. | +| `hold_merges_while_releasing` | Default `true`; after a merge, admit no newer batch until the cumulative exact-main revision is verified live. | +| `repair_branch_prefix` | Deterministic release-repair lease branch prefix; default `"deploybot/repair"`. | | `ready_to_merge_target_minutes` | Positive request-to-ready and queued-to-merge timing target; default 15. | | `merge_to_live_target_minutes` | Positive timing target; default 10. | | `auto_promote` | Default `true`. | @@ -183,6 +187,8 @@ Provider fields are: | `mode` | `"manual"`; allowed: `manual`, `overlap`, `all`. | | `branch_prefix` | `"deploybot/integration"` | | `title_prefix` | `"DeployBot integration"` | +| `max_batch_size` | Positive maximum frozen batch size; default 3. Later FIFO entries remain in the next batch. A larger indivisible source-overlap or dependency closure ships alone rather than being split or deadlocked. | +| `require_non_actions_author` | Default `false`; when `true`, integration creation requires the Action `token` input and an App bot author listed in `queue.coordinator_actors`. | ## GitHub Action @@ -194,6 +200,7 @@ The composite Action runs `deploybot react` from the checked-out default branch. | `follow` | `"true"` | Add `--follow` to the event worker. | | `dispatch_ci` | `"true"` | Dispatch configured CI after a merge made with `github.token`. | | `timeout` | `"1800"` | Release-follow timeout in seconds. | +| `token` | `""` | GitHub App installation token used to author integration PRs so normal PR checks and events run; empty falls back to `github.token`. | The workflow needs `contents: write`, `pull-requests: write`, `checks: read`, `issues: write`, and `actions: write`. Use the event filters, concurrency group, @@ -209,8 +216,11 @@ with `pipeline.ci_workflows`. - `MERGE_QUEUE_CONFIG` selects the policy path when `--config` is absent. - The variable named by `pipeline.webhook_url_env` supplies the optional event webhook URL. -- The composite Action maps `github.token` to `GH_TOKEN`; callers do not need to - create a separate token when its documented permissions are sufficient. +- The composite Action maps `token` to `GH_TOKEN`, falling back to + `github.token`. Repositories that require PR-authored checks for cumulative + integration must pass a GitHub App installation token, list that App's bot + login in `queue.coordinator_actors`, and set + `integration.require_non_actions_author = true`. The `thread-deployed` event contains `notification_id`, `repository`, `provider`, `thread_id`, `main_sha`, and a user-facing `message`, plus available diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index efb6805..7d5f2d8 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -59,5 +59,5 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - # v0.2.11 implementation; keep the full commit for privileged workflows. - - uses: Forward-Future/DeployBot@bf1ccadb3a4110dd29b70efe4f621f822110bfcc + # v0.2.12 implementation; keep the full commit for privileged workflows. + - uses: Forward-Future/DeployBot@01c8c6e48c3a92155803cd4232b56b0c1d3363c2 diff --git a/pyproject.toml b/pyproject.toml index ed4cbe6..ce2b12d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deploybot-merge-queue" -version = "0.2.11" +version = "0.2.12" 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 fb3c508..cf88e38 100644 --- a/skills/deploybot/SKILL.md +++ b/skills/deploybot/SKILL.md @@ -79,6 +79,22 @@ 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. +Before opening or editing an exact-main recovery PR, call +`claim_release_repair` with the native provider and thread ID. Work only when it +returns `owned`, using its deterministic branch. If it returns `claimed`, the +named thread already owns that failed SHA; wait for that repair and never create +a competing PR. The owner is encoded in the atomic branch ref, so a registry +write failure is recovered by calling the same tool again. + +New batches are FIFO-bounded by `integration.max_batch_size`, and a merged batch +closes admission until its cumulative main revision is verified live. Do not +override either boundary for later work. Never execute merged PR code inside +the privileged coordinator; generated-artifact conflicts go to the elected +repair owner for a normal reviewed rebuild. When PR-authored checks are +required, use a GitHub App installation token, list its bot login in +`queue.coordinator_actors`, and enable `require_non_actions_author`; never manually re-author a +workflow-token integration PR. + ## Notify Source Threads After exact-main verification, `follow_release` returns one diff --git a/skills/manage-merge-queue/SKILL.md b/skills/manage-merge-queue/SKILL.md index 76694a2..c8cdf78 100644 --- a/skills/manage-merge-queue/SKILL.md +++ b/skills/manage-merge-queue/SKILL.md @@ -49,6 +49,10 @@ merges do not repeatedly invalidate the replacement head. 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 +the coordinator verifies recovery and unpauses. Before creating that recovery, +call `claim_release_repair`; only the returned `owned` thread may use the +deterministic repair branch. Respect the configured maximum batch size and keep +new merges closed while an earlier exact-main release is unfinished. 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 87b4e36..82cb24c 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.2.11" +__version__ = "0.2.12" diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index 393f389..d3a871a 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -24,6 +24,7 @@ from .doctor import diagnose from .pipeline import ( follow_release, + http_verifications, notify, release_state, seconds_between, @@ -33,6 +34,7 @@ CONTROL_MARKER, INTEGRATION_MARKER, REPAIR_MARKER, + RELEASE_WATERMARK_MARKER, THREAD_PHASES, DeploymentNotificationRecord, ThreadRecord, @@ -41,11 +43,14 @@ integration_body, intent_body, latest_intent, + latest_release_repair, latest_deployment_notifications, latest_payload, latest_thread_records, parse_time, repair_body, + release_repair_body, + release_watermark_body, thread_record_body, ) from .reviews import ReviewVerdict, evaluate_reviews @@ -79,6 +84,7 @@ re.DOTALL, ) PULL_REQUEST_NUMBER = re.compile(r"#(\d+)\b") +RELEASE_REPAIR_LEASE_PREFIX = "DeployBot release repair lease v1 " FAILED_CHECK_STATES = { "ACTION_REQUIRED", "CANCELLED", @@ -434,17 +440,26 @@ def generated_only_change( return bool(removed or added) and removed == added -def overlap_groups(entries: Iterable["QueueEntry"]) -> list[dict[str, Any]]: +def overlap_groups( + entries: Iterable["QueueEntry"], *, include_generated: bool = True +) -> list[dict[str, Any]]: values = list(entries) adjacency: dict[int, set[int]] = {entry.number: set() for entry in values} - shared: dict[tuple[int, int], set[str]] = {} + shared_source: dict[tuple[int, int], set[str]] = {} + shared_generated: dict[tuple[int, int], set[str]] = {} for index, left in enumerate(values): for right in values[index + 1 :]: - paths = set(left.source_paths) & set(right.source_paths) - if not paths: + source_paths = set(left.source_paths) & set(right.source_paths) + generated_paths = ( + set(left.generated_paths) & set(right.generated_paths) + if include_generated + else set() + ) + if not source_paths and not generated_paths: continue pair = tuple(sorted((left.number, right.number))) - shared[pair] = paths + shared_source[pair] = source_paths + shared_generated[pair] = generated_paths adjacency[left.number].add(right.number) adjacency[right.number].add(left.number) @@ -462,12 +477,18 @@ def overlap_groups(entries: Iterable["QueueEntry"]) -> list[dict[str, Any]]: component.add(current) pending.extend(adjacency[current] - component) visited.update(component) - paths: set[str] = set() - for pair, pair_paths in shared.items(): + source_paths: set[str] = set() + generated_paths: set[str] = set() + for pair, pair_paths in shared_source.items(): if pair[0] in component and pair[1] in component: - paths.update(pair_paths) + source_paths.update(pair_paths) + generated_paths.update(shared_generated[pair]) groups.append( - {"pull_requests": sorted(component), "source_paths": sorted(paths)} + { + "pull_requests": sorted(component), + "source_paths": sorted(source_paths), + "generated_paths": sorted(generated_paths), + } ) return groups @@ -479,6 +500,7 @@ def batch_fingerprint(entries: Iterable["QueueEntry"]) -> str: "number": entry.number, "dependencies": entry.dependencies, "source_paths": entry.source_paths, + "generated_paths": entry.generated_paths, } for entry in entries ] @@ -498,6 +520,9 @@ def new_batch(entries: list["QueueEntry"], *, frozen_at: str) -> dict[str, Any]: "pull_requests": [entry.number for entry in entries], "schema": 1, "source_paths": {str(entry.number): entry.source_paths for entry in entries}, + "generated_paths": { + str(entry.number): entry.generated_paths for entry in entries + }, } @@ -522,13 +547,18 @@ def batch_overlap_peers( batch: dict[str, Any], number: int, active_numbers: set[int] ) -> list[int]: paths_by_pr = batch.get("source_paths") or {} + generated_by_pr = batch.get("generated_paths") or {} target_paths = set(paths_by_pr.get(str(number)) or []) + target_generated = set(generated_by_pr.get(str(number)) or []) return sorted( int(peer) for peer in batch.get("pull_requests") or [] if int(peer) != number and int(peer) in active_numbers - and target_paths.intersection(paths_by_pr.get(str(peer)) or []) + and ( + target_paths.intersection(paths_by_pr.get(str(peer)) or []) + or target_generated.intersection(generated_by_pr.get(str(peer)) or []) + ) ) @@ -1376,6 +1406,177 @@ def set_pipeline_control(self, state: str, reason: str | None = None) -> None: raise QueueError("could not create DeployBot registry") self.issue_comment(number, control_body(state=state, reason=reason)) + def verified_main_sha(self) -> str | None: + value = latest_payload( + self.registry_comments(), + RELEASE_WATERMARK_MARKER, + self.coordinator_logins, + ) + sha = str((value or {}).get("main_sha") or "") + return sha if re.fullmatch(r"[0-9a-f]{40}", sha) else None + + def record_verified_main(self, main_sha: str) -> None: + if self.verified_main_sha() == main_sha: + return + 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, release_watermark_body(main_sha)) + + def claim_release_repair( + self, + *, + provider: str, + thread_id: str, + thread_url: str | None = None, + main_sha: str | None = None, + ) -> dict[str, Any]: + """Atomically elect one native thread to repair the active release.""" + self.require_actor( + self.coordinator_logins | self.trusted_logins, + "claim a release repair", + ) + current_sha = self.base_sha() + expected_sha = main_sha or current_sha + if expected_sha != current_sha: + raise QueueError( + f"release repair SHA {expected_sha} is stale; current main is {current_sha}" + ) + release = release_state( + main_sha=current_sha, + runs=self.workflow_runs(), + config=self.config.pipeline, + ) + if release.get("state") == "verified" and self.config.pipeline.verifications: + health = http_verifications(self.config.pipeline) + if not all(item["passed"] for item in health): + release = { + **release, + "state": "verify-failed", + "verifications": health, + } + if release.get("state") not in { + "ci-failed", + "deploy-failed", + "verify-failed", + }: + raise QueueError( + f"main {current_sha} is {release.get('state')}; no release repair is claimable" + ) + branch = f"{self.config.pipeline.repair_branch_prefix}/{current_sha[:12]}" + latest_ci = release.get("latest_ci") or {} + latest_deploy = release.get("latest_deploy") or {} + failed_run = ( + latest_deploy + if release.get("state") == "deploy-failed" + else latest_ci + ) + payload = { + "branch": branch, + "claimed_at": utc_now(), + "failure_state": release.get("state"), + "main_sha": current_sha, + "provider": provider, + "run_id": failed_run.get("id"), + "thread_id": thread_id, + "thread_url": thread_url, + } + base_commit = self._json( + "api", f"repos/{self.repository}/git/commits/{current_sha}" + ) + tree_sha = str((base_commit.get("tree") or {}).get("sha") or "") + if not tree_sha: + raise QueueError("GitHub did not return the failed main commit tree") + lease_commit = self._json( + "api", + "--method", + "POST", + f"repos/{self.repository}/git/commits", + "-f", + f"message={RELEASE_REPAIR_LEASE_PREFIX}{json.dumps(payload, sort_keys=True)}", + "-f", + f"tree={tree_sha}", + "-f", + f"parents[]={current_sha}", + ) + lease_sha = str(lease_commit.get("sha") or "") + if not lease_sha: + raise QueueError("GitHub did not create the release repair lease commit") + payload["lease_sha"] = lease_sha + if self.base_sha() != current_sha: + raise QueueError( + f"release repair SHA {current_sha} is stale; main advanced before claim" + ) + created = False + try: + self._json( + "api", + "--method", + "POST", + f"repos/{self.repository}/git/refs", + "-f", + f"ref=refs/heads/{branch}", + "-f", + f"sha={lease_sha}", + ) + created = True + except QueueError as error: + if "Reference already exists" not in str(error): + raise + + if self.base_sha() != current_sha: + if created: + try: + self._run( + "api", + "--method", + "DELETE", + f"repos/{self.repository}/git/refs/heads/{branch}", + ) + except QueueError: + pass + raise QueueError( + f"release repair SHA {current_sha} is stale; main advanced during claim" + ) + + if not created: + ref = self._json( + "api", f"repos/{self.repository}/git/ref/heads/{branch}" + ) + lease_sha = str((ref.get("object") or {}).get("sha") or "") + commit = self._json( + "api", f"repos/{self.repository}/git/commits/{lease_sha}" + ) + message = str(commit.get("message") or "") + if not message.startswith(RELEASE_REPAIR_LEASE_PREFIX): + raise QueueError(f"repair lease branch {branch} has invalid ownership") + try: + lease_payload = json.loads(message[len(RELEASE_REPAIR_LEASE_PREFIX) :]) + except json.JSONDecodeError as error: + raise QueueError( + f"repair lease branch {branch} has invalid ownership" + ) from error + if ( + not isinstance(lease_payload, dict) + or lease_payload.get("main_sha") != current_sha + or not lease_payload.get("thread_id") + ): + raise QueueError(f"repair lease branch {branch} has invalid ownership") + payload = {**lease_payload, "lease_sha": lease_sha} + + # The ref's owner-encoded commit is authoritative. Publishing the + # registry record is retryable and idempotent even if the first process + # exits immediately after winning the atomic ref create. + 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, release_repair_body(payload)) + same_owner = ( + payload.get("provider") == provider + and payload.get("thread_id") == thread_id + ) + return {**payload, "state": "owned" if same_owner else "claimed"} + def create_integration_pull_request( self, *, @@ -1471,8 +1672,46 @@ def create_integration_pull_request( except QueueError: pass raise + author = str((pull.get("user") or {}).get("login") or "") + identity_error: str | None = None + if self.config.integration.require_non_actions_author: + if author.lower() == "github-actions[bot]": + identity_error = ( + "integration PRs require a GitHub App installation token; " + "pass the action's token input and do not use github.token" + ) + elif author.lower() not in { + login.lower() for login in self.coordinator_logins + }: + identity_error = ( + f"integration PR author {author or ''} is not trusted; " + "add the GitHub App bot login to queue.coordinator_actors" + ) + if identity_error: + # Installation-token identity is available on the PR response, + # unlike GET /user. Remove the unusable workflow-token PR before it + # can own the frozen source batch. + try: + self._run( + "api", + "--method", + "PATCH", + f"repos/{self.repository}/pulls/{pull['number']}", + "-f", + "state=closed", + ) + self._run( + "api", + "--method", + "DELETE", + f"repos/{self.repository}/git/refs/heads/{branch}", + ) + except QueueError: + pass + raise QueueError(identity_error) number = int(pull["number"]) marker = { + "author": author, "base_sha": base_sha, "batch_id": batch_id, "conflict": conflict, @@ -1599,7 +1838,6 @@ def changed_paths(self, number: int) -> tuple[list[str], list[str]]: continue if path in self.config.generated_paths: generated_paths.append(path) - source_paths.append(path) continue target = ( generated_paths @@ -2011,7 +2249,7 @@ def print_plan(entries: list[QueueEntry], *, json_output: bool) -> None: 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"]) + paths = ", ".join(group["source_paths"] + group["generated_paths"]) print(f"integration required: {numbers} overlap in {paths}") @@ -2138,6 +2376,14 @@ def inspect( ), config=client.config.pipeline, ) + coordinator_logins = getattr(client, "coordinator_logins", None) + if not isinstance(coordinator_logins, set): + coordinator_logins = client.trusted_logins + release_repair = latest_release_repair( + client.registry_comments(), + coordinator_logins | client.trusted_logins, + main_sha=main_sha, + ) now = datetime.now(timezone.utc) alerts: list[dict[str, Any]] = [] queue_target = client.config.pipeline.ready_to_merge_target_minutes * 60 @@ -2205,6 +2451,7 @@ def inspect( [entry for entry, _, _ in active_intents] ), "release": delivery, + "release_repair": release_repair, "alerts": alerts, } @@ -3231,6 +3478,55 @@ def as_dict(self) -> dict[str, Any]: } +def bounded_batch_entries( + entries: list[QueueEntry], max_batch_size: int +) -> list[QueueEntry]: + """Choose one FIFO window closed over overlap and queued dependencies.""" + by_number = {entry.number: entry for entry in entries} + component_by_number = { + entry.number: {entry.number} for entry in entries + } + for group in overlap_groups(entries, include_generated=False): + component = {int(number) for number in group["pull_requests"]} + for number in component: + component_by_number[number] = component + + def closure(seed: int) -> set[int]: + result = set(component_by_number[seed]) + pending = list(result) + while pending: + number = pending.pop() + entry = by_number[number] + for dependency in entry.dependencies: + if dependency not in by_number: + continue + for related in component_by_number[dependency]: + if related not in result: + result.add(related) + pending.append(related) + return result + + selected: set[int] = set() + for entry in entries: + if entry.number in selected: + continue + candidate = closure(entry.number) + if len(candidate) > max_batch_size: + if not selected: + # A true source-overlap/dependency closure is indivisible. Let + # it ship alone rather than deadlocking forever or splitting an + # atomic validation unit. Generated artifacts are excluded from + # source overlap, so they cannot inflate this exception. + selected.update(candidate) + break + if len(selected | candidate) > max_batch_size: + break + selected.update(candidate) + if len(selected) == max_batch_size: + break + return [entry for entry in entries if entry.number in selected] + + def freeze_queue( client: GitHub, *, @@ -3311,7 +3607,21 @@ def load_paths(entry: QueueEntry) -> tuple[int, list[str], list[str]]: } batch = active_batch(entries, latest, completed) if batch is None: - batch = new_batch(entries, frozen_at=utc_now()) + # A single slow integration must not turn every ready change behind it + # into one unbounded release train. Preserve FIFO order, but freeze only + # the configured delivery window; the remainder becomes the next batch. + bounded = bounded_batch_entries( + entries, client.config.integration.max_batch_size + ) + if not bounded: + return FreezeResult( + None, + [], + blocked_entries, + entries, + overlap_groups(entries), + ) + batch = new_batch(bounded, frozen_at=utc_now()) selected = entries_in_batch(entries, batch) selected_numbers = {entry.number for entry in selected} next_batch = [entry for entry in entries if entry.number not in selected_numbers] @@ -3788,6 +4098,10 @@ def command_drain( key = tuple(int(value) for value in group["pull_requests"]) integration_by_members[key] = group next_batch = list(batch_result["next_batch"]) + if batch_result["merged"]: + # One reaction owns one bounded release batch. Leave the FIFO + # remainder for the event that runs after exact-main verification. + break if not next_batch: break @@ -4129,6 +4443,7 @@ def command_follow( f"{result['state']} on {result['main_sha']}", ) if result["state"] == "verified": + client.record_verified_main(str(result["main_sha"])) existing_notifications = client.deployment_notifications(include_delivered=True) if not isinstance(existing_notifications, list): existing_notifications = [] @@ -4425,8 +4740,119 @@ def command_react( print(json.dumps(result, indent=2, sort_keys=True)) return result + # An authorized PR may have landed outside the controller. Materialize its + # durable release obligation before deciding whether an empty watermark + # can safely establish a first-install baseline. reconciled_merges = reconcile_externally_merged_threads(client) + # Once a batch reaches main, close admission until that exact cumulative + # revision is live. Otherwise a busy queue can keep advancing main faster + # than CI/deploy can verify it, starving an already-merged small change. + release_completed_before_merge = False + release_before_batch: dict[str, Any] | None = None + if client.config.pipeline.hold_merges_while_releasing: + workflow_runs = client.workflow_runs() + if not isinstance(workflow_runs, list): + workflow_runs = [] + current_main_sha = client.base_sha() + release_before_merge = release_state( + main_sha=current_main_sha, + runs=workflow_runs, + config=client.config.pipeline, + ) + raw_watermark = client.verified_main_sha() + release_already_verified = raw_watermark == current_main_sha + has_release_owner = ( + not release_already_verified + and release_before_merge.get("latest_ci") is not None + ) + if not release_already_verified and any( + value.get("merge_sha") + and client.is_ancestor(str(value["merge_sha"]), current_main_sha) + for value in reconciled_merges + ): + has_release_owner = True + if ( + not release_already_verified + and isinstance(raw_watermark, str) + and raw_watermark != current_main_sha + ): + has_release_owner = True + if not release_already_verified and not has_release_owner: + thread_records = client.thread_records(include_terminal=True) + if not isinstance(thread_records, list): + thread_records = [] + has_release_owner = any( + record.get("phase") == "merged" + and bool(record.get("merge_sha")) + and client.is_ancestor( + str(record["merge_sha"]), + str(release_before_merge["main_sha"]), + ) + for record in thread_records + ) + if not release_already_verified and not has_release_owner: + notifications = client.deployment_notifications(include_delivered=True) + if not isinstance(notifications, list): + notifications = [] + has_release_owner = any( + record.get("state") == "awaiting-verification" + for record in notifications + ) + if raw_watermark is None and not has_release_owner: + # First installation (or registry recovery) has no trustworthy + # prior boundary. Seed current main only when no exact run or + # durable merged obligation owns it; historical runs for older + # SHAs cannot make an unobservable release finish. + client.record_verified_main(current_main_sha) + release_is_verified = release_already_verified or ( + release_before_merge.get("state") == "verified" + ) + if ( + not release_already_verified + and release_is_verified + and client.config.pipeline.verifications + ): + health = http_verifications(client.config.pipeline) + release_before_merge = { + **release_before_merge, + "state": ( + "verified" + if all(item["passed"] for item in health) + else "verify-failed" + ), + "verifications": health, + } + release_is_verified = release_before_merge["state"] == "verified" + if release_is_verified: + client.record_verified_main(current_main_sha) + if has_release_owner and not release_is_verified: + release = release_before_merge + if follow: + release = command_follow( + client, + timeout_seconds=timeout_seconds, + poll_seconds=10, + json_output=False, + emit=False, + ) + if release.get("state") != "verified": + result = { + "state": "release-held", + "release": release, + "promoted": {}, + "promoted_integrations": [], + "drain": {}, + "dispatched_ci": [], + "integrations": [], + "integration_checks": [], + "reconciled_merges": reconciled_merges, + } + print(json.dumps(result, indent=2, sort_keys=True)) + return result + release_completed_before_merge = True + release_before_batch = release + def own_integration_checks( numbers: Iterable[int] | None = None, ) -> list[dict[str, Any]]: @@ -4593,11 +5019,19 @@ def own_integration_checks( emit=False, initial_frozen=frozen, ) - if newly_promoted_integrations: + if newly_promoted_integrations and not drained.get("merged"): + integration_frozen: FreezeResult | None = None + if client.config.integration.mode == "all": + queued_numbers = set(client.queued_numbers()) + integration_frozen = freeze_queue( + client, + held_numbers=queued_numbers - set(newly_promoted_integrations), + ) integration_drain = command_drain( client, json_output=False, emit=False, + initial_frozen=integration_frozen, ) drained = combine_drain_results(drained, integration_drain) dispatched_ci: list[dict[str, Any]] = [] @@ -4610,9 +5044,9 @@ def own_integration_checks( "paused", "post-merge CI dispatch failed: " + str(error) ) raise - release: dict[str, Any] | None = None + release: dict[str, Any] | None = release_before_batch should_follow = bool(drained.get("merged")) - if follow and not should_follow: + if follow and not should_follow and not release_completed_before_merge: # The worker that performed the merge may be replaced while waiting for # CI. Let a later event take over only when a release actually remains; # an idle all-mode integration batch must not occupy the coordinator for @@ -4646,6 +5080,24 @@ def command_control(client: GitHub, *, state: str, reason: str | None) -> None: print(f"DeployBot pipeline is {state}") +def command_claim_release_repair( + client: GitHub, + *, + provider: str, + thread_id: str, + thread_url: str | None, + main_sha: str | None, +) -> dict[str, Any]: + result = client.claim_release_repair( + provider=provider, + thread_id=thread_id, + thread_url=thread_url, + main_sha=main_sha, + ) + print(json.dumps(result, indent=2, sort_keys=True)) + return result + + def delivery_metrics(client: GitHub, *, limit: int) -> dict[str, Any]: pulls = client.recent_merged_pull_requests(limit) merged_times = [ @@ -4810,6 +5262,14 @@ def build_parser() -> argparse.ArgumentParser: ) pause.add_argument("--reason", required=True) subparsers.add_parser("unpause", help="resume a paused delivery pipeline") + claim_repair = subparsers.add_parser( + "claim-release-repair", + help="atomically claim ownership of the current failed release", + ) + claim_repair.add_argument("--provider", required=True) + claim_repair.add_argument("--thread-id", required=True) + claim_repair.add_argument("--thread-url") + claim_repair.add_argument("--sha", dest="main_sha") 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") @@ -4928,6 +5388,14 @@ def main(argv: list[str] | None = None) -> int: command_control(client, state="paused", reason=arguments.reason) elif arguments.command == "unpause": command_control(client, state="running", reason=None) + elif arguments.command == "claim-release-repair": + command_claim_release_repair( + client, + provider=arguments.provider, + thread_id=arguments.thread_id, + thread_url=arguments.thread_url, + main_sha=arguments.main_sha, + ) elif arguments.command == "metrics": if arguments.limit < 1: raise QueueError("--limit must be positive") diff --git a/src/agent_merge_queue/config.py b/src/agent_merge_queue/config.py index 18cc2cb..3585be3 100644 --- a/src/agent_merge_queue/config.py +++ b/src/agent_merge_queue/config.py @@ -48,6 +48,8 @@ class PipelineConfig: ci_failure_grace_seconds: int promotion_workers: int repair_hold_minutes: int + hold_merges_while_releasing: bool + repair_branch_prefix: str ready_to_merge_target_minutes: int merge_to_live_target_minutes: int auto_promote: bool @@ -62,6 +64,8 @@ class IntegrationConfig: mode: str branch_prefix: str title_prefix: str + max_batch_size: int + require_non_actions_author: bool @dataclass(frozen=True) @@ -124,6 +128,8 @@ class QueueConfig: ci_failure_grace_seconds = 90 promotion_workers = 4 repair_hold_minutes = 60 +hold_merges_while_releasing = true +repair_branch_prefix = "deploybot/repair" ready_to_merge_target_minutes = 15 merge_to_live_target_minutes = 10 auto_promote = true @@ -138,6 +144,8 @@ class QueueConfig: mode = "manual" branch_prefix = "deploybot/integration" title_prefix = "DeployBot integration" +max_batch_size = 3 +require_non_actions_author = false """ @@ -149,6 +157,28 @@ def _require_string(value: Any, field: str, default: str | None = None) -> str: return value.strip() +def _branch_prefix(value: Any, field: str, default: str) -> str: + prefix = _require_string(value, field, default).rstrip("/") + components = prefix.split("/") + invalid_character = re.search(r"[\x00-\x20\x7f~^:?*\[\\]", prefix) + if ( + not prefix + or prefix == "@" + or ".." in prefix + or "@{" in prefix + or invalid_character + or any( + not component + or component.startswith(".") + or component.endswith(".") + or component.endswith(".lock") + for component in components + ) + ): + raise ConfigError(f"{field} must be a valid Git branch prefix") + return prefix + + def _string_tuple(value: Any, field: str) -> tuple[str, ...]: if value is None: return () @@ -452,6 +482,16 @@ def parse_config(payload: dict[str, Any]) -> QueueConfig: "pipeline.repair_hold_minutes", 60, ), + hold_merges_while_releasing=_boolean( + pipeline.get("hold_merges_while_releasing"), + "pipeline.hold_merges_while_releasing", + True, + ), + repair_branch_prefix=_branch_prefix( + pipeline.get("repair_branch_prefix"), + "pipeline.repair_branch_prefix", + "deploybot/repair", + ), ready_to_merge_target_minutes=_positive_int( pipeline.get("ready_to_merge_target_minutes"), "pipeline.ready_to_merge_target_minutes", @@ -486,6 +526,16 @@ def parse_config(payload: dict[str, Any]) -> QueueConfig: "integration.title_prefix", "DeployBot integration", ), + max_batch_size=_positive_int( + integration.get("max_batch_size"), + "integration.max_batch_size", + 3, + ), + require_non_actions_author=_boolean( + integration.get("require_non_actions_author"), + "integration.require_non_actions_author", + False, + ), ), ) diff --git a/src/agent_merge_queue/mcp_server.py b/src/agent_merge_queue/mcp_server.py index 5ed3517..f9e6695 100644 --- a/src/agent_merge_queue/mcp_server.py +++ b/src/agent_merge_queue/mcp_server.py @@ -186,6 +186,29 @@ def follow_release( ) +@mcp.tool() +def claim_release_repair( + provider: str, + thread_id: str, + thread_url: str | None = None, + main_sha: str | None = None, + repository: str | None = None, + config: str | None = None, +) -> str: + """Elect one native thread to repair the current failed main release.""" + arguments = ["--provider", provider, "--thread-id", thread_id] + if thread_url: + arguments.extend(("--thread-url", thread_url)) + if main_sha: + arguments.extend(("--sha", main_sha)) + return _run( + "claim-release-repair", + *arguments, + repository=repository, + config=config, + ) + + @mcp.tool() def delivery_metrics( limit: int = 25, diff --git a/src/agent_merge_queue/records.py b/src/agent_merge_queue/records.py index 4f838b8..38a0b85 100644 --- a/src/agent_merge_queue/records.py +++ b/src/agent_merge_queue/records.py @@ -12,9 +12,11 @@ THREAD_PREFIX = "deploybot-thread:v1" INTENT_PREFIX = "deploybot-intent:v1" REPAIR_PREFIX = "deploybot-repair:v1" +RELEASE_REPAIR_PREFIX = "deploybot-release-repair:v1" CONTROL_PREFIX = "deploybot-control:v1" INTEGRATION_PREFIX = "deploybot-integration:v1" NOTIFICATION_PREFIX = "deploybot-notification:v1" +RELEASE_WATERMARK_PREFIX = "deploybot-release-watermark:v1" THREAD_PHASES = { "working", @@ -60,6 +62,9 @@ def _marker(prefix: str, prose: str) -> re.Pattern[str]: 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.") +RELEASE_REPAIR_MARKER = _marker( + RELEASE_REPAIR_PREFIX, "Recorded DeployBot release repair lease." +) CONTROL_MARKER = _marker(CONTROL_PREFIX, "Recorded DeployBot pipeline control.") INTEGRATION_MARKER = _marker( INTEGRATION_PREFIX, "Recorded DeployBot integration pull request." @@ -67,6 +72,9 @@ def _marker(prefix: str, prose: str) -> re.Pattern[str]: NOTIFICATION_MARKER = _marker( NOTIFICATION_PREFIX, "Recorded DeployBot thread notification." ) +RELEASE_WATERMARK_MARKER = _marker( + RELEASE_WATERMARK_PREFIX, "Recorded DeployBot verified main watermark." +) def marker_body(prefix: str, payload: dict[str, Any], prose: str) -> str: @@ -432,6 +440,40 @@ def repair_body(payload: dict[str, Any]) -> str: return marker_body(REPAIR_PREFIX, value, "Recorded DeployBot repair handoff.") +def release_repair_body(payload: dict[str, Any]) -> str: + value = {"schema": 1, **payload} + return marker_body( + RELEASE_REPAIR_PREFIX, + value, + "Recorded DeployBot release repair lease.", + ) + + +def release_watermark_body(main_sha: str) -> str: + return marker_body( + RELEASE_WATERMARK_PREFIX, + {"main_sha": main_sha, "recorded_at": utc_now(), "schema": 1}, + "Recorded DeployBot verified main watermark.", + ) + + +def latest_release_repair( + comments: Iterable[dict[str, Any]], + trusted_logins: Iterable[str], + *, + main_sha: 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 ""), RELEASE_REPAIR_MARKER) + if value is not None and value.get("main_sha") == main_sha: + found.append((_comment_key(comment, index), value)) + return max(found, key=lambda item: item[0])[1] if found else None + + def control_body(*, state: str, reason: str | None = None) -> str: if state not in {"running", "paused"}: raise ValueError(f"unsupported pipeline control state: {state}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 7224f2e..24f9cf0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,9 +12,11 @@ GitHub, QueueEntry, QueueError, + RELEASE_REPAIR_LEASE_PREFIX, active_batch, batch_fingerprint, batch_overlap_peers, + bounded_batch_entries, check_states, command_block, command_dequeue, @@ -62,7 +64,12 @@ thread_notification_id, ) from agent_merge_queue.config import parse_config -from agent_merge_queue.records import integration_body, intent_body, repair_body +from agent_merge_queue.records import ( + integration_body, + intent_body, + release_repair_body, + repair_body, +) from agent_merge_queue.reviews import ReviewVerdict @@ -382,6 +389,7 @@ def test_status_exposes_intent_head_overlap_and_request_delay(self) -> None: client.pipeline_control.return_value = {"state": "running"} client.thread_records.return_value = [] client.deployment_notifications.return_value = [] + client.registry_comments.return_value = [] result = pipeline_status(client) @@ -1392,6 +1400,30 @@ def test_generated_paths_are_configurable(self) -> None: ) ) + def test_generated_paths_do_not_create_hand_edited_overlap(self) -> None: + config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "files": {"generated_paths": ["dist/app.js"]}, + } + ) + client = object.__new__(GitHub) + client.config = config + client.files = Mock( + return_value=[ + {"filename": "src/app.js", "patch": "+code"}, + {"filename": "dist/app.js", "patch": None}, + ] + ) + + source_paths, generated_paths = client.changed_paths(1) + + self.assertEqual(source_paths, ["src/app.js"]) + self.assertEqual(generated_paths, ["dist/app.js"]) + def test_waiting_promotion_defers_changed_file_fetch(self) -> None: client = object.__new__(GitHub) client.config = CONFIG @@ -1589,6 +1621,18 @@ def test_overlap_groups_are_connected_components(self) -> None: self.assertEqual(groups[0]["pull_requests"], [1, 2, 3]) + def test_generated_overlap_still_requires_cumulative_validation(self) -> None: + first = entry(1, "src/a.py") + second = entry(2, "src/b.py") + first.generated_paths = ["dist/app.js"] + second.generated_paths = ["dist/app.js"] + + groups = overlap_groups([first, second]) + + self.assertEqual(groups[0]["pull_requests"], [1, 2]) + self.assertEqual(groups[0]["source_paths"], []) + self.assertEqual(groups[0]["generated_paths"], ["dist/app.js"]) + def test_queue_discovery_uses_every_paginated_pull_request(self) -> None: client = object.__new__(GitHub) client.config = CONFIG @@ -1749,6 +1793,657 @@ def test_dependency_must_be_on_configured_base_branch(self) -> None: self.assertTrue(client.dependency_is_merged(12)) self.assertIn("...main", client._json.call_args_list[1].args[1]) + def test_freeze_caps_new_batch_and_preserves_fifo_remainder(self) -> None: + values = [entry(number, f"{number}.py") for number in range(1, 6)] + client = Mock() + client.config = CONFIG + client.coordinator_logins = {"coordinator"} + client.queue.return_value = values + client.comments.return_value = [] + + frozen = freeze_queue(client) + + self.assertEqual([value.number for value in frozen.queue], [1, 2, 3]) + self.assertEqual([value.number for value in frozen.next_batch], [4, 5]) + self.assertEqual(frozen.batch["pull_requests"], [1, 2, 3]) + + def test_bounded_batch_does_not_split_overlap_component(self) -> None: + values = [ + entry(1, "a.py"), + entry(2, "b.py"), + entry(3, "shared.py"), + entry(4, "shared.py"), + entry(5, "c.py"), + ] + + selected = bounded_batch_entries(values, 3) + + self.assertEqual([value.number for value in selected], [1, 2]) + + def test_bounded_batch_includes_queued_dependency_closure(self) -> None: + dependent = entry(1, "a.py") + dependent.dependencies = [4] + values = [dependent, entry(2, "b.py"), entry(3, "c.py"), entry(4, "d.py")] + + selected = bounded_batch_entries(values, 3) + + self.assertEqual([value.number for value in selected], [1, 2, 4]) + + def test_oversized_indivisible_overlap_closure_ships_alone(self) -> None: + values = [entry(number, "shared.py") for number in range(1, 5)] + values.append(entry(5, "independent.py")) + + selected = bounded_batch_entries(values, 3) + + self.assertEqual([value.number for value in selected], [1, 2, 3, 4]) + + def test_generated_overlap_does_not_exceed_batch_limit(self) -> None: + values = [entry(number, f"src/{number}.py") for number in range(1, 5)] + for value in values: + value.generated_paths = ["dist/app.js"] + + selected = bounded_batch_entries(values, 3) + + self.assertEqual([value.number for value in selected], [1, 2, 3]) + + def test_reactor_holds_admission_until_existing_release_finishes(self) -> None: + sha = "a" * 40 + client = Mock() + client.config = CONFIG + client.pipeline_control.return_value = {"state": "running"} + client.base_sha.return_value = sha + client.workflow_runs.return_value = [ + { + "id": 1, + "name": "CI", + "head_sha": sha, + "status": "in_progress", + "conclusion": None, + "created_at": "2026-06-20T00:00:00Z", + "updated_at": "2026-06-20T00:00:00Z", + } + ] + with ( + patch( + "agent_merge_queue.cli.command_follow", + return_value={"state": "testing", "main_sha": sha}, + ), + patch("agent_merge_queue.cli.command_promote") as promote, + redirect_stdout(io.StringIO()), + ): + result = command_react(client, follow=True, timeout_seconds=10) + + self.assertEqual(result["state"], "release-held") + promote.assert_not_called() + + def test_reactor_holds_admission_even_without_follow(self) -> None: + sha = "a" * 40 + client = Mock() + client.config = CONFIG + client.pipeline_control.return_value = {"state": "running"} + client.base_sha.return_value = sha + client.workflow_runs.return_value = [ + { + "id": 1, + "name": "CI", + "head_sha": sha, + "status": "in_progress", + "conclusion": None, + } + ] + with ( + patch("agent_merge_queue.cli.command_follow") as follow_release, + patch("agent_merge_queue.cli.command_promote") as promote, + redirect_stdout(io.StringIO()), + ): + result = command_react(client, follow=False, timeout_seconds=10) + + self.assertEqual(result["state"], "release-held") + follow_release.assert_not_called() + promote.assert_not_called() + + def test_reactor_ignores_rerun_after_same_main_was_verified(self) -> None: + sha = "a" * 40 + client = Mock() + client.config = CONFIG + client.pipeline_control.return_value = {"state": "running"} + client.base_sha.return_value = sha + client.verified_main_sha.return_value = sha + client.workflow_runs.return_value = [ + { + "id": 2, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "failure", + } + ] + frozen = FreezeResult(None, [], [], [], []) + with ( + patch("agent_merge_queue.cli.settle_integration_checks", return_value=[]), + patch("agent_merge_queue.cli.promote_integrations", return_value=[]), + patch( + "agent_merge_queue.cli.command_promote", + return_value={"promoted": [], "waiting": [], "blocked": []}, + ) as promote, + patch("agent_merge_queue.cli.freeze_queue", return_value=frozen), + redirect_stdout(io.StringIO()), + ): + result = command_react(client, follow=False, timeout_seconds=10) + + self.assertNotEqual(result.get("state"), "release-held") + promote.assert_called_once() + + def test_reactor_holds_newly_merged_revision_before_ci_is_visible(self) -> None: + sha = "a" * 40 + client = Mock() + client.config = CONFIG + client.pipeline_control.return_value = {"state": "running"} + client.base_sha.return_value = sha + client.workflow_runs.return_value = [] + client.thread_records.return_value = [ + {"phase": "merged", "merge_sha": "b" * 40} + ] + client.is_ancestor.return_value = True + with ( + patch("agent_merge_queue.cli.command_promote") as promote, + redirect_stdout(io.StringIO()), + ): + result = command_react(client, follow=False, timeout_seconds=10) + + self.assertEqual(result["state"], "release-held") + promote.assert_not_called() + + def test_reactor_seeds_first_install_despite_historical_runs(self) -> None: + sha = "a" * 40 + client = Mock() + client.config = CONFIG + client.pipeline_control.return_value = {"state": "running"} + client.base_sha.return_value = sha + client.workflow_runs.return_value = [ + { + "id": 99, + "name": "CI", + "head_sha": "b" * 40, + "status": "completed", + "conclusion": "success", + } + ] + client.verified_main_sha.return_value = None + client.thread_records.return_value = [] + client.deployment_notifications.return_value = [] + frozen = FreezeResult(None, [], [], [], []) + with ( + patch("agent_merge_queue.cli.settle_integration_checks", return_value=[]), + patch("agent_merge_queue.cli.promote_integrations", return_value=[]), + patch( + "agent_merge_queue.cli.command_promote", + return_value={"promoted": [], "waiting": [], "blocked": []}, + ) as promote, + patch("agent_merge_queue.cli.freeze_queue", return_value=frozen), + redirect_stdout(io.StringIO()), + ): + result = command_react(client, follow=False, timeout_seconds=10) + + self.assertNotEqual(result.get("state"), "release-held") + client.record_verified_main.assert_called_once_with(sha) + promote.assert_called_once() + + def test_reactor_reconciles_external_merge_before_first_watermark(self) -> None: + sha = "a" * 40 + client = Mock() + client.config = CONFIG + client.pipeline_control.return_value = {"state": "running"} + client.base_sha.return_value = sha + client.workflow_runs.return_value = [] + client.verified_main_sha.return_value = None + client.thread_records.return_value = [] + client.deployment_notifications.return_value = [] + client.is_ancestor.return_value = True + with ( + patch( + "agent_merge_queue.cli.reconcile_externally_merged_threads", + return_value=[{"merge_sha": "b" * 40, "pull_request": 1}], + ) as reconcile, + patch("agent_merge_queue.cli.command_promote") as promote, + redirect_stdout(io.StringIO()), + ): + result = command_react(client, follow=False, timeout_seconds=10) + + self.assertEqual(result["state"], "release-held") + self.assertEqual(result["reconciled_merges"][0]["pull_request"], 1) + client.record_verified_main.assert_not_called() + reconcile.assert_called_once_with(client) + promote.assert_not_called() + + def test_reactor_requires_configured_health_before_reopening_admission(self) -> None: + sha = "a" * 40 + config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "pipeline": { + "verifications": [ + {"name": "Login", "url": "https://example.test/login"} + ] + }, + } + ) + client = Mock() + client.config = config + client.pipeline_control.return_value = {"state": "running"} + client.base_sha.return_value = sha + client.workflow_runs.return_value = [ + { + "id": 1, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + }, + { + "id": 2, + "name": "Deploy", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + }, + ] + with ( + patch( + "agent_merge_queue.cli.http_verifications", + return_value=[{"name": "Login", "passed": False}], + ), + patch("agent_merge_queue.cli.command_promote") as promote, + redirect_stdout(io.StringIO()), + ): + result = command_react(client, follow=False, timeout_seconds=10) + + self.assertEqual(result["state"], "release-held") + self.assertEqual(result["release"]["state"], "verify-failed") + promote.assert_not_called() + + def test_release_repair_claim_creates_one_deterministic_lease(self) -> None: + sha = "a" * 40 + client = object.__new__(GitHub) + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.require_actor = Mock(return_value="trusted") + client.base_sha = Mock(return_value=sha) + client.workflow_runs = Mock( + return_value=[ + { + "id": 7, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "updated_at": "2026-06-20T00:01:00Z", + }, + { + "id": 8, + "name": "Deploy", + "head_sha": sha, + "status": "completed", + "conclusion": "failure", + "created_at": "2026-06-20T00:02:00Z", + }, + ] + ) + client.registry_comments = Mock(return_value=[]) + client.registry_issue_number = Mock(return_value=42) + client.issue_comment = Mock() + client._json = Mock( + side_effect=[ + {"tree": {"sha": "t" * 40}}, + {"sha": "l" * 40}, + {}, + ] + ) + + result = client.claim_release_repair( + provider="codex", + thread_id="thread-1", + ) + + self.assertEqual(result["state"], "owned") + self.assertEqual(result["branch"], f"deploybot/repair/{sha[:12]}") + self.assertEqual(result["run_id"], 8) + client.issue_comment.assert_called_once() + + def test_repair_claim_ignores_unbacked_registry_owner(self) -> None: + sha = "a" * 40 + owner = { + "branch": f"deploybot/repair/{sha[:12]}", + "main_sha": sha, + "provider": "codex", + "thread_id": "thread-1", + } + client = object.__new__(GitHub) + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.require_actor = Mock(return_value="trusted") + client.base_sha = Mock(return_value=sha) + client.workflow_runs = Mock( + return_value=[ + { + "id": 7, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "failure", + } + ] + ) + client.registry_comments = Mock( + return_value=[ + { + "body": release_repair_body(owner), + "created_at": "2026-06-20T00:00:00Z", + "id": 1, + "user": {"login": "coordinator"}, + } + ] + ) + client.registry_issue_number = Mock(return_value=42) + client.issue_comment = Mock() + client._json = Mock( + side_effect=[ + {"tree": {"sha": "t" * 40}}, + {"sha": "l" * 40}, + {}, + ] + ) + + result = client.claim_release_repair( + provider="claude", thread_id="thread-1" + ) + + self.assertEqual(result["state"], "owned") + self.assertEqual(result["provider"], "claude") + client.registry_comments.assert_not_called() + + def test_release_repair_claim_accepts_failed_health_verification(self) -> None: + sha = "a" * 40 + config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "pipeline": { + "verifications": [ + {"name": "Login", "url": "https://example.test/login"} + ] + }, + } + ) + client = object.__new__(GitHub) + client.config = config + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.require_actor = Mock(return_value="trusted") + client.base_sha = Mock(return_value=sha) + client.workflow_runs = Mock( + return_value=[ + { + "id": 7, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + }, + { + "id": 8, + "name": "Deploy", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + }, + ] + ) + client.registry_comments = Mock(return_value=[]) + client.registry_issue_number = Mock(return_value=42) + client.issue_comment = Mock() + client._json = Mock( + side_effect=[ + {"tree": {"sha": "t" * 40}}, + {"sha": "l" * 40}, + {}, + ] + ) + with patch( + "agent_merge_queue.cli.http_verifications", + return_value=[{"name": "Login", "passed": False}], + ): + result = client.claim_release_repair( + provider="codex", thread_id="thread-1" + ) + + self.assertEqual(result["failure_state"], "verify-failed") + + def test_existing_repair_ref_recovers_encoded_owner(self) -> None: + sha = "a" * 40 + client = object.__new__(GitHub) + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.require_actor = Mock(return_value="trusted") + client.base_sha = Mock(return_value=sha) + client.workflow_runs = Mock( + return_value=[ + { + "id": 7, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "failure", + } + ] + ) + client.registry_comments = Mock(return_value=[]) + client.registry_issue_number = Mock(return_value=42) + client.issue_comment = Mock() + owner = { + "branch": f"deploybot/repair/{sha[:12]}", + "claimed_at": "2026-06-20T00:00:00Z", + "failure_state": "ci-failed", + "main_sha": sha, + "provider": "codex", + "run_id": 7, + "thread_id": "thread-1", + "thread_url": None, + } + client._json = Mock( + side_effect=[ + {"tree": {"sha": "t" * 40}}, + {"sha": "n" * 40}, + QueueError("Reference already exists"), + {"object": {"sha": "l" * 40}}, + {"message": RELEASE_REPAIR_LEASE_PREFIX + json.dumps(owner)}, + ] + ) + + result = client.claim_release_repair( + provider="claude", + thread_id="thread-1", + ) + + self.assertEqual(result["state"], "claimed") + self.assertEqual(result["thread_id"], "thread-1") + client.issue_comment.assert_called_once() + + def test_failed_repair_registry_write_is_recoverable_from_lease_ref(self) -> None: + sha = "a" * 40 + client = object.__new__(GitHub) + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.require_actor = Mock(return_value="trusted") + client.base_sha = Mock(return_value=sha) + client.workflow_runs = Mock( + return_value=[ + { + "id": 7, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "failure", + } + ] + ) + client.registry_comments = Mock(return_value=[]) + client.registry_issue_number = Mock(return_value=42) + owner = { + "branch": f"deploybot/repair/{sha[:12]}", + "claimed_at": "2026-06-20T00:00:00Z", + "failure_state": "ci-failed", + "main_sha": sha, + "provider": "codex", + "run_id": 7, + "thread_id": "thread-1", + "thread_url": None, + } + client.issue_comment = Mock( + side_effect=[QueueError("temporary failure"), None] + ) + client._json = Mock( + side_effect=[ + {"tree": {"sha": "t" * 40}}, + {"sha": "l" * 40}, + {}, + {"tree": {"sha": "t" * 40}}, + {"sha": "n" * 40}, + QueueError("Reference already exists"), + {"object": {"sha": "l" * 40}}, + {"message": RELEASE_REPAIR_LEASE_PREFIX + json.dumps(owner)}, + ] + ) + + with self.assertRaisesRegex(QueueError, "temporary failure"): + client.claim_release_repair(provider="codex", thread_id="thread-1") + + result = client.claim_release_repair( + provider="codex", thread_id="thread-1" + ) + self.assertEqual(result["state"], "owned") + self.assertEqual(result["lease_sha"], "l" * 40) + + def test_release_repair_claim_rejects_main_that_advances_during_claim(self) -> None: + sha = "a" * 40 + newer = "b" * 40 + client = object.__new__(GitHub) + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.require_actor = Mock(return_value="trusted") + client.base_sha = Mock(side_effect=[sha, sha, newer]) + client.workflow_runs = Mock( + return_value=[ + { + "id": 7, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "failure", + } + ] + ) + client.registry_comments = Mock(return_value=[]) + client._json = Mock( + side_effect=[ + {"tree": {"sha": "t" * 40}}, + {"sha": "l" * 40}, + {}, + ] + ) + client._run = Mock(return_value="") + + with self.assertRaisesRegex(QueueError, "advanced during claim"): + client.claim_release_repair( + provider="codex", thread_id="thread-1", main_sha=sha + ) + + client._run.assert_called_once() + + def test_integration_can_require_non_actions_author(self) -> None: + config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "integration": {"require_non_actions_author": True}, + } + ) + client = object.__new__(GitHub) + client.config = config + client.repository = "example/repo" + client.owner = "example" + client.coordinator_logins = {"trusted"} + client.base_sha = Mock(return_value="b" * 40) + client._json = Mock( + side_effect=[ + {}, + {}, + {}, + [], + {"number": 99, "user": {"login": "github-actions[bot]"}}, + ] + ) + client._run = Mock(return_value="") + + with self.assertRaisesRegex(QueueError, "GitHub App installation token"): + client.create_integration_pull_request( + batch={"batch_id": "batch"}, + entries=[entry(1), entry(2)], + ) + self.assertEqual(client._run.call_count, 2) + + def test_integration_requires_app_author_to_be_a_coordinator(self) -> None: + config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "integration": {"require_non_actions_author": True}, + } + ) + client = object.__new__(GitHub) + client.config = config + client.repository = "example/repo" + client.owner = "example" + client.coordinator_logins = {"trusted"} + client.base_sha = Mock(return_value="b" * 40) + client._json = Mock( + side_effect=[ + {}, + {}, + {}, + [], + {"number": 99, "user": {"login": "deploybot-app[bot]"}}, + ] + ) + client._run = Mock(return_value="") + + with self.assertRaisesRegex(QueueError, "queue.coordinator_actors"): + client.create_integration_pull_request( + batch={"batch_id": "batch"}, + entries=[entry(1), entry(2)], + ) + self.assertEqual(client._run.call_count, 2) + def test_queue_marker_rejects_forgery_and_free_text_injection(self) -> None: sha = "a" * 40 marker = ( @@ -1934,7 +2629,13 @@ def test_freeze_hydrates_paths_for_transiently_waiting_queued_entries( self.assertEqual(client.changed_paths.call_count, 2) self.assertEqual( frozen.overlap_groups, - [{"pull_requests": [1, 2], "source_paths": ["shared.py"]}], + [ + { + "pull_requests": [1, 2], + "source_paths": ["shared.py"], + "generated_paths": [], + } + ], ) self.assertEqual( frozen.batch["source_paths"], @@ -2064,6 +2765,31 @@ def test_drain_completes_waiting_pass_then_promotes_next_batch(self) -> None: self.assertEqual(result["merged"][0]["number"], 2) self.assertEqual(result["waiting"][0]["number"], 1) + def test_drain_stops_after_one_batch_merges(self) -> None: + first_entry = entry(1, "a.py") + later_entry = entry(2, "b.py") + first = FreezeResult( + batch={"batch_id": "first"}, + queue=[first_entry], + blocked_queue=[], + next_batch=[later_entry], + overlap_groups=[], + ) + client = Mock() + with ( + patch("agent_merge_queue.cli.freeze_queue", return_value=first) as freeze, + patch( + "agent_merge_queue.cli.command_merge", return_value="m" * 40 + ) as merge, + redirect_stdout(io.StringIO()), + ): + result = command_drain(client, json_output=True) + + freeze.assert_called_once() + merge.assert_called_once() + self.assertEqual(result["merged"][0]["number"], 1) + self.assertEqual(result["next_batch"], [2]) + def test_reenqueue_toggles_label_to_wake_event_coordinator(self) -> None: value = entry(1) old_head = "a" * 40 @@ -2897,7 +3623,7 @@ def test_reactor_uses_cumulative_pr_in_all_mode(self) -> None: client.config = config client.coordinator_logins = {"coordinator"} client.pipeline_control.return_value = {"state": "running"} - client.queued_numbers.return_value = [] + client.queued_numbers.return_value = [1, 2, 99] client.integration_pull_request_numbers.return_value = [] client.create_integration_pull_request.return_value = { "number": 99, @@ -2913,7 +3639,19 @@ def test_reactor_uses_cumulative_pr_in_all_mode(self) -> None: side_effect=[[], [99]], ), patch("agent_merge_queue.cli.command_promote", return_value={}), - patch("agent_merge_queue.cli.freeze_queue", return_value=frozen), + patch( + "agent_merge_queue.cli.freeze_queue", + side_effect=[ + frozen, + FreezeResult( + new_batch([entry(99)], frozen_at="2026-06-20T00:01:00Z"), + [entry(99)], + [], + [], + [], + ), + ], + ) as freeze, patch( "agent_merge_queue.cli.command_drain", return_value={ @@ -2928,7 +3666,12 @@ def test_reactor_uses_cumulative_pr_in_all_mode(self) -> None: redirect_stdout(io.StringIO()), ): result = command_react(client, follow=False, timeout_seconds=10) - drain.assert_called_once_with(client, json_output=False, emit=False) + self.assertEqual( + freeze.call_args_list, + [call(client, known_entries=[], held_numbers=set()), call(client, held_numbers={1, 2})], + ) + drain.assert_called_once() + self.assertEqual(drain.call_args.kwargs["initial_frozen"].queue[0].number, 99) self.assertEqual(result["integrations"][0]["number"], 99) self.assertEqual(result["drain"]["merged"][0]["number"], 99) @@ -3018,7 +3761,6 @@ def test_reactor_drains_existing_integration_before_new_all_mode_batch( emit=False, initial_frozen=existing_frozen, ), - call(client, json_output=False, emit=False), ], ) client.create_integration_pull_request.assert_called_once_with( @@ -3027,7 +3769,7 @@ def test_reactor_drains_existing_integration_before_new_all_mode_batch( ) self.assertEqual( [value["number"] for value in result["drain"]["merged"]], - [99, 100], + [99], ) def test_reactor_establishes_overlap_integration_before_draining(self) -> None: @@ -3088,7 +3830,7 @@ def drain(*_args, **_kwargs): ): command_react(client, follow=False, timeout_seconds=10) - self.assertEqual(events, ["integration", "settle", "drain", "drain"]) + self.assertEqual(events, ["integration", "settle", "drain"]) def test_reactor_does_not_drain_when_overlap_integration_creation_fails( self, diff --git a/tests/test_config.py b/tests/test_config.py index 0ae1ae0..f9be80c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -187,6 +187,8 @@ def test_parses_pipeline_integration_and_health_policy(self) -> None: "ci_failure_grace_seconds": 45, "promotion_workers": 3, "repair_hold_minutes": 90, + "hold_merges_while_releasing": False, + "repair_branch_prefix": "repairs/main", "auto_promote": False, "verifications": [ { @@ -196,7 +198,11 @@ def test_parses_pipeline_integration_and_health_policy(self) -> None: } ], }, - "integration": {"mode": "all"}, + "integration": { + "mode": "all", + "max_batch_size": 2, + "require_non_actions_author": True, + }, } ) self.assertEqual(config.pipeline.ci_workflows, ("Main CI",)) @@ -204,9 +210,28 @@ def test_parses_pipeline_integration_and_health_policy(self) -> None: self.assertEqual(config.pipeline.ci_failure_grace_seconds, 45) self.assertEqual(config.pipeline.promotion_workers, 3) self.assertEqual(config.pipeline.repair_hold_minutes, 90) + self.assertFalse(config.pipeline.hold_merges_while_releasing) + self.assertEqual(config.pipeline.repair_branch_prefix, "repairs/main") self.assertFalse(config.pipeline.auto_promote) self.assertEqual(config.pipeline.verifications[0].expected_status, 200) self.assertEqual(config.integration.mode, "all") + self.assertEqual(config.integration.max_batch_size, 2) + self.assertTrue(config.integration.require_non_actions_author) + + def test_rejects_invalid_repair_branch_prefix(self) -> None: + for prefix in ("/", "repairs//main", "repairs/../main", "repairs/main.lock"): + with self.subTest(prefix=prefix), self.assertRaisesRegex( + ConfigError, "valid Git branch prefix" + ): + parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "pipeline": {"repair_branch_prefix": prefix}, + } + ) def test_rejects_invalid_integration_mode_and_boolean(self) -> None: with self.assertRaisesRegex(ConfigError, "integration.mode"): diff --git a/tests/test_docs.py b/tests/test_docs.py index 46a3144..c4b8dfd 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -131,6 +131,8 @@ def test_reference_names_every_policy_field(self) -> None: "ci_failure_grace_seconds", "promotion_workers", "repair_hold_minutes", + "hold_merges_while_releasing", + "repair_branch_prefix", "ready_to_merge_target_minutes", "merge_to_live_target_minutes", "auto_promote", @@ -142,7 +144,13 @@ def test_reference_names_every_policy_field(self) -> None: "url", "expected_status", }, - "integration": {"mode", "branch_prefix", "title_prefix"}, + "integration": { + "mode", + "branch_prefix", + "title_prefix", + "max_batch_size", + "require_non_actions_author", + }, } queue_fields = {field.name for field in fields(QueueConfig)} - { "review_providers", diff --git a/tests/test_skill.py b/tests/test_skill.py index e90890a..5e4e320 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -8,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] CANONICAL = ROOT / "skills" / "deploybot" / "SKILL.md" -RELEASE_COMMIT = "bf1ccadb3a4110dd29b70efe4f621f822110bfcc" +RELEASE_COMMIT = "01c8c6e48c3a92155803cd4232b56b0c1d3363c2" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"