#!/usr/bin/env python3 """Sync the local Forgejo cache at ``tools/.cache/forgejo.sqlite``. This tool is the human-facing wrapper around ``_pipeline_cache.PipelineCache``. On a cold cache it pulls the full master commit history and every PR the repo has ever had. On a warm cache it pulls only deltas (new commits since last sync, PRs whose ``updated_at`` has advanced). Usage ----- Typical incremental refresh:: python3 tools/forgejo-sync.py Seed a fresh cache from scratch (destructive):: python3 tools/forgejo-sync.py --full After any sync, back-fill ``merged_by`` details for every merged PR so downstream reports don't have to fetch per-PR detail on read:: python3 tools/forgejo-sync.py --backfill Show cache statistics:: python3 tools/forgejo-sync.py --stats The cache file lives under ``tools/.cache/`` and is gitignored. """ from __future__ import annotations import argparse import json import os import sys from pathlib import Path # Ensure we can import the sibling cache module regardless of cwd. sys.path.insert(0, str(Path(__file__).resolve().parent)) from _pipeline_cache import ( # noqa: E402 PipelineCache, DEFAULT_CACHE_PATH, ) def _load_token() -> str: token = os.environ.get("GITEA_TOKEN") if token: return token.strip().strip('"').strip("'") repo_root = Path(__file__).resolve().parent.parent for envfile in (repo_root / ".devcontainer" / ".env", repo_root / ".env"): if not envfile.is_file(): continue for line in envfile.read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, _, v = line.partition("=") if k.strip() == "GITEA_TOKEN": return v.strip().strip('"').strip("'") sys.exit( "ERROR: GITEA_TOKEN not found. Set it in the environment " "or in .devcontainer/.env (or .env)." ) def _parse_args() -> argparse.Namespace: p = argparse.ArgumentParser( description="Sync the local Forgejo cache (SQLite at tools/.cache/forgejo.sqlite).", ) p.add_argument( "--full", action="store_true", help="Truncate commits & pulls tables and resync from scratch. " "Use after a force-push to master or to rebuild from clean state.", ) p.add_argument( "--backfill", action="store_true", help="After syncing, fetch PR detail for every closed+merged PR missing " "merged_by (one-shot per PR, permanent). Adds a few minutes on a " "cold cache but makes subsequent reports offline-instant.", ) p.add_argument( "--stats", action="store_true", help="Print cache statistics and exit; do not sync.", ) p.add_argument( "--cache", type=Path, default=DEFAULT_CACHE_PATH, help=f"Cache file path (default: {DEFAULT_CACHE_PATH}).", ) p.add_argument( "--quiet", action="store_true", help="Suppress progress logs; summary JSON is still printed.", ) return p.parse_args() def main() -> None: args = _parse_args() cache = PipelineCache.open(args.cache) if args.stats: print(json.dumps(cache.stats(), indent=2)) return token = _load_token() summary = cache.sync( token, full=args.full, backfill_details=args.backfill, progress=not args.quiet, ) summary["cache"] = cache.stats() print(json.dumps(summary, indent=2)) if __name__ == "__main__": main()