From e4991c050f84a7908402d178e1fff1cf1e046067 Mon Sep 17 00:00:00 2001 From: Thore Cimbal Date: Sat, 1 Aug 2026 12:00:00 +0000 Subject: [PATCH] verfahren: Issue-Migrations-Skript Gitea->GitLab (gitops#48) + Testlauf-Stand Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj --- verfahren/issue-migration/README.md | 40 ++++++++++ verfahren/issue-migration/migrate.py | 107 +++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 verfahren/issue-migration/README.md create mode 100644 verfahren/issue-migration/migrate.py diff --git a/verfahren/issue-migration/README.md b/verfahren/issue-migration/README.md new file mode 100644 index 0000000..cb1074b --- /dev/null +++ b/verfahren/issue-migration/README.md @@ -0,0 +1,40 @@ +# Issue-Migration Gitea → GitLab (gitops#48) + +`migrate.py` überführt Issues (offen **und** geschlossen, inkl. Kommentare) +eines Gitea-Repos in ein bestehendes GitLab-Projekt. Einmal-Werkzeug für den +#48-Cutover; hier versioniert wegen Reproduzierbarkeit. + +## Eigenschaften + +- **Dry-Run per Default**, `--execute` schreibt wirklich +- **Idempotent** über Marker `` in der + Ziel-Beschreibung — Wiederholungsläufe überspringen Migriertes +- **Zeitstempel bleiben erhalten** (Admin-Token darf `created_at` setzen); + Autorschaft läuft auf den Token-User, Original-Autor+Datum stehen im + Migrations-Fußtext bzw. Kommentar-Präfix (bekannter, akzeptierter Verlust) +- Labels namensgleich (Gruppen-Labels müssen vorher existieren — Stand + 2026-08-01 sind die 9 Gitea-Labels + 5 `host:*` als Gruppe-13-Labels angelegt) +- PRs werden ausgefiltert, geschlossene Issues nach Anlage geschlossen + +## Aufruf + +``` +python3 migrate.py sorb/ # Dry-Run +python3 migrate.py sorb/ --execute +``` + +Tokens: `~/.config/gitea-rohana/token` (read:issue) und +`~/.config/gitlab-lab/token` (Admin) auf dem Mac. + +## Stand + +| Repo | Ziel | Status | +|---|---|---| +| sorb/thread-net-git | Projekt 18 | ✅ migriert 2026-08-01 (Testlauf, 1 Issue, verifiziert) | +| sorb/threadnet-call | Projekt 19 | ausstehend | +| sorb/ThreadNet-Web | Projekt 16 | ausstehend | +| sorb/axion1337.chat-gitops | Projekt 20? | ausstehend — größter Brocken, zuletzt | + +**Cutover-Nachschritte** (erst nach vollständiger Migration, siehe gitops#48): +Gitea-Issues schließen/als migriert markieren, Doku-Verweise umbiegen +(CLAUDE.md-Topologieregel!), TURN-Rotations-Hinweis, Bot-/Token-Workflows. diff --git a/verfahren/issue-migration/migrate.py b/verfahren/issue-migration/migrate.py new file mode 100644 index 0000000..234dad4 --- /dev/null +++ b/verfahren/issue-migration/migrate.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# Issue-Migration Gitea -> GitLab (gitops#48). Stdlib-only. +# +# Eigenschaften: +# - Dry-Run per Default; --execute schreibt wirklich +# - idempotent: Marker "" in der +# GitLab-Beschreibung; bereits migrierte Issues werden uebersprungen +# - Kommentare werden als Notes uebernommen (Original-Autor + -Zeit im Text, +# da die API-Autorschaft auf den Token-User laeuft — bekannter, akzeptierter +# Verlust, siehe gitops#48) +# - Zeitstempel bleiben erhalten: der Admin-Token darf created_at setzen +# - geschlossene Issues werden nach Anlage geschlossen +# - Labels werden namensgleich uebernommen (Gruppen-Labels muessen existieren) +# - PRs werden ausgelassen (Gitea listet sie im Issue-Endpunkt mit) +# +# Aufruf: +# python3 migrate.py sorb/thread-net-git 18 # Dry-Run +# python3 migrate.py sorb/thread-net-git 18 --execute +import json +import sys +import urllib.parse +import urllib.request + +GITEA = "https://rohana.axion1337.de" +GITLAB = "https://git.lab" +GITEA_TOKEN = open("/Users/thore/.config/gitea-rohana/token").read().strip() +GITLAB_TOKEN = open("/Users/thore/.config/gitlab-lab/token").read().strip() + + +def gitea(path): + req = urllib.request.Request(f"{GITEA}/api/v1{path}", + headers={"Authorization": f"token {GITEA_TOKEN}"}) + return json.loads(urllib.request.urlopen(req).read()) + + +def gitlab(method, path, payload=None): + data = urllib.parse.urlencode(payload).encode() if payload else None + req = urllib.request.Request(f"{GITLAB}/api/v4{path}", data=data, + headers={"PRIVATE-TOKEN": GITLAB_TOKEN}, method=method) + return json.loads(urllib.request.urlopen(req).read()) + + +def main(): + repo = sys.argv[1] + project = sys.argv[2] + execute = "--execute" in sys.argv + + # Bestehende Migrations-Marker im Zielprojekt einsammeln (Idempotenz) + existing = set() + page = 1 + while True: + batch = gitlab("GET", f"/projects/{project}/issues?per_page=100&page={page}&scope=all&state=all") + if not batch: + break + for i in batch: + desc = i.get("description") or "" + for line in desc.splitlines(): + if line.startswith("" + if marker in existing: + print(f" #{n} bereits migriert - uebersprungen") + continue + labels = ",".join(l["name"] for l in i.get("labels", [])) + comments = gitea(f"/repos/{repo}/issues/{n}/comments") if i.get("comments") else [] + footer = (f"\n\n---\n*Migriert aus Gitea [{repo}#{n}]({GITEA}/{repo}/issues/{n}) " + f"— dort erstellt am {i['created_at'][:10]} von {i['user']['login']}.*\n{marker}") + print(f" #{n} [{i['state']}] {i['title'][:60]} | labels: {labels or '-'} | " + f"{len(comments)} kommentare") + if not execute: + continue + new = gitlab("POST", f"/projects/{project}/issues", { + "title": i["title"], + "description": (i.get("body") or "") + footer, + "labels": labels, + "created_at": i["created_at"], + }) + for c in comments: + gitlab("POST", f"/projects/{project}/issues/{new['iid']}/notes", { + "body": f"**{c['user']['login']} am {c['created_at'][:16].replace('T', ' ')} (Gitea):**\n\n{c['body']}", + "created_at": c["created_at"], + }) + if i["state"] == "closed": + gitlab("PUT", f"/projects/{project}/issues/{new['iid']}", {"state_event": "close"}) + print(f" -> GitLab #{new['iid']} angelegt ({i['state']})") + + +if __name__ == "__main__": + main()