415 lines
16 KiB
Python
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())
|