GRM-36: feat: implement documentation-as-code with wiki sync and doc-coverage

Add /docs/ directory with user and technical documentation extracted from README, AGENTS.md, and source code. Add scripts/sync_wiki.py to sync docs to Gitea wiki via API. Add scripts/doc_coverage.py to check CLI commands, modules, and CI scripts are documented. Add sync-wiki.yml workflow for auto-sync on merge and release. Slim down README.md to lean entry point. 28 new unit tests, 100% coverage maintained.

Closes GRM-36
This commit is contained in:
2026-06-21 19:45:34 +00:00
parent 7fe85423b4
commit 5b05db4e6d
21 changed files with 1899 additions and 415 deletions
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Check documentation coverage for CLI commands and major modules.
Parses Click commands from the CLI source code and checks if each command
has corresponding documentation in the wiki/docs. Reports missing
documentation as warnings and exits with non-zero if coverage is below 100%.
Usage:
python3 scripts/doc_coverage.py [--docs-dir docs/] [--fail-on-missing]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import click
from gitea_runner_manager.i18n import _
DOCS_DIR = Path(__file__).resolve().parent.parent / "docs"
CLI_FILE = Path(__file__).resolve().parent.parent / "src" / "gitea_runner_manager" / "cli.py"
# Major modules that should be documented in tech/architecture.md
REQUIRED_MODULES = [
"cli.py",
"runner_manager.py",
"executor.py",
"registry.py",
"i18n.py",
"exceptions.py",
"api_clients.py",
"config.py",
]
# CI scripts that should be documented in tech/ci-cd-workflow.md
REQUIRED_SCRIPTS = [
"auto_merge.py",
"release.py",
"publish.py",
"review_pr.py",
"notify_failure.py",
"post_merge.py",
]
def extract_cli_commands() -> list[str]:
"""Extract command names from the CLI source file."""
content = CLI_FILE.read_text()
commands: list[str] = []
# Find all @cli.command(...) occurrences, then the next def statement
for match in re.finditer(r'@cli\.command\b', content):
# Check for explicit name="..." in the decorator arguments
decorator_end = content.find(")", match.start())
decorator_text = content[match.start() : decorator_end + 1]
name_match = re.search(r'name\s*=\s*"([^"]+)"', decorator_text)
if name_match:
commands.append(name_match.group(1))
continue
# Find the next def statement after this decorator
after = content[decorator_end:]
def_match = re.search(r'def\s+(\w+)\s*\(', after)
if def_match:
commands.append(def_match.group(1))
return commands
def check_command_documented(command: str, docs_content: str) -> bool:
"""Check if a CLI command is documented in the docs content."""
# Look for the command name as a heading or in code blocks
patterns = [
rf"##.*\b{re.escape(command)}\b",
rf"`grm\s+{re.escape(command)}\b",
rf"\bgrm\s+{re.escape(command)}\b",
rf"###.*\b{re.escape(command)}\b",
]
return any(re.search(p, docs_content, re.IGNORECASE) for p in patterns)
def check_module_documented(module: str, docs_content: str) -> bool:
"""Check if a module is mentioned in the docs content."""
return module in docs_content
@click.command()
@click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.")
@click.option(
"--fail-on-missing",
is_flag=True,
default=False,
help="Exit with non-zero status if any documentation is missing.",
)
def main(docs_dir: str, fail_on_missing: bool) -> None:
docs_path = Path(docs_dir)
cli_commands_file = docs_path / "user" / "cli-commands.md"
architecture_file = docs_path / "tech" / "architecture.md"
ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md"
missing: list[str] = []
total = 0
# Check CLI commands
click.echo(_("Checking CLI command documentation..."))
commands = extract_cli_commands()
total += len(commands)
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
for cmd in commands:
if check_command_documented(cmd, cli_docs):
click.echo(_(" OK: grm {cmd}", cmd=cmd))
else:
click.echo(_(" MISSING: grm {cmd}", cmd=cmd))
missing.append(f"CLI command: grm {cmd}")
# Check modules in architecture.md
click.echo(_("\nChecking module documentation in architecture.md..."))
total += len(REQUIRED_MODULES)
arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
for module in REQUIRED_MODULES:
if check_module_documented(module, arch_docs):
click.echo(_(" OK: {module}", module=module))
else:
click.echo(_(" MISSING: {module}", module=module))
missing.append(f"Module: {module}")
# Check CI scripts in ci-cd-workflow.md
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
total += len(REQUIRED_SCRIPTS)
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
for script in REQUIRED_SCRIPTS:
if check_module_documented(script, ci_docs):
click.echo(_(" OK: {script}", script=script))
else:
click.echo(_(" MISSING: {script}", script=script))
missing.append(f"CI script: {script}")
# Report
covered = total - len(missing)
percentage = (covered / total * 100) if total > 0 else 100.0
click.echo(
_(
"\nDoc coverage: {covered}/{total} ({pct}%)",
covered=covered,
total=total,
pct=f"{percentage:.0f}%",
)
)
if missing:
click.echo(_("\nMissing documentation:"))
for item in missing:
click.echo(f" - {item}")
if missing and fail_on_missing:
click.echo(_("\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."))
sys.exit(1)
if not missing:
click.echo(_("\nAll documentation coverage checks passed!"))
if __name__ == "__main__": # pragma: no cover
main()