These moved every non-fork JMR-dev repository from GitHub into this Gitea
instance and made GitHub a push mirror. They are kept for re-runs and
for rotating the mirror PAT, which expires and fails silently.
- migrate.py full one-time migration (code, issues, PRs, releases,
wiki, LFS); skips anything already on Gitea
- verify.py compares branch/tag shas, issue/PR/release counts, and
the archived and visibility flags; read-only
- repoint.py re-points local clones' JMR-dev remotes at Gitea,
following GitHub renames; dry run unless --apply
- pushmirror.py sync-on-commit push mirrors to GitHub, created only when
every GitHub branch and tag already matches Gitea, so
the first force-push/prune cannot delete anything
Tokens come from the environment, sourced from Secret Manager. None
appear in these files.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
4.8 KiB
Python
Executable File
126 lines
4.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Copy GitHub repositories into Gitea as full, one-time migrations.
|
|
|
|
Non-destructive by construction:
|
|
* GitHub is only ever read (clone + REST API through Gitea's downloader).
|
|
* A repository that already exists in Gitea is never deleted or overwritten;
|
|
it is skipped, so the script is safe to re-run after a partial failure.
|
|
* The only Gitea-side change to an existing repository is setting the archived
|
|
flag on a repository that is archived on GitHub.
|
|
|
|
Usage:
|
|
GITEA_TOKEN=... [GITHUB_TOKEN=...] migrate.py repos.json results.jsonl [name ...]
|
|
|
|
repos.json is `gh repo list OWNER --json name,visibility,isFork,isArchived,
|
|
diskUsage,description,defaultBranchRef`. Forks are always skipped. With names,
|
|
only those repositories are processed, in the order given; otherwise every
|
|
non-fork, smallest first.
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
GITEA = os.environ.get("GITEA_URL", "https://gitea.jasonmross.dev").rstrip("/")
|
|
OWNER = os.environ.get("OWNER", "JMR-dev")
|
|
GITEA_TOKEN = os.environ["GITEA_TOKEN"]
|
|
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
|
|
|
|
|
|
def gitea(method, path, body=None, timeout=60):
|
|
req = urllib.request.Request(
|
|
f"{GITEA}/api/v1{path}",
|
|
method=method,
|
|
data=None if body is None else json.dumps(body).encode(),
|
|
headers={"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
raw = r.read()
|
|
return r.status, (json.loads(raw) if raw else None)
|
|
except urllib.error.HTTPError as e:
|
|
raw = e.read()
|
|
try:
|
|
return e.code, json.loads(raw)
|
|
except ValueError:
|
|
return e.code, {"message": raw.decode(errors="replace")[:500]}
|
|
|
|
|
|
def migrate_one(repo):
|
|
name = repo["name"]
|
|
status, existing = gitea("GET", f"/repos/{OWNER}/{name}")
|
|
if status == 200:
|
|
result = {"result": "exists-skipped"}
|
|
if repo["isArchived"] and not existing.get("archived"):
|
|
s, _ = gitea("PATCH", f"/repos/{OWNER}/{name}", {"archived": True})
|
|
result["archived_set"] = s == 200
|
|
return result
|
|
if status != 404:
|
|
return {"result": "error", "stage": "lookup", "status": status, "detail": existing}
|
|
|
|
body = {
|
|
"clone_addr": f"https://github.com/{OWNER}/{name}.git",
|
|
"service": "github",
|
|
"repo_owner": OWNER,
|
|
"repo_name": name,
|
|
"private": repo["visibility"] != "PUBLIC",
|
|
"description": (repo.get("description") or "")[:2048],
|
|
"mirror": False,
|
|
"wiki": True,
|
|
"issues": True,
|
|
"labels": True,
|
|
"milestones": True,
|
|
"pull_requests": True,
|
|
"releases": True,
|
|
"lfs": True,
|
|
}
|
|
if GITHUB_TOKEN:
|
|
body["auth_token"] = GITHUB_TOKEN
|
|
|
|
started = time.time()
|
|
# The API migrates synchronously; a repository with hundreds of PRs takes
|
|
# many minutes, mostly in Gitea's rate-limit-aware GitHub downloader.
|
|
status, resp = gitea("POST", "/repos/migrate", body, timeout=4 * 3600)
|
|
elapsed = round(time.time() - started, 1)
|
|
if status != 201:
|
|
return {"result": "error", "stage": "migrate", "status": status, "seconds": elapsed,
|
|
"detail": (resp or {}).get("message", resp)}
|
|
|
|
result = {"result": "migrated", "seconds": elapsed, "private": resp.get("private")}
|
|
if repo["isArchived"]:
|
|
s, _ = gitea("PATCH", f"/repos/{OWNER}/{name}", {"archived": True})
|
|
result["archived_set"] = s == 200
|
|
return result
|
|
|
|
|
|
def main():
|
|
repos_file, results_file, *names = sys.argv[1:]
|
|
repos = [r for r in json.load(open(repos_file)) if not r["isFork"]]
|
|
if names:
|
|
by_name = {r["name"]: r for r in repos}
|
|
missing = [n for n in names if n not in by_name]
|
|
if missing:
|
|
sys.exit(f"not in {repos_file} (or a fork): {', '.join(missing)}")
|
|
repos = [by_name[n] for n in names]
|
|
else:
|
|
repos.sort(key=lambda r: r["diskUsage"])
|
|
|
|
print(f"{len(repos)} repositories; github token: {'yes' if GITHUB_TOKEN else 'NO'}", flush=True)
|
|
with open(results_file, "a") as out:
|
|
for i, repo in enumerate(repos, 1):
|
|
print(f"[{i}/{len(repos)}] {repo['name']} ({repo['diskUsage']} KB) ...", flush=True)
|
|
try:
|
|
result = migrate_one(repo)
|
|
except Exception as e: # keep going; one bad repository must not stop the batch
|
|
result = {"result": "error", "stage": "exception", "detail": repr(e)}
|
|
result = {"name": repo["name"], "ts": time.strftime("%H:%M:%S"), **result}
|
|
out.write(json.dumps(result) + "\n")
|
|
out.flush()
|
|
print(f" -> {result['result']} {json.dumps({k: v for k, v in result.items() if k not in ('name', 'result', 'ts')})}", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|