verfahren: Issue-Migrations-Skript Gitea->GitLab (gitops#48) + Testlauf-Stand
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKhFj1S3UdD6xL2fbWPeYj
This commit is contained in:
co-authored by
Claude Fable 5
parent
77a9a94a7f
commit
e4991c050f
@@ -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 `<!-- gitea-migration: OWNER/REPO#N -->` 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/<repo> <gitlab-projekt-id> # Dry-Run
|
||||
python3 migrate.py sorb/<repo> <gitlab-projekt-id> --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.
|
||||
@@ -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 "<!-- gitea-migration: OWNER/REPO#N -->" 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("<!-- gitea-migration: "):
|
||||
existing.add(line.strip())
|
||||
page += 1
|
||||
|
||||
# Gitea-Issues (alle Zustaende), PRs ausfiltern
|
||||
issues = []
|
||||
page = 1
|
||||
while True:
|
||||
batch = gitea(f"/repos/{repo}/issues?state=all&type=issues&limit=50&page={page}")
|
||||
if not batch:
|
||||
break
|
||||
issues.extend(batch)
|
||||
page += 1
|
||||
issues.sort(key=lambda i: i["number"]) # aufsteigend = chronologische Anlage
|
||||
|
||||
print(f"{repo}: {len(issues)} Issues gefunden, Ziel-Projekt {project}, "
|
||||
f"{'EXECUTE' if execute else 'DRY-RUN'}")
|
||||
|
||||
for i in issues:
|
||||
n = i["number"]
|
||||
marker = f"<!-- gitea-migration: {repo}#{n} -->"
|
||||
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()
|
||||
Reference in New Issue
Block a user