diff --git a/.mergequeue.example.toml b/.mergequeue.example.toml index 81b93d4..bb34eeb 100644 --- a/.mergequeue.example.toml +++ b/.mergequeue.example.toml @@ -44,6 +44,7 @@ registry_title = "DeployBot delivery registry" thread_active_hours = 72 ci_workflows = ["CI"] deploy_workflows = ["Deploy"] +repair_hold_minutes = 60 ready_to_merge_target_minutes = 15 merge_to_live_target_minutes = 10 auto_promote = true diff --git a/README.md b/README.md index 4330060..595d003 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.9` source commit directly from GitHub: +Install the reviewed `v0.2.10` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@b06346119a29d0be1c25d1643fdc06a03f7b94a9' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@78208849bb743d649a6e3e5e1c452b9d21ec7e2b' 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@b06346119a29d0be1c25d1643fdc06a03f7b94a9 +- uses: Forward-Future/DeployBot@78208849bb743d649a6e3e5e1c452b9d21ec7e2b ``` The Action uses GitHub's built-in workflow token. GitHub intentionally does not diff --git a/adapters/claude-code/.claude-plugin/plugin.json b/adapters/claude-code/.claude-plugin/plugin.json index bd1f84e..f5c2a3c 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.9", + "version": "0.2.10", "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 5ea6097..ed0f533 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@b06346119a29d0be1c25d1643fdc06a03f7b94a9", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@78208849bb743d649a6e3e5e1c452b9d21ec7e2b", "deploybot-mcp" ] } diff --git a/adapters/claude-code/skills/deploybot/SKILL.md b/adapters/claude-code/skills/deploybot/SKILL.md index caed755..fb3c508 100644 --- a/adapters/claude-code/skills/deploybot/SKILL.md +++ b/adapters/claude-code/skills/deploybot/SKILL.md @@ -71,6 +71,10 @@ 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. +Genuine repair blocks may hold overlapping ready work for the configured bounded +repair window, but they remain merge-ineligible until the trusted source agent +resumes the freshly reviewed exact head. + 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 30e5e53..e381d14 100644 --- a/adapters/claude-code/skills/manage-merge-queue/SKILL.md +++ b/adapters/claude-code/skills/manage-merge-queue/SKILL.md @@ -20,6 +20,10 @@ honor dependencies, route overlap or cumulative validation through and use `resume_pull_request` after fresh review. Finish with `follow_release`; a failed CI or deployment pauses the pipeline until verified recovery. +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. + 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 aeb8450..fac6a97 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.9", + "version": "0.2.10", "description": "Coordinate exact-head pull requests through verified deployment and thread notification", "author": { "name": "DeployBot contributors" diff --git a/adapters/codex/agent-merge-queue/.mcp.json b/adapters/codex/agent-merge-queue/.mcp.json index 5ea6097..ed0f533 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@b06346119a29d0be1c25d1643fdc06a03f7b94a9", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@78208849bb743d649a6e3e5e1c452b9d21ec7e2b", "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 caed755..fb3c508 100644 --- a/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md +++ b/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md @@ -71,6 +71,10 @@ 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. +Genuine repair blocks may hold overlapping ready work for the configured bounded +repair window, but they remain merge-ineligible until the trusted source agent +resumes the freshly reviewed exact head. + 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 eb8e3fa..18d81dc 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 @@ -21,6 +21,10 @@ 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. +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. + When `follow_release` returns `thread_notifications`, send each supplied message to its native source thread. In Codex use `send_message_to_thread`; the source thread calls `acknowledge_thread_deployment` with the matching diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index 5ea6097..ed0f533 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@b06346119a29d0be1c25d1643fdc06a03f7b94a9", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@78208849bb743d649a6e3e5e1c452b9d21ec7e2b", "deploybot-mcp" ] } diff --git a/docs/reference.md b/docs/reference.md index 2daf74b..215df97 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.9. GitHub labels and authenticated comments are the durable state; +DeployBot v0.2.10. GitHub labels and authenticated comments are the durable state; the CLI and MCP tools are two interfaces to the same operations. ## CLI @@ -164,6 +164,7 @@ Provider fields are: | `batch_settle_seconds` | Non-negative window for coalescing near-ready deploy requests before freezing a batch. Default: 15. | | `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. | | `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`. | @@ -190,7 +191,7 @@ The composite Action runs `deploybot react` from the checked-out default branch. | Input | Default | Purpose | | --- | --- | --- | | `config` | `.mergequeue.toml` | Repository-relative policy path. | -| `follow` | `"false"` | Add `--follow` to the event worker. | +| `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. | diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index 0c73f34..ce9aa9a 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.9 implementation; keep the full commit for privileged workflows. - - uses: Forward-Future/DeployBot@b06346119a29d0be1c25d1643fdc06a03f7b94a9 + # v0.2.10 implementation; keep the full commit for privileged workflows. + - uses: Forward-Future/DeployBot@78208849bb743d649a6e3e5e1c452b9d21ec7e2b diff --git a/pyproject.toml b/pyproject.toml index 7803172..661c095 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deploybot-merge-queue" -version = "0.2.9" +version = "0.2.10" 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 caed755..fb3c508 100644 --- a/skills/deploybot/SKILL.md +++ b/skills/deploybot/SKILL.md @@ -71,6 +71,10 @@ 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. +Genuine repair blocks may hold overlapping ready work for the configured bounded +repair window, but they remain merge-ineligible until the trusted source agent +resumes the freshly reviewed exact head. + 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 f4953d6..76694a2 100644 --- a/skills/manage-merge-queue/SKILL.md +++ b/skills/manage-merge-queue/SKILL.md @@ -43,6 +43,10 @@ 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. +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. + 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 diff --git a/src/agent_merge_queue/__init__.py b/src/agent_merge_queue/__init__.py index 1ab0f0b..ee2ee1d 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.9" +__version__ = "0.2.10" diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index d559a5a..393f389 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -14,7 +14,7 @@ import time from concurrent.futures import ThreadPoolExecutor from dataclasses import asdict, dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Callable, Iterable from urllib.parse import quote @@ -639,6 +639,40 @@ def repair_marker_is_transitional(marker: dict[str, Any] | None) -> bool: return bool(reasons) and not any(reason_requires_repair(value) for value in reasons) +def repair_overlap_hold_active( + client: "GitHub", + entry: "QueueEntry", + intent: dict[str, Any], + repair: dict[str, Any] | None, + *, + now: str | None = None, +) -> bool: + """Keep genuine repairs in overlap scheduling without authorizing a merge.""" + if not repair or repair_marker_is_transitional(repair): + return False + if client.config.blocked_label not in entry.labels: + return False + if str(repair.get("intent_id") or "") != str(intent.get("intent_id") or ""): + return False + if str(repair.get("pull_request") or "") != str(entry.number): + return False + created_at = parse_time( + str(repair.get("hold_started_at") or repair.get("created_at") or "") + ) + current = parse_time(now or utc_now()) + if ( + created_at is None + or current is None + or created_at.tzinfo is None + or current.tzinfo is None + ): + return False + expires_at = created_at + timedelta( + minutes=client.config.pipeline.repair_hold_minutes + ) + return current <= expires_at + + @dataclass class QueueEntry: number: int @@ -662,6 +696,7 @@ class QueueEntry: reasons: list[str] | None = None priority_at: str | None = None integration_batch_id: str | None = None + repair_overlap_hold: bool = False def classify( self, @@ -1872,44 +1907,30 @@ def merge( self, number: int, head_sha: str, - *, - authorization_entry: QueueEntry | None = None, ) -> str: - if authorization_entry is None: - authorization = self._json( - "pr", - "view", - str(number), - "--repo", - self.repository, - "--json", - "headRefOid,isDraft,labels,state", - ) - labels = { - 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") - if authorization.get("isDraft"): - 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") - marker = queue_marker_for_client(self, self.comments(number)) - integration_batch_id = ( - str(marker.get("integration_batch_id") or "") if marker else "" - ) - else: - labels = set(authorization_entry.labels) - if authorization_entry.is_draft: - raise QueueError(f"PR #{number} returned to draft before merge") - if authorization_entry.head_sha != head_sha: - raise QueueError(f"PR #{number} changed immediately before merge") - marker = { - "state": authorization_entry.queue_state, - "head_sha": authorization_entry.queued_head_sha, - } - integration_batch_id = authorization_entry.integration_batch_id or "" + authorization = self._json( + "pr", + "view", + str(number), + "--repo", + self.repository, + "--json", + "headRefOid,isDraft,labels,state", + ) + labels = { + 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") + if authorization.get("isDraft"): + 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") + marker = queue_marker_for_client(self, self.comments(number)) + integration_batch_id = ( + str(marker.get("integration_batch_id") or "") if marker else "" + ) if self.config.queue_label not in labels or self.config.blocked_label in labels: raise QueueError(f"PR #{number} queue authorization was revoked") if ( @@ -2540,12 +2561,26 @@ def record_repair( previous and previous.get("head_sha") == entry.head_sha and previous.get("reason") == reason + and previous.get("intent_id") == (intent or {}).get("intent_id") ): return previous + created_at = utc_now() + hold_started_at = created_at + if ( + previous + and previous.get("intent_id") == (intent or {}).get("intent_id") + and not repair_marker_is_transitional(previous) + ): + hold_started_at = str( + previous.get("hold_started_at") + or previous.get("created_at") + or created_at + ) payload = { "base_sha": client.base_sha(), - "created_at": utc_now(), + "created_at": created_at, "head_sha": entry.head_sha, + "hold_started_at": hold_started_at, "intent_id": (intent or {}).get("intent_id"), "provider": (intent or {}).get("provider"), "pull_request": entry.number, @@ -2559,6 +2594,8 @@ def record_repair( labels = client.labels(entry.number) if client.config.blocked_label not in labels: client.add_label(entry.number, client.config.blocked_label) + if client.config.blocked_label not in entry.labels: + entry.labels.append(client.config.blocked_label) if payload.get("provider") and payload.get("thread_id"): client.record_thread( ThreadRecord( @@ -2692,6 +2729,17 @@ def evaluate( known_comments=comments, defer_paths_until_ready=True, ) + repair = latest_payload( + comments, + REPAIR_MARKER, + coordinator_logins(client), + ) + entry.repair_overlap_hold = repair_overlap_hold_active( + client, + entry, + intent, + repair, + ) if ( client.config.pipeline.intent_scope == "head" and intent.get("requested_head") != entry.head_sha @@ -2700,18 +2748,25 @@ def evaluate( "deploy intent is bound to an older head; the trusted source agent " "must run deploybot refresh-request after fresh gates pass" ) - record_repair(client, entry, intent, reason) + repair = record_repair(client, entry, intent, reason) + entry.repair_overlap_hold = repair_overlap_hold_active( + client, + entry, + intent, + repair, + ) return "blocked", {"number": number, "reason": reason}, entry if client.config.blocked_label in entry.labels: - repair = latest_payload( - comments, - REPAIR_MARKER, - coordinator_logins(client), - ) if repair_marker_is_transitional(repair): if deployment_repair_required(entry): reason = "; ".join(entry.reasons or ["blocked"]) - record_repair(client, entry, intent, reason) + repair = record_repair(client, entry, intent, reason) + entry.repair_overlap_hold = repair_overlap_hold_active( + client, + entry, + intent, + repair, + ) return "blocked", {"number": number, "reason": reason}, entry client.remove_label(number, client.config.blocked_label) entry.labels = [ @@ -2744,7 +2799,13 @@ def evaluate( return "ready", None, entry if deployment_repair_required(entry): reason = "; ".join(entry.reasons or ["blocked"]) - record_repair(client, entry, intent, reason) + repair = record_repair(client, entry, intent, reason) + entry.repair_overlap_hold = repair_overlap_hold_active( + client, + entry, + intent, + repair, + ) return "blocked", {"number": number, "reason": reason}, entry return ( "waiting", @@ -3528,11 +3589,7 @@ def command_merge( f"batch {batch_id} predates the current queue authorization" ) - merge_sha = client.merge( - number, - fresh.head_sha, - authorization_entry=fresh, - ) + merge_sha = client.merge(number, fresh.head_sha) merged_comments = client.comments(number) intent = latest_intent(merged_comments, client.trusted_logins) integration = latest_payload( @@ -4255,9 +4312,15 @@ def should_settle_batch(client: GitHub, entries: list[QueueEntry]) -> bool: for entry in entries ) has_near_ready = any( - client.config.queue_label not in entry.labels - and client.config.blocked_label not in entry.labels - and entry.state == "waiting" + ( + entry.repair_overlap_hold + and client.config.blocked_label in entry.labels + ) + or ( + client.config.queue_label not in entry.labels + and client.config.blocked_label not in entry.labels + and entry.state == "waiting" + ) for entry in entries ) return has_ready and has_near_ready @@ -4279,9 +4342,15 @@ def near_ready_overlap_holds( waiting = { entry.number: entry for entry in entries - if entry.state == "waiting" - and client.config.queue_label not in entry.labels - and client.config.blocked_label not in entry.labels + if ( + entry.repair_overlap_hold + and client.config.blocked_label in entry.labels + ) + or ( + entry.state == "waiting" + and client.config.queue_label not in entry.labels + and client.config.blocked_label not in entry.labels + ) } if not ready or not waiting: return {} diff --git a/src/agent_merge_queue/config.py b/src/agent_merge_queue/config.py index 416d6db..18cc2cb 100644 --- a/src/agent_merge_queue/config.py +++ b/src/agent_merge_queue/config.py @@ -47,6 +47,7 @@ class PipelineConfig: batch_settle_seconds: int ci_failure_grace_seconds: int promotion_workers: int + repair_hold_minutes: int ready_to_merge_target_minutes: int merge_to_live_target_minutes: int auto_promote: bool @@ -122,6 +123,7 @@ class QueueConfig: batch_settle_seconds = 15 ci_failure_grace_seconds = 90 promotion_workers = 4 +repair_hold_minutes = 60 ready_to_merge_target_minutes = 15 merge_to_live_target_minutes = 10 auto_promote = true @@ -445,6 +447,11 @@ def parse_config(payload: dict[str, Any]) -> QueueConfig: "pipeline.promotion_workers", 4, ), + repair_hold_minutes=_positive_int( + pipeline.get("repair_hold_minutes"), + "pipeline.repair_hold_minutes", + 60, + ), ready_to_merge_target_minutes=_positive_int( pipeline.get("ready_to_merge_target_minutes"), "pipeline.ready_to_merge_target_minutes", diff --git a/tests/test_cli.py b/tests/test_cli.py index 7a0962b..7224f2e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -52,6 +52,8 @@ queue_from_intent, queue_timestamp, reconcile_externally_merged_threads, + record_repair, + repair_overlap_hold_active, reusable_batch, settle_integration_checks, should_settle_batch, @@ -428,6 +430,132 @@ def test_overlap_mode_holds_only_ready_members_of_near_ready_components( self.assertEqual(holds, {1: [3]}) client.changed_paths.assert_called_once_with(3) + def test_overlap_mode_holds_ready_peer_for_bounded_genuine_repair( + self, + ) -> None: + config = parse_config( + { + "queue": {"required_checks": ["CI"], "trusted_actors": ["trusted"]}, + "integration": {"mode": "overlap"}, + } + ) + ready = entry(1, "shared.py") + repairing = entry(2, state="blocked") + repairing.labels = ["deploy-requested", "merge-queue-blocked"] + repairing.repair_overlap_hold = True + client = Mock() + client.config = config + client.coordinator_logins = {"coordinator"} + client.changed_paths.return_value = (["shared.py"], []) + client.comments.return_value = [] + + self.assertTrue(should_settle_batch(client, [ready, repairing])) + self.assertEqual( + near_ready_overlap_holds(client, [ready, repairing]), + {1: [2]}, + ) + + def test_repair_overlap_hold_is_bounded_and_intent_scoped(self) -> None: + value = entry(7, state="blocked") + value.labels = ["deploy-requested", "merge-queue-blocked"] + intent = {"intent_id": "intent-7"} + repair = { + "created_at": "2026-06-21T12:00:00Z", + "head_sha": value.head_sha, + "intent_id": "intent-7", + "pull_request": 7, + "reason": "pull request conflicts with main", + } + client = Mock() + client.config = parse_config( + { + "queue": {"required_checks": ["CI"], "trusted_actors": ["trusted"]}, + "pipeline": {"repair_hold_minutes": 60}, + } + ) + + self.assertTrue( + repair_overlap_hold_active( + client, + value, + intent, + repair, + now="2026-06-21T12:59:59Z", + ) + ) + self.assertFalse( + repair_overlap_hold_active( + client, + value, + intent, + repair, + now="2026-06-21T13:00:01Z", + ) + ) + self.assertFalse( + repair_overlap_hold_active( + client, + value, + {"intent_id": "intent-new"}, + repair, + now="2026-06-21T12:30:00Z", + ) + ) + renewed = { + **repair, + "created_at": "2026-06-21T12:50:00Z", + "hold_started_at": "2026-06-21T11:45:00Z", + } + self.assertFalse( + repair_overlap_hold_active( + client, + value, + intent, + renewed, + now="2026-06-21T12:50:01Z", + ) + ) + + @patch("agent_merge_queue.cli.utc_now", return_value="2026-06-21T13:00:00Z") + def test_record_repair_does_not_reuse_marker_from_previous_intent( + self, + _utc_now: Mock, + ) -> None: + value = entry(7, state="blocked") + previous = { + "created_at": "2026-06-21T12:00:00Z", + "head_sha": value.head_sha, + "hold_started_at": "2026-06-21T12:00:00Z", + "intent_id": "intent-old", + "pull_request": 7, + "reason": "pull request conflicts with main", + } + client = Mock() + client.config = CONFIG + client.repository = "example/repo" + client.coordinator_logins = {"coordinator"} + client.comments.return_value = [ + { + "body": repair_body(previous), + "created_at": previous["created_at"], + "user": {"login": "coordinator"}, + } + ] + client.base_sha.return_value = "b" * 40 + client.labels.return_value = [] + + repair = record_repair( + client, + value, + {"intent_id": "intent-new"}, + str(previous["reason"]), + ) + + self.assertEqual(repair["intent_id"], "intent-new") + self.assertEqual(repair["hold_started_at"], "2026-06-21T13:00:00Z") + self.assertIn("merge-queue-blocked", value.labels) + client.comment.assert_called_once_with(7, repair_body(repair)) + def test_reconciles_an_externally_merged_requested_thread(self) -> None: head_sha = "a" * 40 merge_sha = "m" * 40 @@ -1178,7 +1306,6 @@ def test_frozen_merge_fast_path_never_rescans_the_whole_queue(self) -> None: client.merge.assert_called_once_with( 1, value.head_sha, - authorization_entry=value, ) def test_frozen_merge_accepts_the_same_head_already_integrated_externally( @@ -2141,25 +2268,6 @@ def test_final_merge_rechecks_durable_authorization(self) -> None: client.merge(1, value.head_sha) self.assertEqual(client._json.call_count, 1) - def test_prevalidated_merge_avoids_duplicate_authorization_reads(self) -> None: - value = entry(1) - client = object.__new__(GitHub) - client.config = CONFIG - client.repository = "example/repo" - client.trusted_logins = {"trusted"} - client._json = Mock(return_value={"merged": True, "sha": "m" * 40}) - client.comments = Mock() - - result = client.merge( - 1, - value.head_sha, - authorization_entry=value, - ) - - self.assertEqual(result, "m" * 40) - client.comments.assert_not_called() - 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 @@ -2405,6 +2513,52 @@ def test_promote_never_auto_resumes_a_repair_block(self) -> None: self.assertIn("deploybot resume", result["waiting"][0]["reasons"][0]) client.add_label.assert_not_called() + def test_promote_does_not_hold_resumed_repair_against_itself(self) -> None: + ready = entry(1) + ready.labels = ["deploy-requested", "merge-queue"] + 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=ready.head_sha, + ), + } + repair_comment = { + "id": 2, + "created_at": "2026-06-20T00:01:00Z", + "user": {"login": "coordinator"}, + "body": repair_body( + { + "created_at": "2026-06-20T00:01:00Z", + "head_sha": ready.head_sha, + "hold_started_at": "2026-06-20T00:01:00Z", + "intent_id": "intent-1", + "pull_request": 1, + "reason": "pull request conflicts with main", + } + ), + } + client = Mock() + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.intent_numbers.return_value = [1] + client.active_integration_sources.return_value = set() + client.comments.return_value = [intent_comment, repair_comment] + client.snapshot.return_value = ready + + captured: list[QueueEntry] = [] + with redirect_stdout(io.StringIO()): + command_promote(client, captured_entries=captured) + + self.assertFalse(captured[0].repair_overlap_hold) + self.assertEqual(near_ready_overlap_holds(client, captured), {}) + def test_promote_clears_a_transitional_draft_block(self) -> None: ready = entry(1) ready.labels = ["deploy-requested", "merge-queue-blocked"] @@ -2478,12 +2632,14 @@ def test_promote_records_stale_intent_as_thread_repair(self) -> None: client.labels.return_value = {"deploy-requested"} client.base_sha.return_value = "b" * 40 + captured: list[QueueEntry] = [] with redirect_stdout(io.StringIO()): - result = command_promote(client) + result = command_promote(client, captured_entries=captured) self.assertEqual(result["promoted"], []) self.assertEqual(result["blocked"][0]["number"], 1) self.assertIn("older head", result["blocked"][0]["reason"]) + self.assertTrue(captured[0].repair_overlap_hold) client.add_label.assert_called_with(1, "merge-queue-blocked") self.assertEqual(client.record_thread.call_args.args[0].phase, "blocked") diff --git a/tests/test_config.py b/tests/test_config.py index e031d43..0ae1ae0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -186,6 +186,7 @@ def test_parses_pipeline_integration_and_health_policy(self) -> None: "batch_settle_seconds": 30, "ci_failure_grace_seconds": 45, "promotion_workers": 3, + "repair_hold_minutes": 90, "auto_promote": False, "verifications": [ { @@ -202,6 +203,7 @@ def test_parses_pipeline_integration_and_health_policy(self) -> None: self.assertEqual(config.pipeline.batch_settle_seconds, 30) 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.auto_promote) self.assertEqual(config.pipeline.verifications[0].expected_status, 200) self.assertEqual(config.integration.mode, "all") @@ -247,6 +249,16 @@ def test_rejects_invalid_integration_mode_and_boolean(self) -> None: "pipeline": {"batch_settle_seconds": -1}, } ) + with self.assertRaisesRegex(ConfigError, "repair_hold_minutes"): + parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "pipeline": {"repair_hold_minutes": 0}, + } + ) if __name__ == "__main__": diff --git a/tests/test_docs.py b/tests/test_docs.py index a9df7a0..46a3144 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -130,6 +130,7 @@ def test_reference_names_every_policy_field(self) -> None: "batch_settle_seconds", "ci_failure_grace_seconds", "promotion_workers", + "repair_hold_minutes", "ready_to_merge_target_minutes", "merge_to_live_target_minutes", "auto_promote", @@ -178,6 +179,18 @@ def test_reference_names_every_action_input(self) -> None: with self.subTest(name=name): self.assertIn(f"`{name}`", reference) + for match in re.finditer( + r"^ (?P[a-z][a-z0-9_]*):\n(?P(?: .*\n)+)", + inputs, + re.MULTILINE, + ): + default = re.search(r"^ default: (.+)$", match.group("body"), re.MULTILINE) + if default: + self.assertIn( + f"| `{match.group('name')}` | `{default.group(1)}` |", + reference, + ) + def test_reference_version_matches_package(self) -> None: reference = REFERENCE.read_text(encoding="utf-8") self.assertIn(f"DeployBot v{__version__}", reference) diff --git a/tests/test_skill.py b/tests/test_skill.py index 4e373ca..bb394d3 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 = "b06346119a29d0be1c25d1643fdc06a03f7b94a9" +RELEASE_COMMIT = "78208849bb743d649a6e3e5e1c452b9d21ec7e2b" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"