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>
110 lines
4.3 KiB
Python
Executable File
110 lines
4.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Compare migrated repositories between GitHub and Gitea. Read-only on both.
|
|
|
|
Checks, per repository: every branch and tag points at the same commit; issue,
|
|
pull request and release counts match; archived flag and visibility match.
|
|
|
|
Usage: GITEA_TOKEN=... verify.py repos.json [name ...]
|
|
GitHub is read through the local `gh` CLI.
|
|
"""
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
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"]
|
|
|
|
|
|
def gh_api(path):
|
|
out = subprocess.run(["gh", "api", "--paginate", path], check=True, capture_output=True, text=True).stdout
|
|
# --paginate concatenates JSON arrays as `][`; join them into one.
|
|
return json.loads(out.replace("]\n[", ",").replace("][", ",")) if out.strip() else []
|
|
|
|
|
|
def gh_counts(name):
|
|
q = ('query($o:String!,$n:String!){repository(owner:$o,name:$n){'
|
|
'issues{totalCount} pullRequests{totalCount} releases{totalCount}}}')
|
|
out = subprocess.run(["gh", "api", "graphql", "-f", f"query={q}", "-f", f"o={OWNER}", "-f", f"n={name}"],
|
|
check=True, capture_output=True, text=True).stdout
|
|
r = json.loads(out)["data"]["repository"]
|
|
return {"issues": r["issues"]["totalCount"], "pulls": r["pullRequests"]["totalCount"],
|
|
"releases": r["releases"]["totalCount"]}
|
|
|
|
|
|
def gitea_get(path):
|
|
req = urllib.request.Request(f"{GITEA}/api/v1{path}", headers={"Authorization": f"token {GITEA_TOKEN}"})
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
return json.loads(r.read()), r.headers.get("X-Total-Count")
|
|
|
|
|
|
def gitea_all(path):
|
|
items, page = [], 1
|
|
sep = "&" if "?" in path else "?"
|
|
while True:
|
|
batch, _ = gitea_get(f"{path}{sep}limit=50&page={page}")
|
|
batch = batch or [] # an empty repository returns null, not []
|
|
items += batch
|
|
if len(batch) < 50:
|
|
return items
|
|
page += 1
|
|
|
|
|
|
def gitea_count(path):
|
|
_, total = gitea_get(path + ("&" if "?" in path else "?") + "limit=1")
|
|
return int(total or 0)
|
|
|
|
|
|
def verify(repo):
|
|
name = repo["name"]
|
|
problems = []
|
|
try:
|
|
g, _ = gitea_get(f"/repos/{OWNER}/{name}")
|
|
except urllib.error.HTTPError as e:
|
|
return {"name": name, "ok": False, "problems": [f"not in gitea ({e.code})"]}
|
|
|
|
if g["archived"] != repo["isArchived"]:
|
|
problems.append(f"archived gitea={g['archived']} github={repo['isArchived']}")
|
|
if g["private"] != (repo["visibility"] != "PUBLIC"):
|
|
problems.append(f"private gitea={g['private']} github={repo['visibility']}")
|
|
|
|
gh_branches = {b["name"]: b["commit"]["sha"] for b in gh_api(f"repos/{OWNER}/{name}/branches")}
|
|
gt_branches = {b["name"]: b["commit"]["id"] for b in gitea_all(f"/repos/{OWNER}/{name}/branches")}
|
|
gh_tags = {t["name"]: t["commit"]["sha"] for t in gh_api(f"repos/{OWNER}/{name}/tags")}
|
|
gt_tags = {t["name"]: t["commit"]["sha"] for t in gitea_all(f"/repos/{OWNER}/{name}/tags")}
|
|
for kind, a, b in (("branch", gh_branches, gt_branches), ("tag", gh_tags, gt_tags)):
|
|
for ref in sorted(set(a) | set(b)):
|
|
if a.get(ref) != b.get(ref):
|
|
problems.append(f"{kind} {ref}: github={(a.get(ref) or 'missing')[:10]} gitea={(b.get(ref) or 'missing')[:10]}")
|
|
|
|
gh = gh_counts(name)
|
|
gt = {"issues": gitea_count(f"/repos/{OWNER}/{name}/issues?state=all&type=issues"),
|
|
"pulls": gitea_count(f"/repos/{OWNER}/{name}/issues?state=all&type=pulls"),
|
|
"releases": gitea_count(f"/repos/{OWNER}/{name}/releases")}
|
|
for k in gh:
|
|
if gh[k] != gt[k]:
|
|
problems.append(f"{k}: github={gh[k]} gitea={gt[k]}")
|
|
|
|
return {"name": name, "ok": not problems, "branches": len(gt_branches), "tags": len(gt_tags),
|
|
**{f"gitea_{k}": v for k, v in gt.items()}, "problems": problems}
|
|
|
|
|
|
def main():
|
|
repos_file, *names = sys.argv[1:]
|
|
repos = [r for r in json.load(open(repos_file)) if not r["isFork"]]
|
|
if names:
|
|
repos = [r for r in repos if r["name"] in set(names)]
|
|
for repo in repos:
|
|
try:
|
|
res = verify(repo)
|
|
except Exception as e:
|
|
res = {"name": repo["name"], "ok": False, "problems": [f"verify error: {e!r}"]}
|
|
print(json.dumps(res), flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|