Files
Gitea/scripts/github-migration/migrate.py
JMR-devandClaude Opus 5.5 f672019c91 Add the GitHub to Gitea migration scripts
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>
2026-10-10 18:16:18 +07:00

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()