#!/usr/bin/env python3 """Mirror project documentation files into Gitea repository wikis.""" from __future__ import annotations import argparse import base64 import dataclasses import hashlib import pathlib import re import shutil import subprocess import sys import urllib.parse from typing import Any from gitea_common import GiteaClient, GiteaError, infer_target, load_dotenv, repo_path, require_token GIT_ROOT = pathlib.Path("/mnt/DATA/git") PRODUCTS_ROOT = pathlib.Path("/mnt/DATA/Nextcloud/ADD ideas UG/Products") MANAGED_MARKER = "", "", "This page is generated from repository and product-directory project files.", "", ] for source in sources: content = render_wiki_content(source) changed = put_wiki_page( client, target.owner, target.repo, source.page_title, content, existing_page_names, overwrite_unmanaged=overwrite_unmanaged, ) print(f"{'updated' if changed else 'unchanged'} {target.owner}/{target.repo} wiki:{source.page_title}") index_lines.append(f"- [{source.page_title}]({source.page_title}) - `{source.path}`") if write_index: put_wiki_page( client, target.owner, target.repo, "Codex-Project-Index", "\n".join(index_lines) + "\n", existing_page_names, overwrite_unmanaged=True, ) else: print(f"skipped {target.owner}/{target.repo} wiki:Codex-Project-Index during page-limited sync") if prune_managed and write_index: prune_managed_wiki_pages_api(client, target.owner, target.repo, existing_page_names, sources) def sync_repo_wiki_git( repo: RepoInfo, sources: list[WikiSource], *, cache_dir: pathlib.Path, overwrite_unmanaged: bool, commit_message: str, write_index: bool = True, prune_managed: bool = False, ) -> None: target = infer_target(repo.root) wiki_root = prepare_wiki_checkout(repo, cache_dir=cache_dir) index_lines = [ f"# {target.repo} Project Wiki Index", "", f"{MANAGED_MARKER}index -->", "", "This page is generated from repository and product-directory project files.", "", ] for source in sources: content = render_wiki_content(source) status = write_wiki_page_file( wiki_root, source.page_title, content, overwrite_unmanaged=overwrite_unmanaged, ) print(f"{status} {target.owner}/{target.repo} wiki:{source.page_title}") index_lines.append(f"- [{source.page_title}]({source.page_title}) - `{source.path}`") if write_index: write_wiki_page_file( wiki_root, "Codex-Project-Index", "\n".join(index_lines) + "\n", overwrite_unmanaged=True, ) else: print(f"skipped {target.owner}/{target.repo} wiki:Codex-Project-Index during page-limited sync") if prune_managed and write_index: prune_managed_wiki_pages_git(wiki_root, sources) if not git_has_changes(wiki_root): print(f"unchanged {target.owner}/{target.repo} wiki repository") return run_git(wiki_root, "add", "-A") if not git_has_staged_changes(wiki_root): print(f"unchanged {target.owner}/{target.repo} wiki repository") return ensure_git_identity(wiki_root) run_git(wiki_root, "commit", "-m", commit_message) run_git(wiki_root, "push") print(f"pushed {target.owner}/{target.repo} wiki repository") def prepare_wiki_checkout(repo: RepoInfo, *, cache_dir: pathlib.Path) -> pathlib.Path: remote = wiki_remote_for_repo(repo.root) cache_dir.mkdir(parents=True, exist_ok=True) checkout = cache_dir / f"{repo.owner}-{repo.repo}.wiki" if (checkout / ".git").exists(): run_git(checkout, "remote", "set-url", "origin", remote) run_git(checkout, "fetch", "origin") branch = current_branch(checkout) if branch: run_git(checkout, "reset", "--hard", f"origin/{branch}") else: run_git(checkout, "reset", "--hard") run_git(checkout, "clean", "-fd") return checkout if checkout.exists(): shutil.rmtree(checkout) result = subprocess.run( ["git", "clone", remote, str(checkout)], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) if result.returncode == 0: return checkout checkout.mkdir(parents=True, exist_ok=True) run_git(checkout, "init") run_git(checkout, "remote", "add", "origin", remote) run_git(checkout, "checkout", "-b", "master") return checkout def wiki_remote_for_repo(repo_root_path: pathlib.Path) -> str: result = subprocess.run( ["git", "-C", str(repo_root_path), "remote", "get-url", "origin"], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) if result.returncode != 0 or not result.stdout.strip(): raise GiteaError(f"Could not read origin remote for {repo_root_path}") remote = result.stdout.strip() if remote.endswith(".git"): return f"{remote[:-4]}.wiki.git" return f"{remote}.wiki.git" def write_wiki_page_file(wiki_root: pathlib.Path, title: str, content: str, *, overwrite_unmanaged: bool) -> str: path = wiki_root / f"{wiki_file_stem(title)}.md" if path.exists(): current = read_text(path) if current == content: return "unchanged" if MANAGED_MARKER not in current and not overwrite_unmanaged: return "skipped unmanaged" path.write_text(content, encoding="utf-8") return "updated" def prune_managed_wiki_pages_git(wiki_root: pathlib.Path, sources: list[WikiSource]) -> None: desired = {f"{wiki_file_stem(source.page_title)}.md" for source in sources} desired.add(f"{wiki_file_stem('Codex-Project-Index')}.md") for path in sorted(wiki_root.glob("*.md")): if path.name in desired: continue current = read_text(path) if MANAGED_MARKER not in current: continue path.unlink() print(f"deleted stale managed wiki page {path.name}") def prune_managed_wiki_pages_api( client: GiteaClient, owner: str, repo: str, existing_page_names: dict[str, str], sources: list[WikiSource], ) -> None: desired = {source.page_title for source in sources} desired.add("Codex-Project-Index") for title, page_name in sorted(existing_page_names.items()): if title in desired: continue current = client.request_json("GET", repo_path(owner, repo, f"/wiki/page/{quote_wiki_page_name(page_name)}")) if MANAGED_MARKER not in decode_wiki_content(current): continue client.request_json("DELETE", repo_path(owner, repo, f"/wiki/page/{quote_wiki_page_name(page_name)}")) print(f"deleted stale managed wiki page {owner}/{repo}:{title}") def wiki_file_stem(title: str) -> str: return re.sub(r"[/\\]+", "-", title).strip() or "Home" def git_has_changes(root: pathlib.Path) -> bool: result = subprocess.run( ["git", "-C", str(root), "status", "--porcelain"], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) if result.returncode != 0: raise GiteaError(f"git status failed in {root}: {result.stderr.strip()}") return bool(result.stdout.strip()) def git_has_staged_changes(root: pathlib.Path) -> bool: result = subprocess.run( ["git", "-C", str(root), "diff", "--cached", "--quiet"], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) if result.returncode == 0: return False if result.returncode == 1: return True raise GiteaError(f"git diff --cached failed in {root}: {result.stderr.strip()}") def current_branch(root: pathlib.Path) -> str: result = subprocess.run( ["git", "-C", str(root), "rev-parse", "--abbrev-ref", "HEAD"], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) branch = result.stdout.strip() if result.returncode != 0 or branch == "HEAD": return "" return branch def ensure_git_identity(root: pathlib.Path) -> None: if not git_config_value(root, "user.email"): run_git(root, "config", "user.email", "codex@govoplan.local") if not git_config_value(root, "user.name"): run_git(root, "config", "user.name", "Codex") def git_config_value(root: pathlib.Path, key: str) -> str: result = subprocess.run( ["git", "-C", str(root), "config", "--get", key], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) return result.stdout.strip() if result.returncode == 0 else "" def run_git(root: pathlib.Path, *args: str) -> None: result = subprocess.run( ["git", "-C", str(root), *args], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) if result.returncode != 0: command = "git -C " + str(root) + " " + " ".join(args) raise GiteaError(f"{command} failed: {result.stderr.strip() or result.stdout.strip()}") def list_existing_wiki_pages(client: GiteaClient, owner: str, repo: str) -> list[dict[str, Any]]: try: return client.paginate(repo_path(owner, repo, "/wiki/pages"), limit=50) except GiteaError as exc: if "HTTP 404" in str(exc) and "/wiki/pages" in str(exc): return [] raise def render_wiki_content(source: WikiSource) -> str: raw = read_text(source.path) fingerprint = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24] header = [ f"{MANAGED_MARKER}{fingerprint} -->", "", f"> Mirrored from `{source.path}`.", f"> Origin: `{source.origin}`.", "> Active tasks and changing state belong in Gitea issues; this wiki page is durable project context.", "", "---", "", ] if source.path.suffix.lower() == ".csv": return "\n".join(header + ["```csv", raw.rstrip(), "```", ""]) return "\n".join(header) + raw.rstrip() + "\n" def put_wiki_page( client: GiteaClient, owner: str, repo: str, title: str, content: str, existing_page_names: dict[str, str], *, overwrite_unmanaged: bool, ) -> bool: body = { "title": title, "content_base64": base64.b64encode(content.encode("utf-8")).decode("ascii"), "message": f"Sync {title} from project files", } if title in existing_page_names: page_name = existing_page_names[title] current = client.request_json("GET", repo_path(owner, repo, f"/wiki/page/{quote_wiki_page_name(page_name)}")) current_content = decode_wiki_content(current) if current_content == content: return False if MANAGED_MARKER not in current_content and not overwrite_unmanaged: print(f"skip unmanaged existing wiki page {owner}/{repo}:{title}") return False client.request_json("PATCH", repo_path(owner, repo, f"/wiki/page/{quote_wiki_page_name(page_name)}"), body=body) return True client.request_json("POST", repo_path(owner, repo, "/wiki/new"), body=body) existing_page_names[title] = title return True def quote_wiki_page_name(value: str) -> str: return urllib.parse.quote(value, safe="+") def decode_wiki_content(page: dict[str, Any]) -> str: encoded = str(page.get("content_base64") or "") if not encoded: return "" return base64.b64decode(encoded).decode("utf-8") def read_text(path: pathlib.Path) -> str: try: return path.read_text(encoding="utf-8") except UnicodeDecodeError: return path.read_text(encoding="latin-1") if __name__ == "__main__": raise SystemExit(main())