G7.4/G11.3: import tool — GitHub upstream, Windy Git downstream, CI here
All checks were successful
check / gate (push) Successful in 17s
All checks were successful
check / gate (push) Successful in 17s
Direction is deliberate. For the migration quarter GitHub stays upstream and Windy Git is a PULL mirror, so it cannot diverge — worst case it is stale, not wrong. Making Windy Git authoritative before Grant flips G11.6 would create a two-writer problem nobody asked for. I-4's push-mirror is the steady state for repos that originate here. Refuses windy-pro by name: six checkouts, a build counter forked three ways, and two sessions recording different HEADs hours apart. That gets resolved by reading, not by importing (G11.5). Records the Cloudflare trap: the public endpoint answers 403 error 1010 because CF blocks urllib's user-agent as a bot signature. It reads like Gitea rejecting the token and is not — the identical call against localhost:3080 on the host succeeds. Bulk import belongs on the host anyway. Verified: windy-calendar imported, 731 KB, mirroring. Survey: 135 private repos, and GitHub Actions cannot run on ANY of them. Sampled windy-pro, windy-mind, eternitas, windy-registry, windy-agent, WindyCloud — every recent run is startup_failure, as recently as today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
176
scripts/import_from_github.py
Executable file
176
scripts/import_from_github.py
Executable file
@@ -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())
|
||||
Reference in New Issue
Block a user