Files
AegisGitea-MCP/scripts/check_docs.py
T
2026-10-14 16:13:11 +02:00

415 lines
16 KiB
Python

"""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())