#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Spiegelt Markdown-Dateien (z. B. aus build/dokka/gfm) in eine YouTrack Knowledge Base. - Verwendet Umgebungsvariablen YT_URL und YT_TOKEN - Erwartet den KB-Root-Titel in KB_ROOT_TITLE (z. B. "API & Entwicklerdoku") - Optional: KB_BC_ROOT (Unterordnername, z. B. "BCs") – wird aktuell nur als Titelpräfix genutzt Sicherheit: - Tokens werden niemals geloggt. - Bei HTTP-Fehlern werden Statuscode und gekürzte Antwort ausgegeben. """ import argparse import json import os import sys import time from pathlib import Path try: import requests except ImportError: # pragma: no cover print("[YT] requests fehlt. Bitte 'pip install requests' ausführen.") sys.exit(2) SESSION = requests.Session() SESSION.headers.update({ "Accept": "application/json", "Content-Type": "application/json", }) # Globale Variablen, in main() gesetzt PROJECT_ID = "MP" # Standardwert, wird in main() überschrieben KB_ID = None # Knowledge-Base-ID des Projekts KB_ROOT_ARTICLE_ID = None # ID des echten KB-Wurzelartikels (Container) def yt_url(path: str) -> str: base = os.environ.get("YT_URL", "").rstrip("/") if not base: print("[YT] YT_URL fehlt in Env.") sys.exit(2) if not path.startswith("/"): path = "/" + path return base + path def set_auth(): token = os.environ.get("YT_TOKEN") if not token: print("[YT] YT_TOKEN fehlt in Env.") sys.exit(2) # Bearer Token SESSION.headers["Authorization"] = f"Bearer {token}" def http(method: str, url: str, **kw): # Einfaches Retry bei 429/5xx for attempt in range(5): r = SESSION.request(method, url, timeout=30, **kw) if r.status_code in (429, 500, 502, 503, 504): wait = (attempt + 1) * 1.5 print(f"[YT] {r.status_code} → Retry in {wait:.1f}s…") time.sleep(wait) continue return r return r # *** KORRIGIERTE FUNKTION *** def get_project_by_short_name(name: str): """Sucht die interne ID des Projekts anhand des Kürzels (z.B. 'MP'). Versucht zuerst /api/projects, fällt bei Fehlern auf /api/admin/projects zurück. """ # 1) Primärer, nicht-admin Endpunkt url1 = yt_url("/api/projects?fields=id,shortName") r1 = http("GET", url1) if r1.status_code == 200: for proj in r1.json(): if proj.get("shortName") == name: print("[YT] Projekte via /api/projects gefunden.") return proj else: print(f"[YT] Projektliste (\"/api/projects\") fehlgeschlagen: HTTP {r1.status_code} {r1.text[:200]}") # 2) Fallback: Admin-Endpunkt (ältere Versionen/Setups) url2 = yt_url("/api/admin/projects?fields=id,shortName") r2 = http("GET", url2) if r2.status_code == 200: for proj in r2.json(): if proj.get("shortName") == name: print("[YT] Projekte via /api/admin/projects gefunden.") return proj return None print(f"[YT] Projektliste (\"/api/admin/projects\") fehlgeschlagen: HTTP {r2.status_code} {r2.text[:200]}") sys.exit(1) # Neues Hilfs-API: Knowledge Base des Projekts abfragen def get_project_knowledge_base(project_id: str): """Liest die Knowledge-Base eines Projekts, inkl. Root-Container-Artikel. Versucht zuerst /api/projects/{id}, fällt bei Fehler auf /api/admin/projects/{id} zurück. """ # 1) Primärer Endpunkt (minimal fields) url1 = yt_url(f"/api/projects/{project_id}?fields=knowledgeBase(id,rootArticle(id,title))") r1 = http("GET", url1) if r1.status_code == 200: data = r1.json() kb = data.get("knowledgeBase") if kb: print("[YT] Projekt-Details via /api/projects/{id} geladen.") return kb else: print(f"[YT] Projekt-Details (\"/api/projects/{project_id}\") fehlgeschlagen: HTTP {r1.status_code} {r1.text[:200]}") # 2) Fallback: Admin-Endpunkt (minimal fields) url2 = yt_url(f"/api/admin/projects/{project_id}?fields=knowledgeBase(id,rootArticle(id,title))") r2 = http("GET", url2) if r2.status_code == 200: data = r2.json() kb = data.get("knowledgeBase") if kb: print("[YT] Projekt-Details via /api/admin/projects/{id} geladen.") return kb return None print(f"[YT] Projekt-Details (\"/api/admin/projects/{project_id}\") fehlgeschlagen: HTTP {r2.status_code} {r2.text[:200]}") sys.exit(1) # *** KORRIGIERTE FUNKTION *** def find_article_in_kb_by_title(title: str, parent_id: str | None = None): """Sucht einen Artikel anhand des Titels in der KB des Projekts. Optional kann parent_id angegeben werden, um die Suche auf direkte Kinder eines Artikels einzuschränken. """ url = yt_url("/api/articles") params = { "query": f'title: "{title}"', "fields": "id,title,knowledgeBase(id),project(id),parent(id)" } r = http("GET", url, params=params) if r.status_code != 200: print(f"[YT] Artikelsuche fehlgeschlagen: HTTP {r.status_code} {r.text[:400]}") sys.exit(1) for art in r.json(): if art.get("title") != title: continue if KB_ID: kb = art.get("knowledgeBase") or {} if kb.get("id") != KB_ID: continue else: proj = (art.get("project") or {}).get("id") if proj != PROJECT_ID: continue if parent_id: parent = art.get("parent") or {} if parent.get("id") != parent_id: continue return art return None # *** KORRIGIERTE FUNKTION *** def create_article(title: str, markdown: str, parent_id: str | None = None): """Erstellt einen neuen Artikel in der Knowledge Base des Projekts. Fallback: Wenn keine KB existiert (KB_ID is None), wird der Artikel dem Projekt zugeordnet. """ url = yt_url("/api/articles?fields=id,title") payload = { "title": title, "content": markdown, } if KB_ID: payload["knowledgeBase"] = {"id": KB_ID} else: payload["project"] = {"id": PROJECT_ID} if parent_id: payload["parent"] = {"id": parent_id} r = http("POST", url, data=json.dumps(payload)) if r.status_code not in (200, 201): print(f"[YT] Artikel erstellen fehlgeschlagen: HTTP {r.status_code} {r.text[:400]}") sys.exit(1) return r.json() # *** KORRIGIERTE FUNKTION *** def update_article(article_id: str, markdown: str): """Aktualisiert einen bestehenden Artikel in der Knowledge Base des Projekts.""" url = yt_url(f"/api/articles/{article_id}?fields=id") payload = {"content": markdown} # YouTrack API verwendet POST für Updates an Artikeln r = http("POST", url, data=json.dumps(payload)) if r.status_code not in (200, 201): print(f"[YT] Artikel aktualisieren fehlgeschlagen: HTTP {r.status_code} {r.text[:400]}") sys.exit(1) def build_title_from_path(rel_path: Path, bc_root: str | None) -> str: # Beispiel: infrastructure/gateway/index.md → "infrastructure / gateway / index.md" parts = list(rel_path.parts) title = " / ".join(parts) if bc_root: title = f"{bc_root} / {title}" return title def load_markdown(path: Path) -> str: try: text = path.read_text(encoding="utf-8") except Exception as e: print(f"[YT] Kann Datei nicht lesen: {path}: {e}") sys.exit(1) return text def main(): global PROJECT_ID, KB_ID, KB_ROOT_ARTICLE_ID # Zugriff auf globale Variablen ap = argparse.ArgumentParser(description="Sync Dokka Markdown nach YouTrack KB") ap.add_argument("--src", default="build/dokka/gfm", help="Quellverzeichnis (Markdown)") args = ap.parse_args() kb_root_title = os.environ.get("KB_ROOT_TITLE") bc_root = os.environ.get("KB_BC_ROOT") if not kb_root_title: print("[YT] KB_ROOT_TITLE fehlt in Env.") sys.exit(2) set_auth() src = Path(args.src) if not src.exists(): print(f"[YT] Quelle nicht gefunden: {src} – nichts zu tun.") return 0 # Projekt anhand des Kürzels ermitteln project_short_name = os.environ.get("YT_PROJECT", "MP").strip() or "MP" project = get_project_by_short_name(project_short_name) if not project: print(f"[YT] Projekt mit Kürzel '{project_short_name}' nicht gefunden.") sys.exit(1) PROJECT_ID = project["id"] print(f"[YT] Arbeite im Projekt: {project_short_name} (ID: {PROJECT_ID})") # Knowledge Base des Projekts abrufen kb = get_project_knowledge_base(PROJECT_ID) if not kb: print("[YT] Hinweis: Dieses Projekt hat (noch) keine Knowledge Base – Synchronisation wird übersprungen.") return 0 KB_ID = kb.get("id") root_article = kb.get("rootArticle") or {} KB_ROOT_ARTICLE_ID = root_article.get("id") print(f"[YT] Verwende Knowledge Base: {KB_ID}; Root-Container: {KB_ROOT_ARTICLE_ID}") # Root-Artikel (logische Wurzel unterhalb des KB-Root-Containers) finden/erstellen kb_root = find_article_in_kb_by_title(kb_root_title, parent_id=KB_ROOT_ARTICLE_ID) if not kb_root: print(f"[YT] Root-Artikel '{kb_root_title}' nicht gefunden. Erstelle ihn unterhalb des KB-Root-Containers…") kb_root = create_article(kb_root_title, f"# {kb_root_title}\n\nDieser Artikel dient als Wurzel für die automatische KDoc-Synchronisation.", parent_id=KB_ROOT_ARTICLE_ID) print(f"[YT] Root-Artikel erstellt: {kb_root_title} (ID: {kb_root['id']})") kb_root_id = kb_root["id"] print(f"[YT] Verwende KB-Root: {kb_root_title} ({kb_root_id})") count = 0 for md in src.rglob("*.md"): rel = md.relative_to(src) title = build_title_from_path(rel, bc_root) content = load_markdown(md) existing = find_article_in_kb_by_title(title, parent_id=kb_root_id) if existing: update_article(existing["id"], content) print(f"[YT] Aktualisiert: {title}") else: # Erstelle Artikel als Kind des Root-Artikels create_article(title, content, parent_id=kb_root_id) print(f"[YT] Erstellt: {title}") count += 1 print(f"[YT] Fertig. {count} Artikel synchronisiert.") return 0 if __name__ == "__main__": sys.exit(main())