#!/usr/bin/env python3 """ Pflegt CHANGELOG-USER.md vor dem ZIP-Build. Modi: --fill Liest Commits seit letztem Tag, generiert lesbaren Text und ersetzt den Platzhalter im Abschnitt der aktuellen Version. Legt den Abschnitt an falls noch nicht vorhanden. --check Prüft ob ein gefüllter Abschnitt für VERSION existiert. Exit 0 = OK, Exit 1 = fehlt oder ist Platzhalter. Aufruf durch build_kodi_zip.sh: python3 scripts/update_user_changelog.py --fill addon/addon.xml addon/CHANGELOG-USER.md python3 scripts/update_user_changelog.py --check addon/addon.xml addon/CHANGELOG-USER.md """ import re import subprocess import sys import xml.etree.ElementTree as ET from pathlib import Path PLACEHOLDER_LINE = "(Bitte Changelog-Einträge hier einfügen)" # Schlüsselwörter aus Commit-Messages → lesbarer Kategorie-Text # Reihenfolge bestimmt Priorität (erster Treffer gewinnt). CATEGORY_RULES: list[tuple[re.Pattern, str]] = [ (re.compile(r"trakt", re.I), "Trakt"), (re.compile(r"serienstream|serien.?stream", re.I), "SerienStream"), (re.compile(r"aniworld", re.I), "AniWorld"), (re.compile(r"youtube|yt.?dlp", re.I), "YouTube"), (re.compile(r"tmdb|metadat", re.I), "Metadaten"), (re.compile(r"suche|search", re.I), "Suche"), (re.compile(r"setting|einstellung", re.I), "Einstellungen"), (re.compile(r"update|version", re.I), "Updates"), (re.compile(r"moflix", re.I), "Moflix"), (re.compile(r"filmpalast", re.I), "Filmpalast"), (re.compile(r"kkiste", re.I), "KKiste"), (re.compile(r"doku.?stream", re.I), "Doku-Streams"), (re.compile(r"topstream", re.I), "Topstreamfilm"), (re.compile(r"einschalten", re.I), "Einschalten"), (re.compile(r"hdfilme", re.I), "HDFilme"), (re.compile(r"netzkino", re.I), "NetzkKino"), ] def get_version(addon_xml: Path) -> str: root = ET.parse(addon_xml).getroot() return root.attrib.get("version", "").strip() def strip_suffix(version: str) -> str: return re.sub(r"-(dev|nightly|beta|alpha).*$", "", version) def get_prev_tag(current_tag: str) -> str | None: try: result = subprocess.run( ["git", "tag", "--sort=-version:refname"], capture_output=True, text=True, check=True, ) tags = [t.strip() for t in result.stdout.splitlines() if t.strip()] if current_tag in tags: idx = tags.index(current_tag) return tags[idx + 1] if idx + 1 < len(tags) else None return tags[0] if tags else None except Exception: return None def get_commits_since(prev_tag: str | None) -> list[str]: """Gibt Commit-Subjects seit prev_tag zurück (ohne den Tag-Commit selbst).""" try: ref = f"{prev_tag}..HEAD" if prev_tag else "HEAD" result = subprocess.run( ["git", "log", ref, "--pretty=format:%s"], capture_output=True, text=True, check=True, ) return [l.strip() for l in result.stdout.splitlines() if l.strip()] except Exception: return [] def extract_description(subject: str) -> str: """Extrahiert den lesbaren Teil aus einer Commit-Message.""" # "dev: bump to 0.1.86.0-dev Beschreibung hier" → "Beschreibung hier" m = re.match(r"^(?:dev|nightly|main):\s*bump\s+to\s+[\d.]+(?:-\w+)?\s*(.+)$", subject, re.I) if m: return m.group(1).strip() # "dev: Beschreibung" → "Beschreibung" m = re.match(r"^(?:dev|nightly|main):\s*(.+)$", subject, re.I) if m: return m.group(1).strip() return subject def split_description(desc: str) -> list[str]: """Zerlegt kommagetrennte Beschreibungen in Einzelpunkte.""" parts = [p.strip() for p in re.split(r",\s*(?=[A-ZÄÖÜ\w])", desc) if p.strip()] return parts if parts else [desc] def categorize(items: list[str]) -> dict[str, list[str]]: """Gruppiert Beschreibungs-Punkte nach Kategorie.""" categories: dict[str, list[str]] = {} for item in items: cat = "Allgemein" for pattern, label in CATEGORY_RULES: if pattern.search(item): cat = label break categories.setdefault(cat, []).append(item) return categories def build_changelog_text(commits: list[str]) -> str: """Erzeugt lesbaren Changelog-Text aus Commit-Subjects.""" all_items: list[str] = [] for subject in commits: desc = extract_description(subject) if desc: all_items.extend(split_description(desc)) if not all_items: return "- Verschiedene Verbesserungen und Fehlerbehebungen" categories = categorize(all_items) lines: list[str] = [] for cat, items in categories.items(): lines.append(f"**{cat}**") for item in items: # Ersten Buchstaben groß, Punkt am Ende item = item[0].upper() + item[1:] if item else item if not item.endswith((".","!","?")): item += "." lines.append(f"- {item}") lines.append("") return "\n".join(lines).rstrip() def section_exists(lines: list[str], header: str) -> bool: return any(line.strip() == header for line in lines) def section_is_placeholder(lines: list[str], header: str) -> bool: in_section = False content_lines = [] for line in lines: if line.strip() == header: in_section = True continue if in_section: if line.startswith("## "): break stripped = line.strip() if stripped: content_lines.append(stripped) return not content_lines or all(l == PLACEHOLDER_LINE for l in content_lines) def replace_section_content(lines: list[str], header: str, new_content: str) -> list[str]: """Ersetzt den Inhalt eines bestehenden Abschnitts.""" result: list[str] = [] in_section = False content_written = False for line in lines: if line.strip() == header: result.append(line) result.append("") result.extend(new_content.splitlines()) result.append("") in_section = True content_written = True continue if in_section: if line.startswith("## "): in_section = False result.append(line) # Alte Zeilen des Abschnitts überspringen continue result.append(line) return result def insert_section(lines: list[str], header: str, content: str) -> list[str]: new_section = [header, "", *content.splitlines(), ""] return new_section + ([""] if lines and lines[0].strip() else []) + lines def main() -> None: if len(sys.argv) < 4 or sys.argv[1] not in ("--fill", "--check"): print(f"Usage: {sys.argv[0]} --fill|--check ", file=sys.stderr) sys.exit(2) mode = sys.argv[1] addon_xml = Path(sys.argv[2]) changelog = Path(sys.argv[3]) version = get_version(addon_xml) if not version: print("Fehler: Version konnte nicht aus addon.xml gelesen werden.", file=sys.stderr) sys.exit(2) clean_version = strip_suffix(version) header = f"## {clean_version}" lines = changelog.read_text(encoding="utf-8").splitlines() if changelog.exists() else [] if mode == "--check": if not section_exists(lines, header): print(f"FEHLER: Kein Changelog-Abschnitt für {clean_version} in {changelog}", file=sys.stderr) sys.exit(1) if section_is_placeholder(lines, header): print(f"FEHLER: Changelog-Abschnitt für {clean_version} ist noch ein Platzhalter.", file=sys.stderr) sys.exit(1) print(f"OK: Changelog-Abschnitt für {clean_version} vorhanden.") sys.exit(0) # --fill current_tag = f"v{version}" prev_tag = get_prev_tag(current_tag) commits = get_commits_since(prev_tag) content = build_changelog_text(commits) if section_exists(lines, header): if not section_is_placeholder(lines, header): print(f"Abschnitt {header} bereits gefüllt – keine Änderung.") sys.exit(0) new_lines = replace_section_content(lines, header, content) else: new_lines = insert_section(lines, header, content) changelog.write_text("\n".join(new_lines) + "\n", encoding="utf-8") print(f"Changelog für {header} geschrieben.") sys.exit(0) if __name__ == "__main__": main()