feat: add docs drift check

This commit is contained in:
Latte committed 2026-10-14 16:13:11 +02:00
1 parent c9e2c481e7
commit 68bd8700dc
10 files changed
+653 -549

No files matched your search

-128
View File
@@ -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 <token>`.
- Missing or invalid tokens return `401` with:
- `WWW-Authenticate: Bearer resource_metadata="<absolute metadata url>", 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).
-27
View File
@@ -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.
-36
View File
@@ -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.
-98
View File
@@ -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=<pat> \
uvx aegis-gitea-mcp
```
With pip:
```bash
pip install aegis-gitea-mcp
GITEA_URL=https://git.hiddenden.cafe GITEA_TOKEN=<pat> 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=<pat> \
-- 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": "<pat>"
}
}
}
}
```
## 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=<pat-with-write-repository> \
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.
-48
View File
@@ -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.
-52
View File
@@ -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.
-105
View File
@@ -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
-55
View File
@@ -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.
+414
View File
@@ -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 ``<!-- tool-summary:start -->`` and
``<!-- tool-summary:end -->``) 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 = "<!-- tool-summary:start -->"
BLOCK_END = "<!-- tool-summary: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"(?<![A-Za-z0-9_])gitea_[a-z0-9_]+")
_MAX_DIFF_LINES = 24
DOMAIN_BLURBS: dict[str, str] = {
"actions": (
"Gitea Actions: workflow runs, jobs, logs, artifacts, dispatch, secrets, variables "
"and runners at repository, organization, user and instance level."
),
"admin": (
"Instance administration: users, organizations, emails, cron tasks, system hooks, "
"unadopted repositories, runners and packages. Needs a site-admin token."
),
"hook": "Webhooks of repositories, organizations, users and the instance (system hooks).",
"issue": (
"Issues, comments, labels, milestones, reactions, pins, subscriptions, time tracking, "
"stopwatches, dependencies and attachments."
),
"misc": (
"Version, settings, markdown rendering, license, gitignore and label templates, "
"signing keys, the current token, and the raw request tool."
),
"notification": "Notification threads and their read state.",
"org": "Organizations, members, public members, blocks, labels, avatars and activity.",
"package": "Package registry: list, get, delete, files, link and unlink.",
"pull": "Pull requests, merging, reviews, review comments, requested reviewers, diffs.",
"release": "Releases, tags of releases and release attachments.",
"repo": (
"Repositories: CRUD, contents, branches, tags, commits, forks, mirrors, collaborators, "
"branch protection, deploy keys, archives, topics, LFS and more."
),
"team": "Organization teams, their members and repositories.",
"user": (
"The signed-in user and other users: profile, emails, keys, followers, blocks, tokens, "
"OAuth applications, stars, watches, settings."
),
"wiki": "Repository wiki pages and revisions.",
}
@dataclass(frozen=True)
class ToolRow:
"""One row of the inventory (a catalog operation or the raw request tool)."""
name: str
domain: str
method: str
path: str
summary: str
flags: tuple[str, ...]
def _flags(operation: Operation) -> 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_<domain>_<action>`, 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())
+239
View File
@@ -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<!-- tool-summary:start -->\nold\n<!-- tool-summary:end -->\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<!-- tool-summary:start -->")
assert readme.endswith("<!-- tool-summary:end -->\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(
"<!-- tool-summary:end -->\n<!-- tool-summary:start -->\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_<domain>_<action>\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