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
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Sync documentation from /docs/ to the Gitea wiki via API.
Reads markdown files from the ``docs/`` directory, uses ``mapping.json`` to
map file paths to wiki page titles, and creates/updates wiki pages via the
Gitea API. Pages that exist in the wiki but not in the mapping are left
untouched (not deleted).
Usage:
REPO_TOKEN=<token> python3 scripts/sync_wiki.py [--dry-run] [--repo owner/repo]
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import GITEA_API_URL
from gitea_runner_manager.exceptions import APIError
from gitea_runner_manager.i18n import _
load_dotenv(override=True)
DOCS_DIR = Path(__file__).resolve().parent.parent / "docs"
MAPPING_FILE = DOCS_DIR / "mapping.json"
def load_mapping() -> dict[str, str]:
"""Load the file-to-wiki-page mapping from mapping.json."""
with open(MAPPING_FILE) as f:
return json.load(f)
def read_doc_content(file_path: str) -> str:
"""Read markdown content from a docs file."""
full_path = DOCS_DIR / file_path
with open(full_path) as f:
return f.read()
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
"""List existing wiki pages, returning {title: page_name}."""
try:
pages = client._request("GET", "/wiki/pages").json()
except APIError:
return {}
return {page.get("title", ""): page.get("page_name", page.get("title", "")) for page in pages}
def sync_page(
client: GiteaClient,
page_title: str,
content: str,
existing_pages: dict[str, str],
dry_run: bool,
) -> str:
"""Create or update a single wiki page.
Returns "created", "updated", or "skipped" (if dry-run).
"""
if dry_run:
click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content)))
return "skipped"
if page_title in existing_pages:
# Update existing page
page_name = existing_pages[page_title]
client._request(
"PUT",
f"/wiki/page/{page_name}",
json={"content": content, "message": f"Sync from docs/ — update {page_title}"},
)
return "updated"
# Create new page
client._request(
"POST",
"/wiki/page",
json={"title": page_title, "content": content, "message": f"Sync from docs/ — create {page_title}"},
)
return "created"
@click.command()
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
@click.option("--repo", default=None, help="Repository in owner/name format (auto-detected if omitted).")
def main(dry_run: bool, repo: str | None) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
if repo is None:
owner = os.environ.get("GRM_REPO_OWNER", "oblachno-oss")
repo_name = os.environ.get("GRM_REPO_NAME", "grm")
else:
owner, repo_name = repo.split("/")
if not MAPPING_FILE.exists():
raise click.ClickException(_("ERROR: mapping.json not found at {path}", path=MAPPING_FILE))
mapping = load_mapping()
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
existing_pages = list_wiki_pages(client)
if existing_pages:
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
created = 0
updated = 0
skipped = 0
for file_path, page_title in sorted(mapping.items()):
try:
content = read_doc_content(file_path)
except FileNotFoundError:
click.echo(_("WARNING: File {file} not found — skipping.", file=file_path))
skipped += 1
continue
result = sync_page(client, page_title, content, existing_pages, dry_run)
if result == "created":
created += 1
click.echo(_(" Created: {title}", title=page_title))
elif result == "updated":
updated += 1
click.echo(_(" Updated: {title}", title=page_title))
else:
skipped += 1
click.echo(
_(
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
created=created,
updated=updated,
skipped=skipped,
)
)
if __name__ == "__main__": # pragma: no cover
main()