diff --git a/scripts/import_from_github.py b/scripts/import_from_github.py new file mode 100755 index 0000000..ca651fa --- /dev/null +++ b/scripts/import_from_github.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Import a GitHub repo into Windy Git (strand G11.3 / G7.4). + +**Direction matters and is deliberate.** During the migration quarter, GitHub +stays upstream and Windy Git is the downstream copy: + + GitHub ──pull──▶ Windy Git ──▶ CI + +Not the other way round. G11.6 keeps GitHub as the durable copy for a full +quarter after cutover, and until Grant flips that, anything that makes Windy Git +authoritative creates a two-writer problem nobody asked for. A pull mirror can +never diverge: if it breaks, the worst case is stale, not wrong. + +That inversion is temporary. I-4's push-mirror (`api/app/services/mirror.py`) is +the steady state, for repos that originate here. + +Usage: + ./import_from_github.py windy-calendar [windy-mind ...] + ./import_from_github.py --list-candidates +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request + +# ⚠️ Default to the HOST-LOCAL address, not the public one. +# +# Cloudflare answers the public endpoint with 403 error 1010 for this client — +# it blocks urllib's user-agent as a bot signature. The failure looks like Gitea +# rejecting the token and is not: the same call against localhost:3080 on the +# host succeeds immediately. +# +# Bulk import belongs on the host anyway: no hairpin through the edge, no +# Cloudflare ~100s proxy ceiling (G4A.5) on a large clone. Run this on Veron 1. +GITEA = os.environ.get("GITEA_BASE_URL", "http://localhost:3080") +GITEA_TOKEN = os.environ.get("GITEA_ADMIN_TOKEN", "") +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") +GITHUB_OWNER = os.environ.get("GITHUB_OWNER", "sneakyfree") +OWNER = os.environ.get("WINDYGIT_OWNER", "windyadmin") + +# G11.4 — least risk first. windy-pro is deliberately last and deliberately not +# in this list: six checkouts exist, the build counter has forked three ways, and +# there is direct evidence conflict on which HEAD is current. Resolve that by +# reading, not by importing (G11.5). +SAFE_ORDER = [ + # scaffolds and sites — nothing depends on them + "windy-calendar", + "windy-search", + "windy-registry", + "Windy-Clone", + # live services with real test suites + "WindyCloud", + "windy-cloud-sites", + "windy-mind", + "eternitas", + "windy-agent", +] + + +def _api(method: str, path: str, body: dict | None = None) -> tuple[int, dict]: + if not GITEA_TOKEN: + sys.exit("GITEA_ADMIN_TOKEN is unset. Refusing to guess.") + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request( + f"{GITEA}/api/v1{path}", + data=data, + method=method, + headers={ + "Authorization": f"token {GITEA_TOKEN}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=180) as r: + raw = r.read().decode() + return r.status, (json.loads(raw) if raw.strip() else {}) + except urllib.error.HTTPError as e: + raw = e.read().decode() + try: + return e.code, json.loads(raw) + except json.JSONDecodeError: + return e.code, {"message": raw[:300]} + + +def list_candidates() -> None: + """Private repos whose CI cannot run on GitHub at all.""" + out = subprocess.run( + ["gh", "repo", "list", GITHUB_OWNER, "--limit", "200", + "--json", "name,isPrivate,isArchived,pushedAt"], + capture_output=True, text=True, check=True, + ) + repos = [r for r in json.loads(out.stdout) if r["isPrivate"] and not r["isArchived"]] + print(f"{len(repos)} private repos — GitHub Actions cannot run on any of them.\n") + for name in SAFE_ORDER: + match = next((r for r in repos if r["name"] == name), None) + print(f" {name:24} {'last push ' + match['pushedAt'][:10] if match else 'NOT FOUND'}") + print("\nwindy-pro is excluded on purpose — see G11.5.") + + +def import_repo(name: str) -> bool: + existing_status, _ = _api("GET", f"/repos/{OWNER}/{name}") + if existing_status == 200: + print(f" {name:24} already present — skipping (idempotent)") + return True + + if not GITHUB_TOKEN: + sys.exit("GITHUB_TOKEN is unset; private repos cannot be read without it.") + + status, body = _api( + "POST", + "/repos/migrate", + { + "clone_addr": f"https://github.com/{GITHUB_OWNER}/{name}.git", + "auth_token": GITHUB_TOKEN, + "repo_name": name, + "repo_owner": OWNER, + "service": "github", + "private": True, + # Pull mirror: GitHub stays upstream for the migration quarter. + # A pull mirror cannot diverge — worst case it is stale, not wrong. + "mirror": True, + "mirror_interval": "10m", + # Issues/PRs/releases deliberately NOT imported. They are GitHub's + # copy of a conversation, and duplicating conversations across two + # systems is how you end up with two half-answers to every question. + "issues": False, + "pull_requests": False, + "releases": False, + "wiki": False, + "labels": False, + "milestones": False, + }, + ) + if status in (200, 201): + print(f" {name:24} imported ({body.get('size', 0)} KB)") + return True + print(f" {name:24} FAILED {status}: {str(body.get('message'))[:120]}") + return False + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("repos", nargs="*") + ap.add_argument("--list-candidates", action="store_true") + ap.add_argument("--safe-batch", action="store_true", help="import SAFE_ORDER in order") + args = ap.parse_args() + + if args.list_candidates: + list_candidates() + return 0 + + targets = SAFE_ORDER if args.safe_batch else args.repos + if not targets: + ap.error("name a repo, or pass --safe-batch / --list-candidates") + + if "windy-pro" in targets: + sys.exit( + "REFUSING windy-pro. Six checkouts exist, the build counter has forked " + "three ways (main 12 / overnight 34 / wave-44 56), and two sessions " + "recorded different HEADs hours apart. Resolve which is current and " + "write it down BEFORE importing (G11.5)." + ) + + ok = sum(import_repo(r) for r in targets) + print(f"\n{ok}/{len(targets)} imported.") + return 0 if ok == len(targets) else 1 + + +if __name__ == "__main__": + sys.exit(main())