#!/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 os 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") def sync_repo_wiki_git( repo: RepoInfo, sources: list[WikiSource], *, cache_dir: pathlib.Path, overwrite_unmanaged: bool, commit_message: str, write_index: bool = True, ) -> 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 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 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())