Files
Gitea/scripts/github-migration/verify.py
T
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

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