Files
HiveGrid-Wireframes/hivegrid-cloud/.planning/plane-sync/sync_plane.py
T

228 lines
11 KiB
Python

#!/usr/bin/env python3
"""
HiveGrid -> Plane Ticket-Sync (idempotent).
Liest Zugang aus ~/.cli-tokens/plane (env-Format).
ZIEL-PROJEKT ist HARDCODED auf HiveGrid (NICHT die PLANE_PROJECT-Variable,
die faelschlich auf PowerLinked zeigt).
Phase A: Cycles (W1-W4) + 7 Module + Labels + Modul "Externals" (12 Issues).
Phase B (spaeter): restliche Module-Issues + modueluebergreifende Relations.
Aufruf: python3 sync_plane.py
Re-Run-sicher: existierende Objekte werden per Name erkannt, nicht dupliziert.
"""
import json, os, sys, urllib.request, urllib.error
# ⚠ BOOTSTRAP-GUARD (seit 2026-06-16): Plane ist LIVE + manuell gescrubbt (Umlaute, PWA-Umbau,
# v3-Defers). Dieses Skript matcht Issues per VOLLNAMEN — ein Re-Run nach dem Scrub fände
# umbenannte Tickets NICHT wieder und legte DUPLIKATE an. Laufende Pflege -> scrub_plane.py
# (Working-ID-Match, idempotent). Bewusster Neu-Bootstrap nur mit --force-bootstrap.
if "--force-bootstrap" not in sys.argv:
raise SystemExit(
"✋ Bootstrap gesperrt: Plane ist live + gescrubbt — Re-Run würde Duplikate erzeugen.\n"
" Laufende Änderungen -> .planning/plane-sync/scrub_plane.py (idempotent).\n"
" Echter Neu-Bootstrap nur bewusst mit --force-bootstrap."
)
# ---- Zugang ----
TOK = {}
with open(os.path.expanduser("~/.cli-tokens/plane")) as f:
for line in f:
line = line.strip()
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
TOK[k.strip()] = v.strip()
BASE = TOK["PLANE_BASE_URL"].rstrip("/")
WS = TOK["PLANE_WORKSPACE"]
KEY = TOK["PLANE_API_KEY"]
# HARDCODED: HiveGrid-Projekt (NICHT PowerLinked!)
PID = "35d0d968-8ab3-40cc-97b8-b81d917dc5df"
API = f"{BASE}/api/v1/workspaces/{WS}/projects/{PID}"
GITEA = "https://git.tool-ctrl.de/toolctrl-labs/HiveGrid-Cloud/src/branch/main"
MEMBERS = {
"raffael": "8e34a88c-19c9-4565-b4f5-50415d623ce8",
"akeshan": "8bf43e86-dc8a-4963-b57a-aeb5f2cc450a",
"david": "6c1fc63f-133d-4f40-86e4-7f658a6edcc7",
"dejan": "a5883f5e-3038-453c-9401-39ab1f40be9e",
"haris": "48367c1f-20d2-44f1-8ae2-faf35aa2052a",
}
def req(method, path, body=None):
url = path if path.startswith("http") else API + path
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(url, data=data, method=method)
r.add_header("X-API-Key", KEY)
r.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(r, timeout=30) as resp:
raw = resp.read().decode()
return resp.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as e:
return e.code, {"_error": e.read().decode()[:400]}
def get_all(path):
"""Cursor-paginierte Liste -> alle results."""
out, cursor = [], None
while True:
p = path + (("?cursor=" + cursor) if cursor else "")
st, d = req("GET", p)
if st >= 400:
print(" GET FAIL", path, st, d); break
if isinstance(d, list):
out += d; break
out += d.get("results", [])
if d.get("next_page_results") and d.get("next_cursor"):
cursor = d["next_cursor"]
else:
break
return out
# ---- States ----
states = {s["name"]: s["id"] for s in get_all("/states/")}
BACKLOG = states.get("Backlog")
print("States:", list(states.keys()), "| Backlog-id ok:", bool(BACKLOG))
# ---- Cycles (idempotent by name) ----
CYCLES = [
("W1 — Fundament & Identitaet", "2026-06-16", "2026-06-19"),
("W2 — Plattform & CRM (Demo 1)", "2026-06-20", "2026-06-26"),
("W3 — Kern-Kette & Kommunikation (Demo 2)", "2026-06-27", "2026-07-03"),
("W4 — USP, Haertung & Launch", "2026-07-04", "2026-07-10"),
]
existing_cycles = {c["name"]: c["id"] for c in get_all("/cycles/")}
cycle_id = {}
for name, start, end in CYCLES:
if name in existing_cycles:
cycle_id[name] = existing_cycles[name]; print("Cycle vorhanden:", name)
else:
st, d = req("POST", "/cycles/", {"name": name, "start_date": start, "end_date": end, "project_id": PID})
if st < 400: cycle_id[name] = d["id"]; print("Cycle angelegt:", name)
else: print("Cycle FAIL:", name, st, d)
W = {"W1": CYCLES[0][0], "W2": CYCLES[1][0], "W3": CYCLES[2][0], "W4": CYCLES[3][0]}
# ---- Module (idempotent by name) ----
MODULES = ["Externals", "Raffael (Dev)", "Akeshan (Dev)", "David (Screens)",
"Dejan (Website)", "Haris (Vertrieb)", "Noah (Validierung)"]
existing_mods = {m["name"]: m["id"] for m in get_all("/modules/")}
module_id = {}
for name in MODULES:
if name in existing_mods:
module_id[name] = existing_mods[name]; print("Modul vorhanden:", name)
else:
st, d = req("POST", "/modules/", {"name": name})
if st < 400: module_id[name] = d["id"]; print("Modul angelegt:", name)
else: print("Modul FAIL:", name, st, d)
# ---- Labels (idempotent by name) ----
LABELS = {
"typ:phase": "#2563eb", "typ:screen": "#db2777", "typ:external": "#d97706",
"typ:website": "#0891b2", "typ:vertrieb": "#16a34a", "typ:validierung": "#7c3aed",
"gate:W1": "#9ca3af", "gate:W2": "#6b7280", "gate:W3": "#4b5563", "gate:W4": "#374151",
}
existing_labels = {l["name"]: l["id"] for l in get_all("/labels/")}
label_id = {}
for name, color in LABELS.items():
if name in existing_labels:
label_id[name] = existing_labels[name]
else:
st, d = req("POST", "/labels/", {"name": name, "color": color})
if st < 400: label_id[name] = d["id"]; print("Label angelegt:", name)
else: print("Label FAIL:", name, st, d)
# ---- Helper: Doku-HTML + Issue-Description ----
def doku_link(path, section):
return f'<a href="{GITEA}/{path}">{path.split("/")[-1]} — {section}</a>'
def desc(doku, blocked_by, blocks, owner, window, dod):
lis = "".join(f"<li>{d}</li>" for d in dod)
return (f"<p><b>📚 Doku</b><br/>{doku}</p>"
f"<p><b>⛔ blocked-by:</b> {blocked_by or '—'}<br/>"
f"<b>➡️ blocks:</b> {blocks or '—'}<br/>"
f"<b>👤 Owner:</b> {owner} &nbsp;·&nbsp; <b>🗓 Fenster:</b> {window}</p>"
f"<p><b>✅ Definition of Done</b></p><ul>{lis}</ul>")
# ---- Issue anlegen (idempotent by exact name) ----
existing_issues = {i["name"]: i["id"] for i in get_all("/issues/")}
def ensure_issue(title, assignees, labels, start, target, description_html):
if title in existing_issues:
return existing_issues[title], False
body = {"name": title, "description_html": description_html,
"state": BACKLOG, "assignees": [MEMBERS[a] for a in assignees],
"labels": [label_id[l] for l in labels if l in label_id],
"start_date": start, "target_date": target}
st, d = req("POST", "/issues/", body)
if st >= 400:
print("Issue FAIL:", title, st, d); return None, False
existing_issues[title] = d["id"]
return d["id"], True
def link_issue(iid, cycle_key, module):
"""Cycle- + Modul-Zuordnung (idempotent; Plane M2M ignoriert Duplikate)."""
cn = W[cycle_key]
if cn in cycle_id:
st, d = req("POST", f"/cycles/{cycle_id[cn]}/cycle-issues/",
{"issues": [iid], "project": PID})
if st >= 400 and "already" not in str(d).lower():
print(" cycle-link FAIL", st, d)
if module in module_id:
st, d = req("POST", f"/modules/{module_id[module]}/module-issues/",
{"issues": [iid], "project": PID})
if st >= 400 and "already" not in str(d).lower():
print(" module-link FAIL", st, d)
EXT_DOC = ".planning/EXTERNALS-CHECKLISTE.md"
EXTERNALS = [
("EXT-02 · Mailjet Sende-Domain (SPF/DKIM/DMARC) + Warm-up", ["raffael"], "W1", "2026-06-16", "2026-06-16",
"A6/B1/D1", "—", "P2 (Einladungs-Mails), P14 (Marketing-Zustellbarkeit)",
["SPF/DKIM/DMARC gesetzt & verifiziert (Auto-Probe gruen)", "Domain-Warm-up gestartet", "AD-6 im ADR auf Accepted"]),
("EXT-03 · Anwalt: AVV + DSGVO-Auskunft + Website-Rechtstexte", ["dejan"], "W1", "2026-06-16", "2026-06-19",
"E6", "—", "Website-LIVE (Rechtstexte), P21 / Launch",
["AVV-Vorlage (Art. 28) beauftragt", "DSGVO-Auskunfts-Vorlage", "Impressum/Datenschutz fuer Website"]),
("EXT-04 · KWP: Callback-URLs melden + bnWin.net 8.1 N3", ["raffael"], "W1", "2026-06-16", "2026-06-17",
"A1", "N-KWP (Noah/Geerkens-IT)", "v2-P10 (KWP-Integration)",
["Beide Callback-URLs an KWP gemeldet (app + staging, PKCE)", "bnWin.net-Update 8.1 N3 bei Geerkens-IT angestossen"]),
("EXT-05 · Noah: 4 Gate-Freitags-Slots blocken", ["raffael"], "W1", "2026-06-16", "2026-06-16",
"E1", "—", "alle Noah-Demos (19./26.06., 03./10.07.)",
["4 Slots terminiert", "von Noah bestaetigt"]),
("EXT-06 · Hetzner-Projekt HiveGrid: S3+Token + DEK-Master-Key", ["raffael"], "W1", "2026-06-16", "2026-06-17",
"B5", "—", "P4 (Storage), P5/P17 (DEK), AK-Infra-Live",
["Hetzner-Projekt HiveGrid + S3-Bucket + Token", "DEK-Master-Key in Coolify-Secrets", "SCALEWAY_*-Env -> Hetzner-Endpoint"]),
("EXT-07 · Stripe-Konto + Live-Verifizierung", ["raffael"], "W1", "2026-06-16", "2026-06-26",
"A5", "—", "P20 (Billing-Go-Live)",
["Konto + Geschaeftsdaten + Bankkonto", "Test-Mode fuer Dev aktiv", "Live-Verifizierung eingereicht"]),
("EXT-08 · AirCall-Konto + Sandbox + Webhooks", ["raffael"], "W1", "2026-06-16", "2026-06-30",
"B2", "—", "P15 (Telefonie)",
["Konto + Sandbox", "API-Keys", "Webhooks konfiguriert"]),
("EXT-09 · PandaDoc-Sandbox (Provider-Wahl)", ["raffael"], "W1", "2026-06-16", "2026-07-03",
"B4/D4", "—", "P16 (Angebot/Signatur)",
["Provider gewaehlt (PandaDoc vs. DocuSign)", "Sandbox + API-Key"]),
("EXT-10 · Google-Cloud OAuth + Calendar-Scope-Antrag", ["raffael"], "W1", "2026-06-16", "2026-06-18",
"A2", "—", "P16 (Kalender, nur Google)",
["OAuth-App + nur Calendar-Scope", "Verifizierung beantragt", "Testmodus-Bruecke mit Workspace-Konten"]),
("EXT-11 · Mistral API-Key", ["raffael"], "W1", "2026-06-16", "2026-06-18",
"B6", "—", "P13 (KI-Layer)",
["La-Plateforme-Key in Coolify-Secrets"]),
("EXT-12 · Geo-IP + HIBP pruefen/anbinden", ["raffael"], "W1", "2026-06-16", "2026-06-19",
"B8/B9", "—", "P2 (Session-Standort + Breach-Check)",
["Geo-IP EU-Konformitaet geprueft (oder stub)", "HIBP k-Anonymity angebunden"]),
("EXT-13 · v2-Vorlauf-Externals (Play/FCM/Gmail-Scope/Entra)", ["raffael"], "W3", "2026-06-29", "2026-06-29",
"v2-Vorlauf", "—", "v2 (Mobile-App, Postfach-Sync)",
["Google Play Console", "FCM/APNs-Zertifikate", "Gmail-Restricted-Scope-Antrag (Wochen Vorlauf!)", "MS-Entra-App"]),
]
print("\n--- Externals-Issues ---")
for title, asg, cyc, start, target, sec, bby, blk, dod in EXTERNALS:
html = desc(doku_link(EXT_DOC, sec), bby, blk,
", ".join(asg), f"{cyc} (faellig {target})", dod)
labels = ["typ:external", f"gate:{cyc}"]
iid, created = ensure_issue(title, asg, labels, start, target, html)
if iid:
link_issue(iid, cyc, "Externals")
print(("angelegt:" if created else "verknuepft:"), title)
print("\nFERTIG (Phase A). Projekt-URL:",
f"{BASE}/{WS}/projects/{PID}/issues/")