Files
Gogs2GiteaMigration/migrate_gogs_to_gitea.py

708 lines
31 KiB
Python

#!/usr/bin/env python3
"""Migration Gogs -> Gitea (figureslibres.io) : dépôts git + collaborateurs + webhooks.
Explicitement HORS scope : issues, wiki, milestones, labels, releases, pull requests.
Fonctionnement :
- la liste des dépôts Gogs est reconstituée propriétaire par propriétaire : on part
de la liste des comptes/orgs connus côté Gitea (admin, synchronisés LDAP YunoHost)
puis on interroge /api/v1/users|orgs/{login}/repos sur Gogs pour chacun.
(/api/v1/repos/search a été testé et écarté : même avec un token admin il ne
renvoie que les dépôts publics + ceux du compte du token, pas tout le site) ;
- chaque dépôt est rapatrié via l'API Gitea native /repos/migrate (service=git,
volontairement PAS service=gogs : ce dernier interroge d'abord l'API Gogs et
utilise son "clone_url" public, qui repasse par le mur SSO -- service=git fait
un clone brut sur l'adresse fournie, donc reste bien sur le loopback) ;
- un dépôt déjà présent côté Gitea (ex: migré à la main) N'EST JAMAIS RECRÉÉ NI
ÉCRASÉ -- seuls les collaborateurs / webhooks / topic peuvent être complétés ;
- les contributeurs Gogs sont vérifiés un par un côté Gitea avant d'être ajoutés ;
ceux qui n'existent pas sont listés dans le rapport, jamais créés automatiquement ;
- chaque dépôt migré (ou complété) reçoit le topic Gitea "migrated-from-gogs" pour
pouvoir les repérer facilement dans l'UI Gitea ;
- le statut migré/non-migré de CHAQUE dépôt Gogs examiné est consigné dans le
rapport JSON (champ "status") -- rien n'est écrit dans Gogs lui-même : son API
n'expose pas d'endpoint d'édition de dépôt (PATCH /repos/:owner/:repo -> 404),
et modifier ça via le formulaire web aurait risqué de toucher au champ de
visibilité (privé/public) des dépôts.
Par défaut le script tourne en DRY-RUN : rien n'est écrit nulle part, seul un plan
détaillé est affiché et enregistré dans un rapport JSON. Il faut passer --apply pour
exécuter réellement les actions.
Configuration :
1. cp config.env.example config.env
2. éditer config.env et renseigner les identifiants des comptes admin Gogs et Gitea
(config.env n'est jamais affiché ni loggé par ce script)
Note : l'API Gogs de cette instance rejette systématiquement l'authentification HTTP
Basic Auth (401 identique avec de bons identifiants, un mauvais mot de passe ou un
utilisateur inexistant -- Basic Auth semble désactivée côté serveur). L'API Gogs
utilise donc un TOKEN (GOGS_TOKEN, à générer via Gogs -> Settings -> Applications ->
Generate New Token) ; GOGS_ADMIN_USERNAME/PASSWORD ne servent plus qu'à l'étape de
clonage git (auth_username/auth_password transmis à Gitea pour /repos/migrate).
Exemples :
python3 migrate_gogs_to_gitea.py # dry-run, tous les dépôts
python3 migrate_gogs_to_gitea.py --only bachir/monrepo # dry-run, un seul dépôt
python3 migrate_gogs_to_gitea.py --limit 3 # dry-run, 3 premiers dépôts
python3 migrate_gogs_to_gitea.py --apply --only bachir/monrepo # exécution réelle, ciblée
python3 migrate_gogs_to_gitea.py --apply # exécution réelle, tout
python3 migrate_gogs_to_gitea.py --verify-existing # compare les refs git (branches/tags)
# Gogs vs Gitea pour les dépôts déjà
# présents côté Gitea -- lecture seule,
# ne pousse/écrase jamais rien sur Gitea.
"""
import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from urllib.parse import quote
import requests
SCRIPT_DIR = Path(__file__).resolve().parent
ENV_FILE = SCRIPT_DIR / "config.env"
DEFAULTS = {
"GOGS_URL": "http://127.0.0.1:17750",
"GITEA_URL": "http://127.0.0.1:6002",
"MIGRATION_TOPIC": "migrated-from-gogs",
"AUTO_CREATE_ORGS": "true",
"WEBHOOK_EXCLUDE_SUBSTR": "figureslibres.io/kanboard",
}
def load_config():
cfg = dict(DEFAULTS)
if ENV_FILE.exists():
for line in ENV_FILE.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
cfg[k.strip()] = v.strip().strip('"').strip("'")
for k in ("GOGS_URL", "GITEA_URL", "GOGS_ADMIN_USERNAME", "GOGS_ADMIN_PASSWORD", "GOGS_TOKEN",
"GITEA_ADMIN_USERNAME", "GITEA_ADMIN_PASSWORD", "MIGRATION_TOPIC", "AUTO_CREATE_ORGS",
"WEBHOOK_EXCLUDE_SUBSTR"):
if k in os.environ:
cfg[k] = os.environ[k]
missing = [k for k in ("GOGS_ADMIN_USERNAME", "GOGS_ADMIN_PASSWORD", "GOGS_TOKEN",
"GITEA_ADMIN_USERNAME", "GITEA_ADMIN_PASSWORD") if not cfg.get(k)]
if missing:
sys.exit(
f"Config manquante: {', '.join(missing)}. "
f"Copie config.env.example vers config.env et renseigne ces valeurs."
)
return cfg
_NAV_KEYWORDS = {
"", "explore", "user", "admin", "help", "api", "issues", "assets", "css", "js", "img",
"organizations", "repos", "users", "login", "avatars",
}
def discover_gogs_orgs(base_url):
# GET /api/v1/orgs/{login} ne vérifie PAS le type sur cette instance : il renvoie
# 200 pour N'IMPORTE QUEL login existant (user OU org), vérifié empiriquement sur
# "bachir" (un simple utilisateur). On ne peut donc pas s'y fier pour distinguer
# user/org -- on scrape la page publique /explore/organizations à la place, qui
# elle liste les VRAIES organisations. Le préfixe (ROOT_URL) est lu dynamiquement
# sur data-suburl plutôt que codé en dur.
orgs = set()
s = requests.Session()
base = base_url.rstrip("/")
suburl = ""
for page in range(1, 10):
try:
r = s.get(f"{base}/explore/organizations", params={"page": page}, timeout=15)
except requests.RequestException:
break
if r.status_code != 200:
break
if page == 1:
m = re.search(r'data-suburl="([^"]*)"', r.text)
suburl = m.group(1) if m else ""
# un seul segment de chemin après le suburl, pas de point (exclut les assets
# .css/.js/.png et les liens externes), pour ne matcher que de vrais logins.
pattern = re.escape(suburl) + r'/([a-zA-Z0-9_\-]+)"'
found = {m for m in re.findall(pattern, r.text) if m not in _NAV_KEYWORDS}
before = len(orgs)
orgs |= found
if len(orgs) == before:
break
return orgs
class GogsClient:
def __init__(self, base_url, token, known_orgs=frozenset()):
self.base = base_url.rstrip("/") + "/api/v1"
self.s = requests.Session()
self.s.headers.update({"Accept": "application/json", "Authorization": f"token {token}"})
self.known_orgs = set(known_orgs)
def repos_for_owner(self, login):
# /repos/search ne fait PAS une vraie recherche site-wide même pour un compte
# admin (vérifié empiriquement : il ne renvoie que les dépôts publics + ceux
# possédés/collaborés par le compte du token). On énumère donc propriétaire
# par propriétaire via /users/{login}/repos ou /orgs/{login}/repos.
for kind in ("users", "orgs"):
r = self.s.get(f"{self.base}/{kind}/{login}/repos", timeout=30)
if r.status_code == 200:
return r.json() or []
if r.status_code != 404:
r.raise_for_status()
return []
def list_all_repos(self, candidate_owners):
by_full_name = {}
for owner in candidate_owners:
for r in self.repos_for_owner(owner):
by_full_name[r["full_name"]] = r
return list(by_full_name.values())
def is_org(self, login):
return login in self.known_orgs
def collaborators(self, owner, repo):
r = self.s.get(f"{self.base}/repos/{owner}/{repo}/collaborators", timeout=30)
if r.status_code == 404:
return []
r.raise_for_status()
return r.json() or []
def hooks(self, owner, repo):
r = self.s.get(f"{self.base}/repos/{owner}/{repo}/hooks", timeout=30)
if r.status_code == 404:
return []
r.raise_for_status()
return r.json() or []
class GiteaClient:
def __init__(self, base_url, username, password):
self.base = base_url.rstrip("/") + "/api/v1"
self.s = requests.Session()
self.s.auth = (username, password)
self.s.headers.update({"Accept": "application/json"})
def _list_paginated(self, path):
items, page = [], 1
while True:
r = self.s.get(f"{self.base}{path}", params={"limit": 50, "page": page}, timeout=30)
r.raise_for_status()
data = r.json()
if not data:
break
items.extend(data)
if 'rel="next"' not in r.headers.get("Link", ""):
break
page += 1
return items
def list_all_usernames(self):
# source de vérité pour la découverte des dépôts Gogs : ces comptes sont
# synchronisés via le LDAP YunoHost, donc identiques entre Gogs et Gitea.
return [u["login"] for u in self._list_paginated("/admin/users")]
def list_all_orgnames(self):
return [o.get("username") or o.get("name") for o in self._list_paginated("/admin/orgs")]
def user_exists(self, username):
r = self.s.get(f"{self.base}/users/{username}", timeout=30)
if r.status_code not in (200, 404):
r.raise_for_status()
return r.status_code == 200
def org_exists(self, org):
r = self.s.get(f"{self.base}/orgs/{org}", timeout=30)
if r.status_code not in (200, 404):
r.raise_for_status()
return r.status_code == 200
def create_org(self, username, visibility="private"):
r = self.s.post(f"{self.base}/orgs", json={"username": username, "visibility": visibility}, timeout=30)
r.raise_for_status()
return r.json()
def repo_exists(self, owner, repo):
r = self.s.get(f"{self.base}/repos/{owner}/{repo}", timeout=30)
if r.status_code not in (200, 404):
r.raise_for_status()
return r.status_code == 200
def get_repo(self, owner, repo):
r = self.s.get(f"{self.base}/repos/{owner}/{repo}", timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def migrate_repo(self, clone_addr, repo_owner, repo_name, private,
description, auth_username, auth_password):
payload = {
"clone_addr": clone_addr,
"repo_owner": repo_owner,
"repo_name": repo_name,
"service": "git",
"private": private,
"description": description or "",
"mirror": False,
"issues": False,
"labels": False,
"milestones": False,
"pull_requests": False,
"releases": False,
"wiki": False,
"lfs": True,
"auth_username": auth_username,
"auth_password": auth_password,
}
r = self.s.post(f"{self.base}/repos/migrate", json=payload, timeout=300)
r.raise_for_status()
return r.json()
def list_collaborators(self, owner, repo):
r = self.s.get(f"{self.base}/repos/{owner}/{repo}/collaborators", timeout=30)
if r.status_code == 404:
return []
r.raise_for_status()
return [u["login"] for u in r.json()]
def add_collaborator(self, owner, repo, username, permission):
r = self.s.put(
f"{self.base}/repos/{owner}/{repo}/collaborators/{username}",
json={"permission": permission}, timeout=30,
)
r.raise_for_status()
def list_hooks(self, owner, repo):
r = self.s.get(f"{self.base}/repos/{owner}/{repo}/hooks", timeout=30)
if r.status_code == 404:
return []
r.raise_for_status()
return r.json() or []
def create_hook(self, owner, repo, hook_type, config, events, active):
r = self.s.post(
f"{self.base}/repos/{owner}/{repo}/hooks",
json={"type": hook_type, "config": config, "events": events, "active": active},
timeout=30,
)
r.raise_for_status()
return r.json()
def get_topics(self, owner, repo):
r = self.s.get(f"{self.base}/repos/{owner}/{repo}/topics", timeout=30)
if r.status_code == 404:
return []
r.raise_for_status()
return r.json().get("topics", [])
def set_topics(self, owner, repo, topics):
r = self.s.put(f"{self.base}/repos/{owner}/{repo}/topics", json={"topics": topics}, timeout=30)
r.raise_for_status()
# Gogs collaborator objects n'exposent pas toujours un niveau de permission explicite
# selon la version -- si absent, on suppose "write" (c'est le sens historique de
# "collaborateur" chez Gogs). Vérifié/affiché en dry-run avant toute exécution réelle.
def guess_permission(gogs_collaborator):
perm = gogs_collaborator.get("permission") or gogs_collaborator.get("permissions")
if isinstance(perm, dict):
if perm.get("admin"):
return "admin"
if perm.get("push") or perm.get("write"):
return "write"
return "read"
if isinstance(perm, str) and perm in ("read", "write", "admin"):
return perm
return "write"
def is_migrated(entry):
return "migrate_git" in entry["actions"] or "skip_git_migrate_already_exists" in entry["actions"]
def build_plan(gogs, gitea, cfg, only=None, limit=None):
candidate_owners = set(gitea.list_all_usernames()) | set(gitea.list_all_orgnames()) | gogs.known_orgs
repos = gogs.list_all_repos(candidate_owners)
if only:
wanted = set(only)
repos = [r for r in repos if r["full_name"] in wanted]
if limit:
repos = repos[:limit]
plan = []
for r in repos:
owner = r["owner"]["username"]
name = r["name"]
full_name = r["full_name"]
entry = {
"full_name": full_name,
"owner": owner,
"name": name,
"private": r["private"],
"description": r.get("description", ""),
"clone_addr": f"{cfg['GOGS_URL'].rstrip('/')}/{owner}/{name}.git",
"owner_is_org": gogs.is_org(owner),
"owner_exists_on_gitea": None,
"repo_exists_on_gitea": None,
"actions": [],
"collaborators": [],
"missing_collaborators": [],
"hooks": [],
"excluded_hooks": [],
"warnings": [],
}
if entry["owner_is_org"]:
entry["owner_exists_on_gitea"] = gitea.org_exists(owner)
if not entry["owner_exists_on_gitea"]:
if cfg.get("AUTO_CREATE_ORGS", "true").lower() == "true":
entry["actions"].append("create_org")
else:
entry["warnings"].append("organisation absente côté Gitea et AUTO_CREATE_ORGS=false -> dépôt ignoré")
entry["status"] = "NON MIGRÉ"
plan.append(entry)
continue
else:
entry["owner_exists_on_gitea"] = gitea.user_exists(owner)
if not entry["owner_exists_on_gitea"]:
entry["warnings"].append(
"utilisateur propriétaire absent côté Gitea -> dépôt ignoré "
"(les comptes utilisateurs ne sont jamais créés automatiquement)"
)
entry["status"] = "NON MIGRÉ"
plan.append(entry)
continue
entry["repo_exists_on_gitea"] = gitea.repo_exists(owner, name)
if entry["repo_exists_on_gitea"]:
entry["actions"].append("skip_git_migrate_already_exists")
else:
entry["actions"].append("migrate_git")
# collaborateurs -- on exclut le compte de service (celui utilisé pour le
# token Gogs/l'auth git), ajouté aux dépôts privés uniquement pour pouvoir
# les découvrir, jamais destiné à rester collaborateur côté Gitea.
existing_target_collabs = gitea.list_collaborators(owner, name) if entry["repo_exists_on_gitea"] else []
for c in gogs.collaborators(owner, name):
username = c.get("username") or c.get("login")
if not username or username == cfg["GOGS_ADMIN_USERNAME"]:
continue
if not gitea.user_exists(username):
entry["missing_collaborators"].append(username)
continue
perm = guess_permission(c)
already = username in existing_target_collabs
entry["collaborators"].append({"username": username, "permission": perm, "already_present": already})
if not already:
entry["actions"].append(f"add_collaborator:{username}:{perm}")
# webhooks -- on exclut ceux pointant vers l'URL kanboard interne (secrets non
# transférables, migration explicitement écartée à la demande de l'utilisateur)
exclude_substr = cfg.get("WEBHOOK_EXCLUDE_SUBSTR", "")
existing_target_hooks = gitea.list_hooks(owner, name) if entry["repo_exists_on_gitea"] else []
existing_urls = {h.get("config", {}).get("url") for h in existing_target_hooks}
for h in gogs.hooks(owner, name):
config = h.get("config", {}) or {}
url = config.get("url")
if exclude_substr and url and exclude_substr in url:
entry["excluded_hooks"].append(url)
continue
has_secret = bool(config.get("secret"))
hook_info = {
"type": h.get("type", "gogs"),
"url": url,
"events": h.get("events", []),
"active": h.get("active", True),
"already_present": url in existing_urls,
"secret_transferable": has_secret,
}
if not has_secret:
entry["warnings"].append(
f"webhook {url}: secret non exposé par l'API Gogs -> à ressaisir "
f"manuellement côté Gitea si ce hook en utilise un"
)
entry["hooks"].append(hook_info)
if not hook_info["already_present"]:
entry["actions"].append(f"create_hook:{url}")
# topic de tag côté Gitea
topics = gitea.get_topics(owner, name) if entry["repo_exists_on_gitea"] else []
if cfg["MIGRATION_TOPIC"] not in topics:
entry["actions"].append(f"add_topic:{cfg['MIGRATION_TOPIC']}")
# statut prévisionnel, mis à jour après coup si --apply échoue sur ce dépôt
entry["status"] = "MIGRÉ" if is_migrated(entry) else "NON MIGRÉ"
plan.append(entry)
return plan
def apply_plan(gogs, gitea, cfg, plan):
for entry in plan:
owner, name = entry["owner"], entry["name"]
try:
if "create_org" in entry["actions"]:
gitea.create_org(owner)
print(f" [org créée] {owner}")
if "migrate_git" in entry["actions"]:
gitea.migrate_repo(
clone_addr=entry["clone_addr"],
repo_owner=owner,
repo_name=name,
private=entry["private"],
description=entry["description"],
auth_username=cfg["GOGS_ADMIN_USERNAME"],
auth_password=cfg["GOGS_ADMIN_PASSWORD"],
)
print(f" [git migré] {entry['full_name']}")
for c in entry["collaborators"]:
if not c["already_present"]:
gitea.add_collaborator(owner, name, c["username"], c["permission"])
print(f" [collaborateur ajouté] {entry['full_name']} <- {c['username']} ({c['permission']})")
for h in entry["hooks"]:
if not h["already_present"] and h["url"]:
gitea.create_hook(owner, name, h["type"], {"url": h["url"], "content_type": "json"},
h["events"], h["active"])
print(f" [webhook créé] {entry['full_name']} -> {h['url']}")
if any(a.startswith("add_topic:") for a in entry["actions"]):
topics = gitea.get_topics(owner, name)
topic = cfg["MIGRATION_TOPIC"]
if topic not in topics:
gitea.set_topics(owner, name, topics + [topic])
print(f" [topic ajouté] {entry['full_name']} -> {topic}")
entry["applied"] = True
except requests.HTTPError as e:
entry["applied"] = False
entry["error"] = f"{e} -- {e.response.text[:300] if e.response is not None else ''}"
print(f" [ERREUR] {entry['full_name']}: {entry['error']}")
except Exception as e:
entry["applied"] = False
entry["error"] = str(e)
print(f" [ERREUR] {entry['full_name']}: {e}")
entry["status"] = "MIGRÉ" if (entry.get("applied") and is_migrated(entry)) else "NON MIGRÉ"
def print_summary(plan, apply_mode):
total = len(plan)
ignored = [e for e in plan if not e["actions"] and e["warnings"]]
to_create = [e for e in plan if "migrate_git" in e["actions"]]
already = [e for e in plan if "skip_git_migrate_already_exists" in e["actions"]]
missing_users = sorted({u for e in plan for u in e["missing_collaborators"]})
hook_secret_warnings = [e for e in plan for w in e["warnings"] if "secret non exposé" in w]
print("\n" + "=" * 70)
print(f"{'APPLICATION RÉELLE' if apply_mode else 'DRY-RUN (aucune écriture)'} -- résumé")
print("=" * 70)
print(f"Dépôts examinés : {total}")
print(f" -> à migrer (git) : {len(to_create)}")
print(f" -> déjà présents (skip) : {len(already)}")
print(f" -> ignorés (owner absent): {len(ignored)}")
if missing_users:
print(f"Contributeurs Gogs absents de Gitea ({len(missing_users)}) : {', '.join(missing_users)}")
if hook_secret_warnings:
print(f"Webhooks avec secret non transférable : {len(hook_secret_warnings)} (voir rapport)")
print(f"Statut (voir champ 'status' du rapport) : "
f"{sum(1 for e in plan if e['status'] == 'MIGRÉ')} 'MIGRÉ', "
f"{sum(1 for e in plan if e['status'] == 'NON MIGRÉ')} 'NON MIGRÉ'")
print("=" * 70)
for e in plan:
if e["warnings"] and not e["actions"]:
print(f"IGNORÉ {e['full_name']}: {'; '.join(e['warnings'])}")
def _clone_url_with_auth(base_url, owner, name, username, password):
scheme, rest = base_url.rstrip("/").split("://", 1)
return f"{scheme}://{quote(username, safe='')}:{quote(password, safe='')}@{rest}/{owner}/{name}.git"
def git_refs(url):
r = subprocess.run(
["git", "ls-remote", "--heads", "--tags", url],
capture_output=True, text=True, timeout=60,
)
if r.returncode != 0:
raise RuntimeError(r.stderr.strip() or f"git ls-remote a échoué (code {r.returncode})")
refs = {}
for line in r.stdout.splitlines():
if not line.strip():
continue
sha, ref = line.split("\t", 1)
refs[ref] = sha
return refs
def push_missing_refs(cfg, owner, name, missing_refs):
# N'ajoute QUE des refs totalement absentes côté Gitea (vérifié par l'appelant
# via missing_in_gitea) -- push sans --force, donc impossible d'écraser quoi que
# ce soit d'existant. Les branches divergentes ("Gitea a avancé") ne sont jamais
# touchées ici, volontairement.
import tempfile
gogs_url = _clone_url_with_auth(cfg["GOGS_URL"], owner, name, cfg["GOGS_ADMIN_USERNAME"], cfg["GOGS_ADMIN_PASSWORD"])
gitea_url = _clone_url_with_auth(cfg["GITEA_URL"], owner, name, cfg["GITEA_ADMIN_USERNAME"], cfg["GITEA_ADMIN_PASSWORD"])
results = {}
with tempfile.TemporaryDirectory() as tmp:
subprocess.run(["git", "init", "-q", "--bare", tmp], check=True, timeout=30)
for ref in missing_refs:
try:
subprocess.run(
["git", "fetch", "-q", gogs_url, f"{ref}:{ref}"],
cwd=tmp, check=True, capture_output=True, text=True, timeout=90,
)
subprocess.run(
["git", "push", "-q", gitea_url, f"{ref}:{ref}"],
cwd=tmp, check=True, capture_output=True, text=True, timeout=90,
)
results[ref] = {"ok": True}
except subprocess.CalledProcessError as ex:
results[ref] = {"ok": False, "error": (ex.stderr or str(ex)).strip()[:300]}
except subprocess.TimeoutExpired:
results[ref] = {"ok": False, "error": "timeout"}
return results
def verify_existing(cfg, plan):
# Compare les refs (branches + tags) Gogs vs Gitea pour les dépôts déjà présents
# côté Gitea (migrés à la main). Lecture seule (git ls-remote) : ne clone, ne
# pousse et n'écrase jamais rien -- Gitea reste la référence dans tous les cas.
# L'ajout des refs manquantes (si demandé) se fait séparément via
# apply_missing_refs(), jamais dans cette fonction.
targets = [e for e in plan if "skip_git_migrate_already_exists" in e["actions"]]
results = []
print(f"\nVérification des refs git (lecture seule) sur {len(targets)} dépôt(s) déjà présents côté Gitea...")
for e in targets:
owner, name = e["owner"], e["name"]
gogs_url = _clone_url_with_auth(cfg["GOGS_URL"], owner, name, cfg["GOGS_ADMIN_USERNAME"], cfg["GOGS_ADMIN_PASSWORD"])
gitea_url = _clone_url_with_auth(cfg["GITEA_URL"], owner, name, cfg["GITEA_ADMIN_USERNAME"], cfg["GITEA_ADMIN_PASSWORD"])
entry = {"full_name": e["full_name"], "owner": owner, "name": name}
try:
gogs_refs = git_refs(gogs_url)
gitea_refs = git_refs(gitea_url)
except Exception as ex:
entry["error"] = str(ex)
results.append(entry)
print(f" [ERREUR] {e['full_name']}: {ex}")
continue
common = set(gogs_refs) & set(gitea_refs)
missing_in_gitea = sorted(set(gogs_refs) - set(gitea_refs))
extra_in_gitea = sorted(set(gitea_refs) - set(gogs_refs))
diverged = sorted(ref for ref in common if gogs_refs[ref] != gitea_refs[ref])
matching = sorted(ref for ref in common if gogs_refs[ref] == gitea_refs[ref])
entry.update({
"gogs_ref_count": len(gogs_refs),
"gitea_ref_count": len(gitea_refs),
"missing_in_gitea": missing_in_gitea,
"extra_in_gitea": extra_in_gitea,
"diverged": [{"ref": r, "gogs_sha": gogs_refs[r], "gitea_sha": gitea_refs[r]} for r in diverged],
"matching_count": len(matching),
})
status = "OK" if not missing_in_gitea and not diverged else "ÉCART"
print(f" [{status}] {e['full_name']} -- "
f"{len(matching)} identiques, {len(missing_in_gitea)} manquante(s) sur Gitea, "
f"{len(diverged)} divergente(s) (jamais touchées), {len(extra_in_gitea)} en plus sur Gitea")
if missing_in_gitea:
print(f" manquantes: {', '.join(missing_in_gitea)}")
if diverged:
print(f" divergentes (ignorées, Gitea reste tel quel): {', '.join(diverged)}")
results.append(entry)
return results
def apply_missing_refs(cfg, verify_results):
# Deuxième passe, séparée de verify_existing() : pousse UNIQUEMENT les refs
# identifiées comme totalement absentes lors de la vérification (pas de nouvel
# appel git ls-remote, on pousse exactement ce qui a été montré à l'utilisateur).
for entry in verify_results:
if not entry.get("missing_in_gitea"):
continue
owner, name = entry["owner"], entry["name"]
print(f" {entry['full_name']} -- ajout de {len(entry['missing_in_gitea'])} ref(s)...")
push_results = push_missing_refs(cfg, owner, name, entry["missing_in_gitea"])
entry["push_results"] = push_results
for ref, res in push_results.items():
if res["ok"]:
print(f" [ajouté] {ref}")
else:
print(f" [ÉCHEC] {ref}: {res['error']}")
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--apply", action="store_true", help="exécute réellement (par défaut: dry-run)")
ap.add_argument("--only", action="append", help="ne traiter que owner/repo (répétable)")
ap.add_argument("--limit", type=int, help="limiter aux N premiers dépôts trouvés")
ap.add_argument("--report", default="migration_report.json", help="fichier de rapport JSON")
ap.add_argument("--verify-existing", action="store_true",
help="compare les refs git Gogs/Gitea des dépôts déjà présents (lecture seule)")
ap.add_argument("--verify-report", default="verify_report.json", help="fichier de rapport de vérification")
args = ap.parse_args()
cfg = load_config()
known_orgs = discover_gogs_orgs(cfg["GOGS_URL"])
gogs = GogsClient(cfg["GOGS_URL"], cfg["GOGS_TOKEN"], known_orgs=known_orgs)
gitea = GiteaClient(cfg["GITEA_URL"], cfg["GITEA_ADMIN_USERNAME"], cfg["GITEA_ADMIN_PASSWORD"])
print(f"Organisations Gogs détectées : {sorted(known_orgs) or '(aucune)'}")
print(f"Découverte des dépôts Gogs ({cfg['GOGS_URL']}) ...")
plan = build_plan(gogs, gitea, cfg, only=args.only, limit=args.limit)
print(f"{len(plan)} dépôt(s) analysé(s).")
if args.verify_existing:
verify_results = verify_existing(cfg, plan)
n_ok = sum(1 for r in verify_results if not r.get("error") and not r["missing_in_gitea"] and not r["diverged"])
n_ecart = sum(1 for r in verify_results if not r.get("error") and (r["missing_in_gitea"] or r["diverged"]))
n_err = sum(1 for r in verify_results if r.get("error"))
total_missing = sum(len(r.get("missing_in_gitea", [])) for r in verify_results)
print(f"\n{n_ok} OK, {n_ecart} avec écart, {n_err} en erreur.")
if args.apply and total_missing:
confirm = input(
f"\nATTENTION: {total_missing} ref(s) (branches/tags) totalement absentes vont être "
f"ajoutées sur Gitea, cible {cfg['GITEA_URL']}. Les branches divergentes ne sont "
f"JAMAIS touchées. Tape 'oui' pour confirmer: "
)
if confirm.strip().lower() != "oui":
print("Annulé (aucune ref ajoutée).")
else:
apply_missing_refs(cfg, verify_results)
elif args.apply:
print("Aucune ref manquante à ajouter.")
Path(args.verify_report).write_text(json.dumps(verify_results, indent=2, ensure_ascii=False))
print(f"Rapport: {args.verify_report}")
return
if args.apply:
confirm = input(
f"\nATTENTION: exécution RÉELLE sur {len(plan)} dépôt(s), cible {cfg['GITEA_URL']}.\n"
f"Tape 'oui' pour confirmer: "
)
if confirm.strip().lower() != "oui":
print("Annulé.")
sys.exit(1)
apply_plan(gogs, gitea, cfg, plan)
print_summary(plan, args.apply)
Path(args.report).write_text(json.dumps(plan, indent=2, ensure_ascii=False))
print(f"\nRapport détaillé écrit dans: {args.report}")
if __name__ == "__main__":
main()