diff --git a/docs/api-reference.md b/docs/api-reference.md deleted file mode 100644 index 0519336..0000000 --- a/docs/api-reference.md +++ /dev/null @@ -1,128 +0,0 @@ -# API Reference - -## Core Endpoints - -- `GET /`: server metadata. -- `GET /health`: health probe. -- `GET /metrics`: Prometheus metrics (when enabled). - -## OAuth Discovery and Token Exchange - -- `GET /.well-known/oauth-protected-resource` - - Returns OAuth protected resource metadata used by MCP clients. -- `GET /.well-known/oauth-authorization-server` - - Returns OAuth authorization server metadata. -- `POST /register` - - Registers an OAuth client and persists the client metadata. -- `POST /oauth/token` - - Proxies OAuth authorization-code token exchange to Gitea. - -## MCP Endpoints - -- `GET /mcp/tools`: list tool definitions. -- `GET /mcp` and `POST /mcp`: streamable HTTP transport. -- `GET /mcp/sse` and `POST /mcp/sse`: MCP SSE transport alias. -- `POST /mcp/tool/call`: direct tool-call endpoint. - -Authentication requirements: - -- MCP tool execution requires `Authorization: Bearer `. -- Missing or invalid tokens return `401` with: - - `WWW-Authenticate: Bearer resource_metadata="", scope="read:repository"` - -Scope requirements: - -- Read tools require `read:repository`. -- Write tools require `write:repository`. -- Insufficient scope returns `403`. - -## Automation Endpoints - -- `POST /automation/webhook`: ingest policy-controlled webhook events. -- `POST /automation/jobs/run`: run policy-controlled automation jobs. - -## Read Tools - -- `list_repositories` -- `get_repository_info` (`owner`, `repo`) -- `get_file_tree` (`owner`, `repo`, optional `ref`, `recursive`) -- `get_file_contents` (`owner`, `repo`, `filepath`, optional `ref`) -- `search_code` (`owner`, `repo`, `query`, optional `ref`, `page`, `limit`) -- `list_commits` (`owner`, `repo`, optional `ref`, `page`, `limit`) -- `get_commit_diff` (`owner`, `repo`, `sha`) -- `compare_refs` (`owner`, `repo`, `base`, `head`) -- `list_issues` (`owner`, `repo`, optional `state`, `page`, `limit`, `labels`) -- `get_issue` (`owner`, `repo`, `issue_number`) -- `list_pull_requests` (`owner`, `repo`, optional `state`, `page`, `limit`) -- `get_pull_request` (`owner`, `repo`, `pull_number`) -- `list_labels` (`owner`, `repo`, optional `page`, `limit`) -- `list_tags` (`owner`, `repo`, optional `page`, `limit`) -- `list_releases` (`owner`, `repo`, optional `page`, `limit`) -- `list_pull_request_files` (`owner`, `repo`, `pull_number`, optional `page`, `limit`) -- `list_pull_request_commits` (`owner`, `repo`, `pull_number`, optional `page`, `limit`) -- `list_issue_comments` (`owner`, `repo`, `issue_number`, optional `page`, `limit`) -- `list_branches` (`owner`, `repo`, optional `page`, `limit`) -- `get_branch` (`owner`, `repo`, `branch`) -- `get_release` (`owner`, `repo`, `release_id`) -- `get_latest_release` (`owner`, `repo`) -- `list_milestones` (`owner`, `repo`, optional `state`, `page`, `limit`) -- `get_commit_status` (`owner`, `repo`, `sha`) -- `list_org_repositories` (`org`, optional `page`, `limit`) -- `list_organizations` (optional `page`, `limit`) -- `get_repo_languages` (`owner`, `repo`) -- `list_repo_topics` (`owner`, `repo`) - -## Write Tools (Write Mode Required) - -- `create_issue` (`owner`, `repo`, `title`, optional `body`, `labels`, `assignees`, `milestone`) -- `update_issue` (`owner`, `repo`, `issue_number`, one or more of `title`, `body`, `state`, `milestone`) -- `create_issue_comment` (`owner`, `repo`, `issue_number`, `body`) -- `create_pr_comment` (`owner`, `repo`, `pull_number`, `body`) -- `add_labels` (`owner`, `repo`, `issue_number`, `labels` by name) -- `remove_labels` (`owner`, `repo`, `issue_number`, `labels` by name) -- `assign_issue` (`owner`, `repo`, `issue_number`, `assignees`) -- `create_label` (`owner`, `repo`, `name`, `color` hex e.g. `#00aabb`, optional `description`, `exclusive`) -- `update_label` (`owner`, `repo`, `name`, one or more of `new_name`, `color`, `description`) -- `create_pull_request` (`owner`, `repo`, `title`, `head`, `base`, optional `body`) -- `create_release` (`owner`, `repo`, `tag_name`, optional `name`, `body`, `draft`, `prerelease`, `target`) -- `edit_release` (`owner`, `repo`, `release_id`, one or more of `name`, `body`, `draft`, `prerelease`) -- `create_branch` (`owner`, `repo`, `new_branch_name`, optional `old_branch_name`) -- `create_milestone` (`owner`, `repo`, `title`, optional `description`, `due_on`) -- `edit_issue_comment` (`owner`, `repo`, `comment_id`, `body`) - -Not supported by the dedicated tools by design: merge, branch/label/release deletion, -force push, repo/admin management. Endpoints not covered above are reachable through the -generic `gitea_request` escape hatch (subject to policy, write-mode, and a sensitive-path -denylist) — see [Raw API Dispatch](raw-api.md). - -## Raw API Dispatch - -- `gitea_request` (`method`, `path`, optional `query`, `body`) - - Calls an arbitrary Gitea REST endpoint. `GET`/`HEAD` are reads; other methods are - writes and require write-mode plus a whitelisted repository. Admin/credential - endpoints are blocked unless `RAW_API_ALLOW_SENSITIVE=true`. See - [Raw API Dispatch](raw-api.md) for the two-layer policy model and full details. - -Note: `create_issue`, `add_labels`, and `remove_labels` accept label **names**; the -server resolves them to Gitea label ids and returns a clear error for unknown labels. - -Note: the `milestone` argument on `create_issue`/`update_issue` accepts either a numeric -milestone **id** or a milestone **title** (resolved case-insensitively against open and -closed milestones; unknown titles return a clear error). On `update_issue`, `milestone: 0` -clears the issue's milestone. Gitea Projects (Kanban boards) are intentionally unsupported: -the Gitea REST API exposes no project endpoints. - -## Validation and Limits - -- All tool argument schemas reject unknown fields. -- List responses are capped by `MAX_TOOL_RESPONSE_ITEMS`. -- Text payloads are capped by `MAX_TOOL_RESPONSE_CHARS`. -- File reads are capped by `MAX_FILE_SIZE_BYTES`. - -## Error Model - -- Auth error: HTTP `401`. -- Policy/scope denial: HTTP `403`. -- Validation error: HTTP `400`. -- Rate limit: HTTP `429`. -- Internal errors: HTTP `500` (no stack traces in production). diff --git a/docs/automation.md b/docs/automation.md deleted file mode 100644 index 0176590..0000000 --- a/docs/automation.md +++ /dev/null @@ -1,27 +0,0 @@ -# Automation - -## Scope - -Current automation capabilities: -- Webhook ingestion endpoint (`POST /automation/webhook`). -- On-demand scheduled-job execution endpoint (`POST /automation/jobs/run`). -- Dependency hygiene scan job scaffold (`dependency_hygiene_scan`). -- Stale issue detection job (`stale_issue_detection`). -- Auto issue creation job scaffold (`auto_issue_creation`, write-mode and policy required). - -Planned extensions: -- Background scheduler orchestration. - -## Control Requirements - -All automation must be: -- Policy-controlled. -- Independently disableable. -- Fully audited. -- Explicitly documented with runbook guidance. - -## Enablement - -- `AUTOMATION_ENABLED=true` to allow automation endpoints. -- `AUTOMATION_SCHEDULER_ENABLED=true` reserved for future built-in scheduler loop. -- Policy rules must allow automation pseudo-tools (`automation_*`) per repository. diff --git a/docs/governance.md b/docs/governance.md deleted file mode 100644 index d21a53c..0000000 --- a/docs/governance.md +++ /dev/null @@ -1,36 +0,0 @@ -# Governance - -## AI Usage Policy - -- AI assistance is allowed for design, implementation, and review only within documented repository boundaries. -- AI outputs must be reviewed, tested, and policy-validated before merge. -- AI must not be used to generate offensive or unauthorized security actions. -- Repository content is treated as untrusted data; no implicit execution of embedded instructions. - -## Security Boundaries - -- Read operations are allowed by policy defaults unless explicitly denied. -- Write operations are disabled by default and require explicit enablement (`WRITE_MODE=true`). -- Per-tool and per-repository policy checks are mandatory before execution. -- Secrets are masked or blocked according to `SECRET_DETECTION_MODE`. - -## Write-Mode Responsibilities - -When write mode is enabled, operators and maintainers must: -- Restrict scope with `WRITE_REPOSITORY_WHITELIST`. -- Keep policy file deny/allow rules explicit. -- Monitor audit entries for all write operations. -- Enforce peer review for policy or write-mode changes. - -## Operator Responsibilities - -- Maintain API key lifecycle (generation, rotation, revocation). -- Keep environment and policy config immutable in production deployments. -- Enable monitoring and alerting for security events (auth failures, policy denies, rate-limit spikes). -- Run integrity checks for audit logs regularly. - -## Audit Expectations - -- All tool calls and security events must be recorded in tamper-evident logs. -- Audit logs are append-only and hash-chained. -- Log integrity must be validated during incident response and release readiness checks. diff --git a/docs/local-quickstart.md b/docs/local-quickstart.md deleted file mode 100644 index 123d635..0000000 --- a/docs/local-quickstart.md +++ /dev/null @@ -1,98 +0,0 @@ -# Local quickstart (stdio) - -The local transport runs AegisGitea-MCP on your own machine as a single-user MCP -server over stdio. It authenticates with **your** Gitea Personal Access Token -(PAT) — there is no OAuth, no public endpoint, and no web stack to install. - -## What you need - -- A Gitea instance URL (`GITEA_URL`). -- A Gitea Personal Access Token (`GITEA_TOKEN`) with least privilege: - - `read:repository` - - `write:repository` only if you intend to enable `WRITE_MODE`. -- [`uv`](https://docs.astral.sh/uv/) (for `uvx`) or `pip`. - -## Run it - -With `uvx` (no install): - -```bash -GITEA_URL=https://git.hiddenden.cafe \ -GITEA_TOKEN= \ -uvx aegis-gitea-mcp -``` - -With pip: - -```bash -pip install aegis-gitea-mcp -GITEA_URL=https://git.hiddenden.cafe GITEA_TOKEN= aegis-gitea-mcp -``` - -A local `.env` file is also supported — drop `GITEA_URL` and `GITEA_TOKEN` in it -and just run `aegis-gitea-mcp`. - -If a required variable is missing the server exits with a clear message instead -of a traceback. - -## Wire it into a client - -Claude Code: - -```bash -claude mcp add aegis-gitea \ - -e GITEA_URL=https://git.hiddenden.cafe \ - -e GITEA_TOKEN= \ - -- uvx aegis-gitea-mcp -``` - -Claude Desktop (`claude_desktop_config.json`): - -```json -{ - "mcpServers": { - "aegis-gitea": { - "command": "uvx", - "args": ["aegis-gitea-mcp"], - "env": { - "GITEA_URL": "https://git.hiddenden.cafe", - "GITEA_TOKEN": "" - } - } - } -} -``` - -## What still applies locally - -The local adapter is single-user and trusts the PAT owner, so it skips the -per-user repository-permission probe used by the public server. Everything else -is identical to the server: - -- **Policy engine** (`policy.yaml`) — same allow/deny rules. -- **`WRITE_MODE`** — off by default; writes are denied unless explicitly enabled - and whitelisted. -- **`gitea_request`** full-API escape hatch — same write classifier, known-path - gate, and admin/credential denylist. -- **Secret sanitization** of tool output. -- **Tamper-evident audit log** — written to a per-user path when the container - default is not writable: - - Windows: `%LOCALAPPDATA%\aegis-gitea-mcp\audit.log` - - Linux/macOS: `$XDG_STATE_HOME/aegis-gitea-mcp/audit.log` or - `~/.local/state/aegis-gitea-mcp/audit.log` - - Override with `AUDIT_LOG_PATH`. - -## Enabling writes locally - -Writes are opt-in, exactly as on the server: - -```bash -GITEA_URL=https://git.hiddenden.cafe \ -GITEA_TOKEN= \ -WRITE_MODE=true \ -WRITE_REPOSITORY_WHITELIST=acme/app,acme/docs \ -uvx aegis-gitea-mcp -``` - -See [configuration.md](configuration.md) for the full variable reference and -[write-mode.md](write-mode.md) for the write-mode model. diff --git a/docs/observability.md b/docs/observability.md deleted file mode 100644 index 392134d..0000000 --- a/docs/observability.md +++ /dev/null @@ -1,48 +0,0 @@ -# Observability - -## Logging - -- Structured JSON logs. -- Request correlation via `X-Request-ID`. -- Security events and policy denials are audit logged. - -### Structured event helpers - -`logging_utils` exposes reusable helpers so endpoints emit consistent, -secret-safe structured events instead of ad-hoc inline logging: - -- `log_event(logger, level, event, **context)` — emit a named event with a - `context` mapping; keys in `SENSITIVE_CONTEXT_KEYS` (e.g. `token`, - `authorization`, `password`) are masked as `***`. -- `log_nullable_field(logger, event, field, value)` — record whether a parsed - response field is `None` and its runtime type, without dumping its contents. -- `sanitize_context(context)` — the masking primitive used by the above. - -The `context` mapping is serialized into the JSON log payload under a `context` -key. These run at `DEBUG`, so they are silent unless `LOG_LEVEL=DEBUG`. - -`get_issue` is instrumented with these helpers (`get_issue.start`, -`get_issue.payload_shape`, `get_issue.field_check`) to make nullable-field -parsing failures diagnosable. The same pattern can be reused for other -parsing-heavy endpoints (`get_pull_request`, `list_issues`, `get_commit_diff`). - -## Metrics - -Prometheus-compatible endpoint: `GET /metrics`. - -Current metrics: -- `aegis_http_requests_total{method,path,status}` -- `aegis_tool_calls_total{tool,status}` -- `aegis_tool_duration_seconds_sum{tool}` -- `aegis_tool_duration_seconds_count{tool}` - -## Tracing and Correlation - -- Request IDs propagate in response header (`X-Request-ID`). -- Tool-level correlation IDs included in MCP responses. - -## Operational Guidance - -- Alert on spikes in 401/403/429 rates. -- Alert on repeated `access_denied` and auth-rate-limit events. -- Track tool latency trends for incident triage. diff --git a/docs/policy.md b/docs/policy.md deleted file mode 100644 index f4574d3..0000000 --- a/docs/policy.md +++ /dev/null @@ -1,52 +0,0 @@ -# Policy Engine - -## Overview - -Aegis uses a YAML policy engine to authorize tool execution before any Gitea API call is made. - -## Behavior Summary - -- Global tool allow/deny supported. -- Per-repository tool allow/deny supported. -- Optional repository path allow/deny supported. -- Write operations are denied by default. -- Write operations also require `WRITE_MODE=true` and either: - - `WRITE_REPOSITORY_WHITELIST` match, or - - `WRITE_ALLOW_ALL_TOKEN_REPOS=true`. - -## Example Configuration - -```yaml -defaults: - read: allow - write: deny - -tools: - deny: - - search_code - -repositories: - acme/service-a: - tools: - allow: - - get_file_contents - - list_commits - paths: - allow: - - src/* - deny: - - src/secrets/* -``` - -## Failure Behavior - -- Invalid YAML or invalid schema: startup failure (fail closed). -- Denied tool call: HTTP `403` + audit `access_denied` entry. -- Path traversal attempt in path-scoped tools: denied by validation/policy checks. - -## Operational Guidance - -- Keep policy files version-controlled and code-reviewed. -- Prefer explicit deny entries for sensitive tools. -- Use repository-specific allow lists for high-risk environments. -- Test policy updates in staging before production rollout. diff --git a/docs/todo.md b/docs/todo.md deleted file mode 100644 index 0771705..0000000 --- a/docs/todo.md +++ /dev/null @@ -1,105 +0,0 @@ -# TODO - -## Phase 0 Governance - -- [x] Add `CODE_OF_CONDUCT.md`. -- [x] Add governance policy documentation. -- [x] Upgrade `AGENTS.md` as authoritative AI contract. - -## Phase 1 Architecture - -- [x] Publish roadmap and threat/security model updates. -- [x] Publish phased TODO tracker. - -## Phase 2 Expanded Read Tools - -- [x] Implement `search_code`. -- [x] Implement `list_commits`. -- [x] Implement `get_commit_diff`. -- [x] Implement `compare_refs`. -- [x] Implement `list_issues`. -- [x] Implement `get_issue`. -- [x] Implement `list_pull_requests`. -- [x] Implement `get_pull_request`. -- [x] Implement `list_labels`. -- [x] Implement `list_tags`. -- [x] Implement `list_releases`. -- [x] Add input validation and response bounds. -- [x] Add unit/failure-mode tests. - -## Phase 3 Policy Engine - -- [x] Implement YAML policy loader and validator. -- [x] Implement per-tool and per-repo allow/deny. -- [x] Implement optional path restrictions. -- [x] Enforce default write deny. -- [x] Add policy unit tests. - -## Phase 4 Write Mode - -- [x] Implement write tools (`create_issue`, `update_issue`, comments, labels, assignment). -- [x] Keep write mode disabled by default. -- [x] Enforce repository whitelist. -- [x] Ensure no merge/deletion/force-push capabilities. -- [x] Add write denial tests. - -## Phase 5 Hardening - -- [x] Add secret detection + mask/block controls. -- [x] Add prompt-injection defensive model (data-only handling). -- [x] Add tamper-evident audit chaining and validation. -- [x] Add per-IP and per-token rate limiting. - -## Phase 6 Automation - -- [x] Implement webhook ingestion pipeline. -- [x] Implement on-demand scheduled jobs runner endpoint. -- [x] Implement auto issue creation job scaffold from findings. -- [x] Implement dependency hygiene scan orchestration scaffold. -- [x] Implement stale issue detection automation. -- [x] Add automation endpoint tests. - -## Phase 7 Deployment - -- [x] Harden Docker runtime defaults. -- [x] Separate dev/prod compose profiles. -- [x] Preserve non-root runtime and health checks. - -## Phase 8 Observability - -- [x] Add Prometheus metrics endpoint. -- [x] Add structured JSON logging. -- [x] Add request ID correlation. -- [x] Add tool timing metrics. - -## Phase 9 Testing and Release Readiness - -- [x] Extend unit tests. -- [x] Add policy tests. -- [x] Add secret detection tests. -- [x] Add write-mode denial tests. -- [x] Add audit integrity tests. -- [ ] Add integration-tagged tests against live Gitea (optional CI stage). -- [ ] Final security review sign-off. -- [ ] Release checklist execution. - -## Phase 10 Local Package & Safe Full Coverage (0.2.0) - -- [x] Extract transport-agnostic core + shared tool registry. -- [x] Lock the core/web boundary with a no-fastapi import test. -- [x] Add local stdio adapter (`stdio_app.py`) over the `mcp` SDK. -- [x] Restructure packaging: core install + `[server]` extra + console scripts. -- [x] Resource-type-aware authorization (repo/org/user/admin/misc), fail-closed. -- [x] Classified `gitea_request`: write classifier + known-path gate + denylist. -- [x] Authz matrix, write-mode bypass, classifier, and stdio adapter tests. -- [x] `.gitea/workflows/publish.yml` (uv build + publish to Gitea registry on tag). -- [ ] Make `list_organizations` user-scoped in service-PAT mode (`/users/{login}/orgs`) - so it can be allowed instead of denied. (TODO(authz)) - -## Release Checklist - -- [ ] `make lint` -- [ ] `make test` -- [ ] Documentation review complete -- [ ] Policy file reviewed for production scope -- [ ] Write mode remains disabled unless explicitly approved diff --git a/docs/write-mode.md b/docs/write-mode.md deleted file mode 100644 index 11b7a58..0000000 --- a/docs/write-mode.md +++ /dev/null @@ -1,55 +0,0 @@ -# Write Mode - -## Threat Model - -Write mode introduces mutation risk (issue/PR changes, metadata updates). Risks include unauthorized action, accidental mass updates, and audit evasion. - -## Default Posture - -- `WRITE_MODE=false` by default. -- When enabled, writes require repository whitelist membership by default. -- Optional opt-in: `WRITE_ALLOW_ALL_TOKEN_REPOS=true` allows writes to any repo the token can access. -- Policy engine remains authoritative and may deny specific write tools. - -## Supported Write Tools - -- `create_issue` (optional `milestone` id or title) -- `update_issue` (optional `milestone`; `0` clears it) -- `create_issue_comment` -- `create_pr_comment` -- `edit_issue_comment` -- `add_labels` -- `remove_labels` -- `assign_issue` -- `create_label` -- `update_label` -- `create_pull_request` -- `create_release` -- `edit_release` -- `create_branch` -- `create_milestone` - -Not supported (explicitly forbidden): merge actions, branch/label/release deletion, -force push, repo/admin management, and repository content writes (file create/edit, -commits). Gitea Projects (Kanban boards) are unsupported because the Gitea REST API -exposes no project endpoints. - -## Enablement Steps - -1. Set `WRITE_MODE=true`. -2. Choose one: - - `WRITE_REPOSITORY_WHITELIST=owner/repo,...` (recommended) - - `WRITE_ALLOW_ALL_TOKEN_REPOS=true` (broader scope) -3. Review policy file for write-tool scope. -4. Verify audit logging and alerting before rollout. - -## Safe Operations - -- Start with one repository in whitelist. -- Use narrowly scoped bot credentials. -- Require peer review for whitelist/policy changes. -- Disable write mode during incident response if abuse is suspected. - -## Risk Tradeoffs - -Write mode improves automation and triage speed but increases blast radius. Use least privilege, tight policy, and strong monitoring. diff --git a/scripts/check_docs.py b/scripts/check_docs.py new file mode 100644 index 0000000..a97a48b --- /dev/null +++ b/scripts/check_docs.py @@ -0,0 +1,414 @@ +"""Generate and check the tool inventory in the documentation. + +Usage: + uv run python scripts/check_docs.py # check mode (CI job ``docs-check``) + uv run python scripts/check_docs.py --write # regenerate docs/tools.md and the README block + +Check mode fails (exit code 1) when: + +* ``docs/tools.md`` differs from what the catalog generates; +* the generated block of ``README.md`` (between ```` and + ````) differs from what the catalog generates; +* ``README.md`` or a top-level ``docs/*.md`` file (``docs/adr``, ``docs/research`` and + ``docs/audit.md`` are excluded) mentions a ``gitea_*`` tool name that is not registered. + +The inventory comes from ``load_catalog()`` plus the hand-written raw tool +``gitea_api_request``. Output is deterministic. The logic is importable so tests can +drive it against a temporary tree. +""" + +from __future__ import annotations + +import argparse +import difflib +import re +import sys +from collections import Counter +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "src")) + +from aegis_gitea_mcp.catalog import Catalog, Operation, load_catalog # noqa: E402 +from aegis_gitea_mcp.gitea import RAW_TOOL_NAME # noqa: E402 + +README_NAME = "README.md" +TOOLS_DOC = Path("docs") / "tools.md" +BLOCK_START = "" +BLOCK_END = "" +#: Documentation that may name tools that no longer exist (history, research, audit). +EXCLUDED_DOCS = frozenset({"audit.md"}) +REGEN_HINT = "run: uv run python scripts/check_docs.py --write" + +RAW_DOMAIN = "misc" +RAW_SUMMARY = "Raw request to any Gitea endpoint, for endpoints newer than the pinned spec" + +_TOOL_MENTION = re.compile(r"(? tuple[str, ...]: + flags: list[str] = [] + if operation.read_only: + flags.append("read-only") + if operation.destructive: + flags.append("destructive") + if operation.paginated: + flags.append("paginated") + if any(parameter.is_file for parameter in operation.parameters): + flags.append("file upload") + if operation.domain == "admin": + flags.append("admin") + return tuple(flags) + + +def build_rows(catalog: Catalog) -> list[ToolRow]: + """Return every tool (catalog operations plus the raw tool), sorted by domain then name.""" + rows = [ + ToolRow( + name=op.tool_name, + domain=op.domain, + method=op.method, + path=op.path, + summary=op.summary, + flags=_flags(op), + ) + for op in catalog.operations + ] + rows.append( + ToolRow( + name=RAW_TOOL_NAME, + domain=RAW_DOMAIN, + method="any", + path="(caller supplied)", + summary=RAW_SUMMARY, + flags=("destructive",), + ) + ) + return sorted(rows, key=lambda row: (row.domain, row.name)) + + +def registered_names(catalog: Catalog) -> frozenset[str]: + """Return every registered tool name, including the raw tool.""" + return frozenset({op.tool_name for op in catalog.operations} | {RAW_TOOL_NAME}) + + +def _cell(text: str) -> str: + """Make ``text`` safe inside a Markdown table cell.""" + return " ".join(text.split()).replace("|", "\\|") + + +def _domain_order(catalog: Catalog, rows: Iterable[ToolRow]) -> list[str]: + present = {row.domain for row in rows} + ordered = [domain for domain in catalog.domains() if domain in present] + ordered.extend(sorted(present - set(ordered))) + return ordered + + +def render_summary_table(catalog: Catalog) -> str: + """Render the per-domain count table (also used inside the README block).""" + rows = build_rows(catalog) + totals: Counter[str] = Counter(row.domain for row in rows) + read_only: Counter[str] = Counter(row.domain for row in rows if "read-only" in row.flags) + lines = ["| Domain | Tools | Read-only | Mutating |", "|---|---:|---:|---:|"] + for domain in _domain_order(catalog, rows): + total = totals[domain] + lines.append(f"| {domain} | {total} | {read_only[domain]} | {total - read_only[domain]} |") + total_all = len(rows) + read_all = sum(read_only.values()) + lines.append(f"| **all** | **{total_all}** | **{read_all}** | **{total_all - read_all}** |") + return "\n".join(lines) + + +def render_readme_block(catalog: Catalog) -> str: + """Render the text that sits between the README markers (without the markers).""" + rows = build_rows(catalog) + generated = len(rows) - 1 + body = ( + f"{len(rows)} tools: {generated} generated from the Gitea {catalog.spec_version} " + f"swagger (one per operation) plus `{RAW_TOOL_NAME}`. The raw tool is counted " + "under misc.\n\n" + f"{render_summary_table(catalog)}\n\n" + "The full list with HTTP method, path and flags is in [docs/tools.md](docs/tools.md)." + ) + return f"\n{body}\n" + + +def render_tools_md(catalog: Catalog) -> str: + """Render ``docs/tools.md`` completely.""" + rows = build_rows(catalog) + generated = len(rows) - 1 + parts: list[str] = [ + "# Tool inventory\n", + "This file is generated. Do not edit it by hand: run " + "`uv run python scripts/check_docs.py --write` after changing the catalog. CI fails " + "when it is out of date.\n", + f"There are {len(rows)} tools: {generated} generated from the pinned Gitea " + f"{catalog.spec_version} swagger (`spec/gitea-swagger.v1.json`, one tool per " + f"operation) plus the hand-written `{RAW_TOOL_NAME}`, which is listed under misc.\n", + "Naming: `gitea__`, lowercase snake_case, at most 64 characters. The " + "domain comes from the request path, the action from the swagger operationId. Names " + "are stable for a given spec version.\n", + "Every tool also accepts an optional `sudo` argument (a Gitea login). With an admin " + "token the call then runs as that user. Paths are relative to `/api/v1`.\n", + "Filters: `TOOLSETS` limits the exposed domains, `READ_ONLY` exposes only read-only " + f"operations (`{RAW_TOOL_NAME}` then accepts only GET), `DISABLED_TOOLS` hides " + "single tools by name. A filtered tool is neither listed nor callable. See " + "[configuration.md](configuration.md).\n", + "Flags: read-only (maps to the MCP `readOnlyHint`), destructive (`destructiveHint`: " + "deletes and calls that replace state), paginated (accepts `page` and `limit`), file " + "upload (takes base64 file content), admin (needs a site-admin token).\n", + "## Summary\n", + render_summary_table(catalog) + "\n", + ] + by_domain: dict[str, list[ToolRow]] = {} + for row in rows: + by_domain.setdefault(row.domain, []).append(row) + for domain in _domain_order(catalog, rows): + domain_rows = by_domain[domain] + parts.append(f"## {domain}\n") + blurb = DOMAIN_BLURBS.get(domain) + if blurb: + parts.append(f"{blurb} {len(domain_rows)} tools.\n") + table = [ + "| Tool | Method | Path | Summary | Flags |", + "|---|---|---|---|---|", + ] + for row in domain_rows: + table.append( + f"| `{row.name}` | {row.method} | `{_cell(row.path)}` | {_cell(row.summary)} " + f"| {', '.join(row.flags)} |" + ) + parts.append("\n".join(table) + "\n") + return "\n".join(parts) + + +# --------------------------------------------------------------------------- +# README block handling +# --------------------------------------------------------------------------- + + +def extract_block(readme_text: str) -> str | None: + """Return the text between the README markers, or None when a marker is missing.""" + start = readme_text.find(BLOCK_START) + end = readme_text.find(BLOCK_END) + if start == -1 or end == -1 or end < start: + return None + return readme_text[start + len(BLOCK_START) : end] + + +def replace_block(readme_text: str, block: str) -> str: + """Return ``readme_text`` with the generated block replaced (markers must exist).""" + start = readme_text.find(BLOCK_START) + end = readme_text.find(BLOCK_END) + if start == -1 or end == -1 or end < start: + raise ValueError(f"README.md must contain {BLOCK_START} and {BLOCK_END} (in that order)") + return readme_text[: start + len(BLOCK_START)] + block + readme_text[end:] + + +# --------------------------------------------------------------------------- +# Tool name mentions +# --------------------------------------------------------------------------- + + +def doc_files(root: Path) -> list[Path]: + """Return the Markdown files scanned for tool names (README plus top-level docs).""" + files: list[Path] = [] + readme = root / README_NAME + if readme.is_file(): + files.append(readme) + docs = root / "docs" + if docs.is_dir(): + files.extend( + path + for path in sorted(docs.glob("*.md")) + if path.name not in EXCLUDED_DOCS and path.is_file() + ) + return files + + +def is_known_mention(mention: str, names: frozenset[str]) -> bool: + """Return True for a registered tool name or a prefix such as ``gitea_admin_``.""" + if mention in names: + return True + return mention.endswith("_") and any(name.startswith(mention) for name in names) + + +def find_unknown_mentions(text: str, names: frozenset[str]) -> list[tuple[int, str]]: + """Return ``(line number, name)`` for every mention that is not a registered tool.""" + unknown: list[tuple[int, str]] = [] + for number, line in enumerate(text.splitlines(), start=1): + for match in _TOOL_MENTION.finditer(line): + mention = match.group(0) + if not is_known_mention(mention, names): + unknown.append((number, mention)) + return unknown + + +# --------------------------------------------------------------------------- +# check and write +# --------------------------------------------------------------------------- + + +def _diff_summary(label: str, current: str, expected: str) -> str: + diff = list( + difflib.unified_diff( + current.splitlines(), + expected.splitlines(), + fromfile=f"{label} (committed)", + tofile=f"{label} (generated)", + lineterm="", + n=0, + ) + ) + shown = diff[:_MAX_DIFF_LINES] + if len(diff) > len(shown): + shown.append(f"... {len(diff) - len(shown)} more diff lines") + return "\n".join(shown) + + +def check(root: Path, catalog: Catalog | None = None) -> list[str]: + """Run every drift rule against the tree at ``root`` and return the problems found.""" + catalog = catalog or load_catalog() + problems: list[str] = [] + + tools_path = root / TOOLS_DOC + expected_tools = render_tools_md(catalog) + if not tools_path.is_file(): + problems.append(f"{TOOLS_DOC} is missing. {REGEN_HINT}") + else: + current = tools_path.read_text(encoding="utf-8") + if current != expected_tools: + problems.append( + f"{TOOLS_DOC} is out of date. {REGEN_HINT}\n" + + _diff_summary(str(TOOLS_DOC), current, expected_tools) + ) + + readme_path = root / README_NAME + if not readme_path.is_file(): + problems.append(f"{README_NAME} is missing") + else: + readme = readme_path.read_text(encoding="utf-8") + block = extract_block(readme) + expected_block = render_readme_block(catalog) + if block is None: + problems.append( + f"{README_NAME} has no generated block: add {BLOCK_START} and {BLOCK_END}, " + "then run: uv run python scripts/check_docs.py --write" + ) + elif block != expected_block: + problems.append( + f"the tool summary in {README_NAME} is out of date. {REGEN_HINT}\n" + + _diff_summary(f"{README_NAME} tool summary", block, expected_block) + ) + + names = registered_names(catalog) + for path in doc_files(root): + text = path.read_text(encoding="utf-8") + for number, mention in find_unknown_mentions(text, names): + problems.append( + f"{path.relative_to(root)}:{number}: '{mention}' is not a registered tool name" + ) + return problems + + +def write(root: Path, catalog: Catalog | None = None) -> list[Path]: + """Regenerate ``docs/tools.md`` and the README block. Returns the files written.""" + catalog = catalog or load_catalog() + written: list[Path] = [] + + tools_path = root / TOOLS_DOC + tools_path.parent.mkdir(parents=True, exist_ok=True) + tools_path.write_text(render_tools_md(catalog), encoding="utf-8") + written.append(tools_path) + + readme_path = root / README_NAME + readme = readme_path.read_text(encoding="utf-8") + updated = replace_block(readme, render_readme_block(catalog)) + if updated != readme: + readme_path.write_text(updated, encoding="utf-8") + written.append(readme_path) + return written + + +def main(argv: Sequence[str] | None = None, root: Path = REPO_ROOT) -> int: + """Run the script and return the process exit code.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--write", + action="store_true", + help="regenerate docs/tools.md and the README tool summary instead of checking", + ) + args = parser.parse_args(argv) + + if args.write: + try: + written = write(root) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + for path in written: + print(f"wrote {path.relative_to(root)}") + return 0 + + problems = check(root) + if problems: + print(f"docs check failed with {len(problems)} problem(s):", file=sys.stderr) + for problem in problems: + print(f"- {problem}", file=sys.stderr) + return 1 + print("docs check passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_check_docs.py b/tests/test_check_docs.py new file mode 100644 index 0000000..27d1413 --- /dev/null +++ b/tests/test_check_docs.py @@ -0,0 +1,239 @@ +"""Tests for scripts/check_docs.py: generation, drift detection and tool name mentions.""" + +from __future__ import annotations + +import importlib.util +import re +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +from aegis_gitea_mcp.catalog import load_catalog + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "scripts" / "check_docs.py" + +README_TEMPLATE = ( + "# Title\n\nIntro.\n\n\nold\n\n\nOutro.\n" +) + + +@pytest.fixture(scope="module") +def script() -> ModuleType: + spec = importlib.util.spec_from_file_location("check_docs_script", SCRIPT) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module # dataclasses resolves string annotations through it + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def tree(script: ModuleType, tmp_path: Path) -> Path: + """A minimal docs tree in which the generated files are up to date.""" + (tmp_path / "docs").mkdir() + (tmp_path / "README.md").write_text(README_TEMPLATE, encoding="utf-8") + script.write(tmp_path) + return tmp_path + + +def test_repository_docs_are_in_sync(script: ModuleType) -> None: + # docs/security.md still describes the 0.x design until the security phase rewrites it; + # its stale tool names are the only accepted problems. Drop the filter after that rewrite. + problems = [p for p in script.check(REPO_ROOT) if not p.startswith("docs/security.md:")] + assert problems == [] + + +def test_tools_md_lists_every_tool_once(script: ModuleType) -> None: + catalog = load_catalog() + text = script.render_tools_md(catalog) + rows = re.findall(r"^\| `(gitea_[a-z0-9_]+)` \|", text, flags=re.MULTILINE) + expected = {op.tool_name for op in catalog.operations} | {"gitea_api_request"} + assert len(rows) == len(expected) == len(catalog.operations) + 1 + assert set(rows) == expected + assert f"There are {len(expected)} tools" in text + assert catalog.spec_version in text + + +def test_tools_md_row_content(script: ModuleType) -> None: + catalog = load_catalog() + text = script.render_tools_md(catalog) + delete = next(op for op in catalog.operations if op.tool_name == "gitea_repo_delete") + row = next(line for line in text.splitlines() if line.startswith("| `gitea_repo_delete` |")) + assert f"| {delete.method} | `{delete.path}` |" in row + assert "destructive" in row + raw_row = next(line for line in text.splitlines() if line.startswith("| `gitea_api_request`")) + assert "any" in raw_row + assert "destructive" in raw_row + + +def test_flags_cover_admin_upload_and_pagination(script: ModuleType) -> None: + catalog = load_catalog() + rows = {row.name: row for row in script.build_rows(catalog)} + admin = next(op.tool_name for op in catalog.operations if op.domain == "admin") + assert "admin" in rows[admin].flags + assert any("file upload" in row.flags for row in rows.values()) + assert any("paginated" in row.flags for row in rows.values()) + assert any("read-only" in row.flags for row in rows.values()) + + +def test_output_is_deterministic(script: ModuleType) -> None: + catalog = load_catalog() + assert script.render_tools_md(catalog) == script.render_tools_md(catalog) + assert script.render_readme_block(catalog) == script.render_readme_block(catalog) + + +def test_summary_table_counts_match_catalog(script: ModuleType) -> None: + catalog = load_catalog() + block = script.render_readme_block(catalog) + total = len(catalog.operations) + 1 + assert f"{total} tools" in block + misc = sum(1 for op in catalog.operations if op.domain == "misc") + 1 + assert f"| misc | {misc} |" in block + assert f"| **all** | **{total}** |" in block + + +def test_table_cells_escape_pipes_and_newlines(script: ModuleType) -> None: + assert script._cell("a | b\nc") == "a \\| b c" + + +def test_write_mode_creates_files_and_replaces_only_the_block( + script: ModuleType, tmp_path: Path +) -> None: + (tmp_path / "README.md").write_text(README_TEMPLATE, encoding="utf-8") + written = script.write(tmp_path) + assert {path.name for path in written} == {"tools.md", "README.md"} + readme = (tmp_path / "README.md").read_text(encoding="utf-8") + assert readme.startswith("# Title\n\nIntro.\n\n") + assert readme.endswith("\n\nOutro.\n") + assert "old" not in readme + assert (tmp_path / "docs" / "tools.md").read_text(encoding="utf-8").startswith("# Tool") + + +def test_write_mode_is_idempotent(script: ModuleType, tree: Path) -> None: + before = (tree / "README.md").read_text(encoding="utf-8") + script.write(tree) + assert (tree / "README.md").read_text(encoding="utf-8") == before + + +def test_write_requires_markers(script: ModuleType, tmp_path: Path) -> None: + (tmp_path / "README.md").write_text("# no markers\n", encoding="utf-8") + with pytest.raises(ValueError, match="tool-summary"): + script.write(tmp_path) + assert script.main(["--write"], root=tmp_path) == 1 + + +def test_main_write_then_check( + script: ModuleType, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (tmp_path / "README.md").write_text(README_TEMPLATE, encoding="utf-8") + assert script.main(["--write"], root=tmp_path) == 0 + assert "wrote docs/tools.md" in capsys.readouterr().out + assert script.main([], root=tmp_path) == 0 + assert "docs check passed" in capsys.readouterr().out + + +def test_clean_tree_passes(script: ModuleType, tree: Path) -> None: + assert script.check(tree) == [] + + +def test_detects_stale_tools_md( + script: ModuleType, tree: Path, capsys: pytest.CaptureFixture[str] +) -> None: + path = tree / "docs" / "tools.md" + path.write_text(path.read_text(encoding="utf-8").replace("| repo |", "| repos |")) + problems = script.check(tree) + assert len(problems) == 1 + assert "docs/tools.md is out of date" in problems[0] + assert "check_docs.py --write" in problems[0] + assert "-| repos |" in problems[0] + assert "+| repo |" in problems[0] + assert script.main([], root=tree) == 1 + assert "docs check failed" in capsys.readouterr().err + + +def test_detects_missing_tools_md(script: ModuleType, tree: Path) -> None: + (tree / "docs" / "tools.md").unlink() + problems = script.check(tree) + assert any("docs/tools.md is missing" in problem for problem in problems) + + +def test_detects_stale_readme_block(script: ModuleType, tree: Path) -> None: + readme = tree / "README.md" + readme.write_text(readme.read_text(encoding="utf-8").replace("| misc |", "| miscx |")) + problems = script.check(tree) + assert len(problems) == 1 + assert "tool summary in README.md is out of date" in problems[0] + + +def test_detects_missing_readme_markers(script: ModuleType, tree: Path) -> None: + (tree / "README.md").write_text("# no block here\n", encoding="utf-8") + problems = script.check(tree) + assert any("no generated block" in problem for problem in problems) + + +def test_detects_reversed_markers(script: ModuleType, tree: Path) -> None: + (tree / "README.md").write_text( + "\n\n", encoding="utf-8" + ) + assert any("no generated block" in problem for problem in script.check(tree)) + + +def test_detects_unknown_tool_name_in_readme(script: ModuleType, tree: Path) -> None: + readme = tree / "README.md" + readme.write_text(readme.read_text(encoding="utf-8") + "\nUse `gitea_made_up_tool`.\n") + problems = script.check(tree) + assert len(problems) == 1 + assert "README.md" in problems[0] + assert "'gitea_made_up_tool' is not a registered tool name" in problems[0] + + +def test_detects_unknown_tool_name_in_docs(script: ModuleType, tree: Path) -> None: + (tree / "docs" / "guide.md").write_text("one\ntwo `gitea_request` three\n", encoding="utf-8") + problems = script.check(tree) + assert problems == ["docs/guide.md:2: 'gitea_request' is not a registered tool name"] + + +def test_registered_names_and_prefixes_are_accepted(script: ModuleType, tree: Path) -> None: + (tree / "docs" / "guide.md").write_text( + "`gitea_repo_get`, `gitea_api_request` and the gitea_admin_ tools.\n", encoding="utf-8" + ) + assert script.check(tree) == [] + + +def test_unknown_prefix_is_rejected(script: ModuleType, tree: Path) -> None: + (tree / "docs" / "guide.md").write_text("the gitea_bogus_ tools\n", encoding="utf-8") + assert len(script.check(tree)) == 1 + + +def test_similar_identifiers_are_not_tool_mentions(script: ModuleType) -> None: + names = script.registered_names(load_catalog()) + text = "aegis_gitea_mcp, my_gitea_thing, GITEA_URL and gitea__\n" + assert script.find_unknown_mentions(text, names) == [] + + +def test_excluded_documents_may_name_old_tools(script: ModuleType, tree: Path) -> None: + (tree / "docs" / "audit.md").write_text("old `gitea_request`\n", encoding="utf-8") + (tree / "docs" / "adr").mkdir() + (tree / "docs" / "adr" / "0001.md").write_text("old `gitea_request`\n", encoding="utf-8") + (tree / "docs" / "research").mkdir() + (tree / "docs" / "research" / "notes.md").write_text("`gitea_request`\n", encoding="utf-8") + assert script.check(tree) == [] + scanned = {path.name for path in script.doc_files(tree)} + assert scanned == {"README.md", "tools.md"} + + +def test_missing_readme_is_reported(script: ModuleType, tree: Path) -> None: + (tree / "README.md").unlink() + assert any("README.md is missing" in problem for problem in script.check(tree)) + + +def test_long_diffs_are_truncated(script: ModuleType) -> None: + current = "\n".join(f"old {i}" for i in range(200)) + expected = "\n".join(f"new {i}" for i in range(200)) + summary = script._diff_summary("x", current, expected) + assert "more diff lines" in summary + assert len(summary.splitlines()) <= 30