diff --git a/README.md b/README.md index 8832c27..9b2541b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,11 @@ python3 -m pip install \ deploybot init ``` +`deploybot init` writes the safe starter policy. The annotated +[example policy](.mergequeue.example.toml) shows every supported section, and +the [CLI, MCP, policy, and Action reference](docs/reference.md) lists every +current command, tool, option, and configuration field. + Invoke the bundled `$deploybot` skill to inspect or operate the queue. Typical requests include “show the delivery pipeline,” “why is PR 42 blocked?”, and “deploy this PR.” Status and diagnostics are read-only: @@ -218,37 +223,49 @@ commit SHA so an older score can never authorize a replacement head. The bundled MCP configurations launch the pinned public release with `uvx`. The `mergeq` and `mergeq-mcp` command aliases remain for compatibility. -## Commands +## Command overview + +All commands accept the global `--config PATH` and `--repository OWNER/REPO` +options before the subcommand. A missing pull-request selector resolves the PR +for the current branch. ```text +deploybot init [--force] +deploybot ensure-labels deploybot status --json deploybot plan --json deploybot doctor --json -deploybot request --provider codex --thread-id THREAD -deploybot refresh-request +deploybot inspect [PR] --json +deploybot thread update --provider CLIENT --thread-id ID --phase PHASE [metadata] +deploybot request [PR] [--provider CLIENT] [--thread-id ID] [--thread-url URL] +deploybot cancel-request [PR] +deploybot refresh-request [PR] +deploybot enqueue [PR] deploybot promote -deploybot react --follow -deploybot resume -deploybot integrate --all -deploybot follow --json +deploybot freeze --json +deploybot drain --json +deploybot react [--follow] [--dispatch-ci] [--timeout SECONDS] +deploybot integrate [--all] +deploybot follow [--timeout SECONDS] [--poll SECONDS] [--json] deploybot metrics --json deploybot pause --reason "main CI failed" deploybot unpause -deploybot thread update --provider codex --thread-id THREAD --phase ready --pr 42 -deploybot inspect --json -deploybot enqueue -deploybot freeze --json -deploybot drain --json -deploybot block --reason "..." -deploybot unblock -deploybot dequeue --reason "..." -deploybot merge --batch +deploybot block [PR] --reason "..." +deploybot unblock [PR] +deploybot resume [PR] +deploybot dequeue [PR] --reason "..." +deploybot merge [PR] --batch BATCH_ID ``` `status` is the read-only full delivery view. `plan` is the narrower queue-only view. `doctor` verifies CLI auth, policy, labels, actors, check names, and branch protection without changing the repository. +The MCP server exposes named equivalents for the operational commands, plus +explicit `repository` and `config` arguments on every tool. See the +[complete reference](docs/reference.md#mcp-tools) for the exact mapping and +arguments. + `drain` merges only independent, green, exact-head-reviewed PRs. Integration mode `overlap` creates one cumulative PR for shared source; mode `all` routes the whole frozen batch through one cumulative PR before `main`. DeployBot never diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..b5872f0 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,206 @@ +# DeployBot reference + +This reference describes the CLI, MCP server, policy file, and GitHub Action in +DeployBot v0.2.8. GitHub labels and authenticated comments are the durable state; +the CLI and MCP tools are two interfaces to the same operations. + +## CLI + +```text +deploybot [--config PATH] [--repository OWNER/REPO] COMMAND ... +``` + +`--config` defaults to `MERGE_QUEUE_CONFIG`, then `.mergequeue.toml` in the +current directory. `--repository` defaults to the repository resolved by the +GitHub CLI. `--version` prints the installed version. Wherever `[PR]` appears, +the selector may be a pull-request number, URL, or branch; when omitted, +DeployBot resolves the pull request for the current branch. + +### Setup and read-only inspection + +| Command | Purpose | +| --- | --- | +| `deploybot init [--force]` | Write a safe starter policy. Existing files are preserved unless `--force` is supplied. | +| `deploybot ensure-labels` | Create or refresh the configured queue, blocked, intent, pause, and registry labels. | +| `deploybot doctor [--json]` | Check authentication, policy, labels, actors, checks, workflows, and branch protection without changing repository state. | +| `deploybot status [--json]` | Read active thread metadata, PR stages, deploy intent, queue state, exact-main CI, deployment, and pipeline control state. | +| `deploybot plan [--json]` | Read the ordered queue, dependencies, blockers, and source-overlap groups. | +| `deploybot inspect [PR] [--json]` | Evaluate one exact PR head without granting merge authority. | +| `deploybot metrics [--limit N] [--json]` | Summarize p50, p95, and maximum delivery timings for recent merged PRs. The default limit is 25. | + +`status`, `plan`, `inspect`, `doctor`, and `metrics` are read-only. In +particular, do not use `freeze` as a status command because it writes a durable +batch marker. + +### Thread and deploy intent + +| Command | Purpose | +| --- | --- | +| `deploybot thread update --provider CLIENT --thread-id ID --phase PHASE [--title TEXT] [--branch NAME] [--pr NUMBER] [--url URL]` | Publish metadata-only thread state. Valid phases are `working`, `pr-draft`, `pr-review`, `ready`, `deploy-requested`, `queued`, `merged`, `blocked`, `completed`, and `abandoned`. | +| `deploybot request [PR] [--provider CLIENT] [--thread-id ID] [--thread-url URL]` | Record the user's durable deploy intent for the current exact head, even while gates are pending. | +| `deploybot cancel-request [PR]` | Cancel an unmerged durable deploy request. | +| `deploybot refresh-request [PR]` | Bind existing user intent to a freshly reviewed replacement head. | +| `deploybot enqueue [PR]` | Directly queue one exact reviewed head. Prefer `request` for the normal durable-intent flow. | + +Only the user's exact `deploy` instruction authorizes `request` or `enqueue` for +that thread. A source agent may use `refresh-request` after the replacement head +has fresh evidence; the user does not need to repeat the instruction. + +### Coordinator operations + +| Command | Purpose | +| --- | --- | +| `deploybot promote` | Promote every ready, exact-head-bound deploy request into the queue. | +| `deploybot freeze [--json]` | Persist one exact queue batch and its head SHAs. | +| `deploybot drain [--json]` | Freeze as needed and merge independent ready entries in the active batch. | +| `deploybot react [--follow] [--dispatch-ci] [--timeout SECONDS]` | Run the event-driven promotion, batching, merge, and optional release-follow flow. The timeout defaults to 1800 seconds. | +| `deploybot integrate [--all]` | Scaffold a cumulative integration PR for overlap groups, or the whole frozen batch with `--all`. | +| `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. | + +Only a configured coordinator should run these operations. `react +--dispatch-ci` is required when the merge identity does not trigger ordinary +push CI, as with GitHub's built-in workflow token. + +### Repair and low-level queue operations + +| Command | Purpose | +| --- | --- | +| `deploybot block [PR] --reason TEXT` | Block one queued PR with a concrete reason while independent work continues. | +| `deploybot unblock [PR]` | Clear a resolved queue blocker without the additional checks performed by `resume`. | +| `deploybot resume [PR]` | Atomically verify a repaired head, clear its blocker, requeue it, and emit a wake-up event. | +| `deploybot dequeue [PR] --reason TEXT` | Revoke merge authority for one queued PR. | +| `deploybot merge [PR] --batch BATCH_ID` | Merge one PR only under the trusted exact batch returned by `freeze`. Normally `drain` owns this step. | + +## MCP tools + +Run `deploybot-mcp` after installing the `mcp` extra. Every tool accepts the +optional `repository` (`owner/name`) and `config` arguments. The remaining +arguments shown below are the tool-specific arguments. + +| Tool | CLI equivalent | Tool-specific arguments | +| --- | --- | --- | +| `queue_plan` | `plan --json` | none | +| `pipeline_status` | `status --json` | none | +| `diagnose` | `doctor --json` | none | +| `inspect_pull_request` | `inspect PR --json` | `pull_request` | +| `enqueue_pull_request` | `enqueue PR` | `pull_request` | +| `request_deployment` | `request PR` | `pull_request`; optional `provider`, `thread_id`, `thread_url` | +| `cancel_deployment_request` | `cancel-request PR` | `pull_request` | +| `refresh_deployment_request` | `refresh-request PR` | `pull_request` | +| `promote_deployment_requests` | `promote` | none | +| `freeze_queue` | `freeze --json` | none | +| `drain_queue` | `drain --json` | none | +| `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` | +| `delivery_metrics` | `metrics --json` | optional `limit` | +| `update_agent_thread` | `thread update` | `provider`, `thread_id`, `phase`; optional `pull_request`, `title`, `branch`, `url` | +| `block_pull_request` | `block PR` | `pull_request`, `reason` | +| `unblock_pull_request` | `unblock PR` | `pull_request` | +| `resume_pull_request` | `resume PR` | `pull_request` | +| `dequeue_pull_request` | `dequeue PR` | `pull_request`, `reason` | + +The MCP server deliberately does not expose the low-level `merge`, `pause`, +`unpause`, `ensure-labels`, or `init` commands. Use the CLI for those operations. + +## Policy file + +Run `deploybot init` to create `.mergequeue.toml`, or start from the annotated +[example policy](../.mergequeue.example.toml). At least one required check or +review provider is mandatory. + +### `[queue]` + +| Field | Default or constraint | +| --- | --- | +| `base_branch` | `"main"` | +| `queue_label` | `"merge-queue"` | +| `blocked_label` | `"merge-queue-blocked"` | +| `merge_method` | `"merge"`; allowed: `merge`, `squash`, `rebase` | +| `required_checks` | Exact check display names; may be empty only when a review provider is configured. | +| `dependency_directive` | `"Merge-queue-depends-on"` | +| `trusted_actors` | Required exact GitHub logins allowed to create deploy intent or queue markers. `@repository-owner` resolves to the owner for user-owned repositories. Never include `github-actions[bot]`. | +| `coordinator_actors` | Exact logins allowed to write batch and controller records. Defaults to `trusted_actors`; workflows commonly add `github-actions[bot]`. | + +### `[files]` + +| Field | Default or constraint | +| --- | --- | +| `generated_paths` | Exact generated file paths ignored as hand-edited overlap. Default: empty. | +| `generated_version_paths` | Glob patterns for generated version-only files. Default: empty. | +| `asset_version_pattern` | Regular expression used to recognize generated asset-version changes. Default: `\?v=[0-9a-f]{12}`. | + +### `[[review.providers]]` + +`kind` is `github-approvals`, `check`, or `bot`; `name` is the display name. +Provider fields are: + +| Field | Applies to | Default or constraint | +| --- | --- | --- | +| `check_name` | `check`, `bot` | Required for `check`; exact check display name. | +| `login` | `bot` | Required exact bot login. | +| `allowed_reviewers` | `github-approvals` | Required exact reviewer logins. | +| `minimum_approvals` | `github-approvals` | Positive integer; default 1. | +| `minimum_score` | `bot` | Optional non-negative threshold. | +| `score_pattern` | `bot` | Required with `minimum_score`; regex with a numeric capture group. | +| `require_formal_review` | `bot` | Default `false`. | +| `require_resolved_threads` | `bot` | Default `false`; this is not sufficient positive evidence by itself. | + +### `[pipeline]` and `[[pipeline.verifications]]` + +| Field | Default or constraint | +| --- | --- | +| `intent_label` | `"deploy-requested"` | +| `pause_label` | `"deploybot-paused"` | +| `registry_label` | `"deploybot-registry"` | +| `registry_title` | `"DeployBot delivery registry"` | +| `thread_active_hours` | Positive integer; default 72. | +| `ci_workflows` | Workflow names followed as exact-main CI. Default: `["CI"]`. | +| `deploy_workflows` | Deployment workflow names. Default: `["Deploy"]`. | +| `ready_to_merge_target_minutes` | Positive timing target; default 15. | +| `merge_to_live_target_minutes` | Positive timing target; default 10. | +| `auto_promote` | Default `true`. | +| `intent_scope` | Currently must be `"head"`. | +| `pause_on_failure` | Default `true`. | +| `webhook_url_env` | Optional environment-variable name containing a best-effort event webhook URL. GitHub remains authoritative if notification fails. | +| `verifications` | Optional array of post-deployment HTTP verification tables. Default: empty. | +| `name` | Required health-check display name in each `pipeline.verifications` entry. | +| `url` | Required URL checked after deployment in each `pipeline.verifications` entry. | +| `expected_status` | HTTP status from 100 through 599; default 200. | + +### `[integration]` + +| Field | Default or constraint | +| --- | --- | +| `mode` | `"manual"`; allowed: `manual`, `overlap`, `all`. | +| `branch_prefix` | `"deploybot/integration"` | +| `title_prefix` | `"DeployBot integration"` | + +## GitHub Action + +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. | +| `dispatch_ci` | `"true"` | Dispatch configured CI after a merge made with `github.token`. | +| `timeout` | `"1800"` | Release-follow timeout in seconds. | + +The workflow needs `contents: write`, `pull-requests: write`, `checks: read`, +`issues: write`, and `actions: write`. Use the event filters and concurrency +group in [`examples/github-workflow.yml`](../examples/github-workflow.yml), pin +the Action to a reviewed full commit SHA, and keep its `workflow_run.workflows` +list aligned with `pipeline.ci_workflows`. + +## Authentication and environment + +- Authenticate the local CLI with `gh auth login`. DeployBot inherits the + GitHub CLI identity and permissions. +- `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. diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 0000000..ba8b105 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import argparse +import ast +import re +import unittest +from dataclasses import fields +from pathlib import Path + +from agent_merge_queue.cli import build_parser +from agent_merge_queue.config import ( + IntegrationConfig, + PipelineConfig, + QueueConfig, + ReviewProviderConfig, + VerificationConfig, +) +from agent_merge_queue import __version__ + + +ROOT = Path(__file__).resolve().parents[1] +REFERENCE = ROOT / "docs" / "reference.md" + + +class DocumentationTest(unittest.TestCase): + def test_readme_links_the_complete_reference(self) -> None: + readme = (ROOT / "README.md").read_text(encoding="utf-8") + self.assertIn("docs/reference.md", readme) + self.assertTrue(REFERENCE.is_file()) + + def test_reference_names_every_cli_command(self) -> None: + parser = build_parser() + subparsers = next( + action + for action in parser._actions + if isinstance(action, argparse._SubParsersAction) + ) + reference = REFERENCE.read_text(encoding="utf-8") + for command in subparsers.choices: + with self.subTest(command=command): + self.assertIn(f"deploybot {command}", reference) + + parsers = [parser, *subparsers.choices.values()] + thread_parser = subparsers.choices["thread"] + thread_subparsers = next( + action + for action in thread_parser._actions + if isinstance(action, argparse._SubParsersAction) + ) + parsers.extend(thread_subparsers.choices.values()) + options = { + option + for current in parsers + for action in current._actions + for option in action.option_strings + if option not in {"-h", "--help"} + } + for option in options: + with self.subTest(option=option): + self.assertIn(option, reference) + + def test_reference_names_every_mcp_tool(self) -> None: + source = (ROOT / "src" / "agent_merge_queue" / "mcp_server.py").read_text( + encoding="utf-8" + ) + tree = ast.parse(source) + tools: dict[str, set[str]] = {} + for node in tree.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for decorator in node.decorator_list: + if ( + isinstance(decorator, ast.Call) + and isinstance(decorator.func, ast.Attribute) + and isinstance(decorator.func.value, ast.Name) + and decorator.func.value.id == "mcp" + and decorator.func.attr == "tool" + ): + tools[node.name] = { + argument.arg + for argument in node.args.args + if argument.arg not in {"repository", "config"} + } + reference = REFERENCE.read_text(encoding="utf-8") + self.assertTrue(tools) + for tool, arguments in tools.items(): + with self.subTest(tool=tool): + self.assertIn(f"`{tool}`", reference) + for argument in arguments: + with self.subTest(tool=tool, argument=argument): + self.assertIn(f"`{argument}`", reference) + + def test_reference_names_every_policy_field(self) -> None: + documented = { + "queue": { + "base_branch", + "queue_label", + "blocked_label", + "merge_method", + "required_checks", + "dependency_directive", + "trusted_actors", + "coordinator_actors", + }, + "files": { + "generated_paths", + "generated_version_paths", + "asset_version_pattern", + }, + "review": { + "kind", + "name", + "check_name", + "login", + "allowed_reviewers", + "minimum_approvals", + "minimum_score", + "score_pattern", + "require_formal_review", + "require_resolved_threads", + }, + "pipeline": { + "intent_label", + "pause_label", + "registry_label", + "registry_title", + "thread_active_hours", + "ci_workflows", + "deploy_workflows", + "ready_to_merge_target_minutes", + "merge_to_live_target_minutes", + "auto_promote", + "intent_scope", + "pause_on_failure", + "webhook_url_env", + "verifications", + "name", + "url", + "expected_status", + }, + "integration": {"mode", "branch_prefix", "title_prefix"}, + } + queue_fields = {field.name for field in fields(QueueConfig)} - { + "review_providers", + "pipeline", + "integration", + } + self.assertEqual(documented["queue"] | documented["files"], queue_fields) + self.assertEqual( + documented["review"], + {field.name for field in fields(ReviewProviderConfig)}, + ) + self.assertEqual( + documented["pipeline"], + {field.name for field in fields(PipelineConfig)} + | {field.name for field in fields(VerificationConfig)}, + ) + self.assertEqual( + documented["integration"], + {field.name for field in fields(IntegrationConfig)}, + ) + reference = REFERENCE.read_text(encoding="utf-8") + for section, section_fields in documented.items(): + for field in section_fields: + with self.subTest(section=section, field=field): + self.assertIn(f"`{field}`", reference) + + def test_reference_names_every_action_input(self) -> None: + action = (ROOT / "action.yml").read_text(encoding="utf-8") + inputs = action.split("inputs:\n", 1)[1].split("runs:\n", 1)[0] + names = re.findall(r"^ ([a-z][a-z0-9_]*):$", inputs, re.MULTILINE) + reference = REFERENCE.read_text(encoding="utf-8") + self.assertTrue(names) + for name in names: + with self.subTest(name=name): + self.assertIn(f"`{name}`", reference) + + def test_reference_version_matches_package(self) -> None: + reference = REFERENCE.read_text(encoding="utf-8") + self.assertIn(f"DeployBot v{__version__}", reference) + + +if __name__ == "__main__": + unittest.main()