#!/usr/bin/env python3 """Lesender Zugriff auf das Gitea-Ticketsystem der Organisation aus workspace.toml. Fragt die öffentlich lesbare Gitea-API ab (kein Token, keine Anmeldung, keine Installation – nur Standardbibliothek) und gibt Issues, Pull Requests und Suchtreffer token-sparend für KI-Agenten und Entwickler aus. Läuft auf Linux, macOS und Windows mit jedem Python >= 3.11. WICHTIGE PROJEKTREGEL: Agenten dürfen auf dem Gitea-Server nichts verändern. Dieses Skript stellt ausschließlich GET-Anfragen. Schreibende Aktionen (neues Issue, neuer Kommentar) werden nur als Entwurfsdatei unter entwuerfe/ vorbereitet. Ein Mensch prüft den Text und sendet ihn selbst über die Weboberfläche ab. Aufrufe (aus dem Workspace-Root): python scripts/gitea.py tickets # offene Issues ALLER Repos python scripts/gitea.py tickets Projektwissen # nur ein Repo (Kurzname genügt) python scripts/gitea.py tickets --geschlossen # geschlossene statt offene python scripts/gitea.py tickets --label Bug # nach Label filtern python scripts/gitea.py ticket Projektwissen 12 # ein Issue mit allen Kommentaren python scripts/gitea.py suche "Swimlane" # Volltext über Titel, Text und Kommentare, # offene und geschlossene Tickets python scripts/gitea.py pulls # offene Pull Requests python scripts/gitea.py entwurf issue Projektwissen # Entwurfsdatei anlegen python scripts/gitea.py entwurf kommentar Projektwissen 12 # Entwurf für einen Kommentar Exit-Code: 0 = in Ordnung, 1 = Gitea nicht erreichbar / nicht gefunden, 2 = falsche Angabe (Repo unbekannt oder mehrdeutig). """ from __future__ import annotations import argparse import json import shutil import tomllib import urllib.error import urllib.parse import urllib.request from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[1] MANIFEST = ROOT / "workspace.toml" ENTWURFSORDNER = ROOT / "entwuerfe" VORLAGE_ISSUE = ROOT / "docs" / "vorlagen" / "issue.md" BENUTZERKENNUNG = "AG_Prozesse-Projektwissen/1.0" ZEITLIMIT = 20 # Sekunden je Anfrage SEITENGROESSE = 50 # Gitea liefert sonst nur wenige Einträge je Seite MAX_SEITEN = 20 # Sicherheitsgrenze gegen Endlosschleifen LEER = "-" class GiteaFehler(Exception): """Netz-, HTTP- oder Formatfehler beim Zugriff auf die API (Exit-Code 1).""" class NichtGefunden(GiteaFehler): """HTTP 404: Repo oder Issue existiert nicht bzw. hat kein Ticketsystem.""" class EingabeFehler(Exception): """Unbekannter oder mehrdeutiger Repo-Name (Exit-Code 2).""" # ---------------------------------------------------------------- Manifest --- def lade_manifest() -> dict: with MANIFEST.open("rb") as f: return tomllib.load(f) def zugangsdaten(manifest: dict) -> tuple[str, str]: """Basis-URL und Organisation aus [workspace] – nie hart kodieren.""" ws = manifest.get("workspace", {}) basis = str(ws.get("gitea", "")).rstrip("/") org = str(ws.get("org", "")) if not basis or not org: raise GiteaFehler("In workspace.toml fehlen [workspace] gitea oder org.") return basis, org def repo_namen(manifest: dict) -> list[str]: """Das Workspace-Repo ([workspace] repo) zuerst, danach die Klone aus [[repos]].""" eigenes = manifest.get("workspace", {}).get("repo") namen = [eigenes] if eigenes else [] return namen + [r["name"] for r in manifest.get("repos", []) if r.get("name")] def loese_repo(kurzname: str, namen: list[str]) -> str: """Löst einen Kurznamen wie 'wissen' zu 'Projektwissen' auf. Reihenfolge: exakter Name gewinnt, dann Groß-/Kleinschreibung egal, dann eindeutige Übereinstimmung am Namensende, dann eindeutiger Teilstring. """ if kurzname in namen: return kurzname klein = kurzname.lower() for pruefung in ( lambda n: n.lower() == klein, lambda n: n.lower().endswith(klein), lambda n: klein in n.lower(), ): treffer = [n for n in namen if pruefung(n)] if len(treffer) == 1: return treffer[0] if len(treffer) > 1: raise EingabeFehler( f"Repo-Name '{kurzname}' ist mehrdeutig. Gemeint sein könnte: " + ", ".join(treffer) ) raise EingabeFehler( f"Repo '{kurzname}' steht nicht in workspace.toml. Bekannt: " + ", ".join(namen) ) # ------------------------------------------------------------------- API ------ def baue_url(basis: str, pfad: str, parameter: dict[str, str] | None = None) -> str: url = f"{basis}/api/v1{pfad}" if parameter: url += "?" + urllib.parse.urlencode(parameter) return url def hole_json(url: str) -> object: """Eine einzelne GET-Anfrage. Schreibende Methoden gibt es hier bewusst nicht.""" anfrage = urllib.request.Request( url, method="GET", headers={"User-Agent": BENUTZERKENNUNG, "Accept": "application/json"}, ) try: with urllib.request.urlopen(anfrage, timeout=ZEITLIMIT) as antwort: roh = antwort.read() except urllib.error.HTTPError as fehler: if fehler.code == 404: raise NichtGefunden("Repo/Issue nicht gefunden: " + url) from None raise GiteaFehler( f"Gitea antwortet mit HTTP {fehler.code} ({fehler.reason}) auf {url}" ) from None except TimeoutError: raise GiteaFehler( f"Gitea nicht erreichbar: Zeitlimit von {ZEITLIMIT} s überschritten. " "Netzverbindung oder Proxy prüfen." ) from None except urllib.error.URLError as fehler: raise GiteaFehler( f"Gitea nicht erreichbar: {fehler.reason}. Netzverbindung oder Proxy prüfen." ) from None except OSError as fehler: raise GiteaFehler( f"Gitea nicht erreichbar: {fehler}. Netzverbindung oder Proxy prüfen." ) from None try: return json.loads(roh.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): raise GiteaFehler(f"Unerwartete Antwort (kein JSON) von {url}") from None def hole_liste(basis: str, pfad: str, parameter: dict[str, str]) -> list[dict]: """Blättert über die Seiten, bis eine leere oder unvollständige Seite kommt.""" ergebnisse: list[dict] = [] for seite in range(1, MAX_SEITEN + 1): abfrage = dict(parameter, limit=str(SEITENGROESSE), page=str(seite)) daten = hole_json(baue_url(basis, pfad, abfrage)) if not isinstance(daten, list): # Gitea liefert bei deaktiviertem Ticketsystem ein Objekt statt einer Liste. raise NichtGefunden("Repo/Issue nicht gefunden: " + baue_url(basis, pfad, abfrage)) ergebnisse.extend(daten) if len(daten) < SEITENGROESSE: break else: print(f"Hinweis: Sicherheitsgrenze von {MAX_SEITEN} Seiten erreicht – Liste evtl. unvollständig.") return ergebnisse def hole_issues(basis: str, org: str, repo: str, state: str, art: str = "issues") -> list[dict]: """Issues EINES Repos. art='issues' blendet Pull Requests aus, 'pulls' zeigt nur sie.""" pfad = f"/repos/{urllib.parse.quote(org)}/{urllib.parse.quote(repo)}/issues" try: return hole_liste(basis, pfad, {"state": state, "type": art}) except NichtGefunden: raise NichtGefunden( f"Repo/Issue nicht gefunden: {org}/{repo} – Repo existiert nicht oder " "hat auf Gitea kein Ticketsystem." ) from None def hole_issues_org(basis: str, org: str, state: str, art: str = "issues", suchtext: str | None = None) -> list[dict]: """Issues ALLER Repos der Organisation über den Sammelendpunkt. Spart pro Aufruf zehn Einzelabfragen; Repos ohne Ticketsystem fallen dabei automatisch weg. Mit suchtext sucht Gitea serverseitig (Titel, Text und Kommentare). """ parameter = {"owner": org, "state": state, "type": art} if suchtext: parameter["q"] = suchtext return hole_liste(basis, "/repos/issues/search", parameter) def sammle_issues(basis: str, org: str, repo: str | None, namen: list[str], state: str, art: str = "issues", suchtext: str | None = None) -> list[tuple[str, list[dict]]]: """Holt die Einträge und gruppiert sie in der Reihenfolge aus workspace.toml.""" if suchtext: # Auch für ein einzelnes Repo über den Sammelendpunkt: nur er durchsucht # zusätzlich die Kommentare. eintraege = hole_issues_org(basis, org, state, art, suchtext) if repo: eintraege = [e for e in eintraege if (e.get("repository") or {}).get("name") == repo] elif repo: eintraege = hole_issues(basis, org, repo, state, art) else: eintraege = hole_issues_org(basis, org, state, art) return gruppiere_nach_repo(eintraege, namen) def gruppiere_nach_repo(eintraege: list[dict], namen: list[str]) -> list[tuple[str, list[dict]]]: nach_repo: dict[str, list[dict]] = {} for eintrag in eintraege: name = (eintrag.get("repository") or {}).get("name") or "(unbekanntes Repo)" nach_repo.setdefault(name, []).append(eintrag) reihenfolge = [n for n in namen if n in nach_repo] reihenfolge += sorted(n for n in nach_repo if n not in namen) return [(n, nach_repo[n]) for n in reihenfolge] def enthaelt(eintrag: dict, suchtext: str) -> bool: """Lokaler Volltextvergleich über Titel und Text (ohne Kommentare).""" klein = suchtext.lower() return klein in (eintrag.get("title") or "").lower() or klein in (eintrag.get("body") or "").lower() def hole_kommentare(basis: str, org: str, repo: str, nummer: int) -> list[dict]: pfad = f"/repos/{urllib.parse.quote(org)}/{urllib.parse.quote(repo)}/issues/{nummer}/comments" return hole_liste(basis, pfad, {}) def hole_issue(basis: str, org: str, repo: str, nummer: int) -> dict: pfad = f"/repos/{urllib.parse.quote(org)}/{urllib.parse.quote(repo)}/issues/{nummer}" try: daten = hole_json(baue_url(basis, pfad)) except NichtGefunden: raise NichtGefunden(f"Repo/Issue nicht gefunden: {repo} #{nummer}") from None if not isinstance(daten, dict) or "number" not in daten: raise NichtGefunden(f"Repo/Issue nicht gefunden: {repo} #{nummer}") return daten # ------------------------------------------------------------- Formatierung --- def terminalbreite(vorgabe: int | None = None) -> int: breite = vorgabe or shutil.get_terminal_size((100, 24)).columns return max(60, min(breite, 200)) def kuerze(text: str, breite: int) -> str: text = " ".join((text or "").split()) if len(text) <= breite: return text return text[: max(1, breite - 1)] + "…" def zeitpunkt(wert: str | None) -> datetime | None: if not wert: return None try: return datetime.fromisoformat(wert.replace("Z", "+00:00")) except ValueError: return None def datum(wert: str | None) -> str: zeit = zeitpunkt(wert) return zeit.strftime("%Y-%m-%d") if zeit else LEER def datum_zeit(wert: str | None) -> str: zeit = zeitpunkt(wert) return zeit.strftime("%Y-%m-%d %H:%M") if zeit else LEER def alter_tage(wert: str | None) -> int: zeit = zeitpunkt(wert) if not zeit: return 0 jetzt = datetime.now(timezone.utc) return max(0, (jetzt - zeit.astimezone(timezone.utc)).days) def labelnamen(eintrag: dict) -> list[str]: return [l.get("name", "") for l in (eintrag.get("labels") or [])] def zuweisung(eintrag: dict) -> list[str]: namen = [a.get("login", "") for a in (eintrag.get("assignees") or [])] if not namen and eintrag.get("assignee"): namen = [eintrag["assignee"].get("login", "")] return [n for n in namen if n] def mehrzahl(anzahl: int, einzahl: str, mehrzahlform: str) -> str: return f"{anzahl} {einzahl if anzahl == 1 else mehrzahlform}" def anzahl_tickets(anzahl: int, geschlossen: bool, pull: bool = False) -> str: """Zählbare Wortform für die Summenzeile, z.B. '1 offenes Ticket'.""" if pull: wort = ("offener Pull Request", "offene Pull Requests") if geschlossen: wort = ("geschlossener Pull Request", "geschlossene Pull Requests") elif geschlossen: wort = ("geschlossenes Ticket", "geschlossene Tickets") else: wort = ("offenes Ticket", "offene Tickets") return mehrzahl(anzahl, *wort) def zustandskuerzel(eintrag: dict) -> str: return "[offen]" if eintrag.get("state") == "open" else "[geschl.]" def autor(eintrag: dict) -> str: nutzer = eintrag.get("user") or {} login = nutzer.get("login", "unbekannt") voll = (nutzer.get("full_name") or "").strip() return f"{voll} ({login})" if voll and voll != login else login def tabellenkopf(breiten: dict[str, int]) -> str: return (" " + "Nr".rjust(breiten["nr"]) + " " + "Titel".ljust(breiten["titel"]) + " " + "Labels".ljust(breiten["labels"]) + " " + "Zugewiesen".ljust(breiten["zug"]) + " " + "Alter".rjust(breiten["alter"])) def spaltenbreiten(breite: int, eintraege: list[dict]) -> dict[str, int]: nr = max(4, max((len(str(e.get("number", 0))) + 1 for e in eintraege), default=4)) labels, zug, alter = 16, 11, 5 titel = breite - (nr + labels + zug + alter + 10) return {"nr": nr, "titel": max(20, titel), "labels": labels, "zug": zug, "alter": alter} def tabellenzeile(eintrag: dict, breiten: dict[str, int], zusatz: str = "") -> str: zeile = (" " + ("#" + str(eintrag.get("number", 0))).rjust(breiten["nr"]) + " " + kuerze(eintrag.get("title", ""), breiten["titel"]).ljust(breiten["titel"]) + " " + kuerze(", ".join(labelnamen(eintrag)) or LEER, breiten["labels"]).ljust(breiten["labels"]) + " " + kuerze(", ".join(zuweisung(eintrag)) or LEER, breiten["zug"]).ljust(breiten["zug"]) + " " + f"{alter_tage(eintrag.get('created_at'))}T".rjust(breiten["alter"])) return zeile + ("\n" + " " * (breiten["nr"] + 4) + zusatz if zusatz else "") def drucke_gruppen(gruppen: list[tuple[str, list[dict]]], breite: int, leermeldung: str, zusatz=None) -> tuple[int, int]: """Gibt die nach Repo gruppierte Tabelle aus; Spaltenkopf nur einmal.""" gruppen = [(repo, eintraege) for repo, eintraege in gruppen if eintraege] if not gruppen: print("\n" + leermeldung) return 0, 0 breiten = spaltenbreiten(breite, [e for _, eintraege in gruppen for e in eintraege]) print(tabellenkopf(breiten)) gesamt = 0 for repo, eintraege in gruppen: gesamt += len(eintraege) print(f"\n{repo} ({len(eintraege)})") for eintrag in sorted(eintraege, key=lambda e: -e.get("number", 0)): print(tabellenzeile(eintrag, breiten, zusatz(eintrag, breiten) if zusatz else "")) return gesamt, len(gruppen) def passt_zum_label(eintrag: dict, label: str | None) -> bool: if not label: return True return any(label.lower() == name.lower() for name in labelnamen(eintrag)) # ---------------------------------------------------------------- Befehle ----- def befehl_tickets(basis: str, org: str, repo: str | None, namen: list[str], args: argparse.Namespace) -> int: state = "closed" if args.geschlossen else "open" bezeichnung = "geschlossene" if args.geschlossen else "offene" gefunden = sammle_issues(basis, org, repo, namen, state) breite = terminalbreite(args.breite) gruppen = [(name, [i for i in issues if passt_zum_label(i, args.label)]) for name, issues in gefunden] filter_text = f", Label '{args.label}'" if args.label else "" print(f"{org} – {bezeichnung} Tickets{filter_text} (Stand {datetime.now():%Y-%m-%d})") gesamt, repos_mit = drucke_gruppen(gruppen, breite, "Keine passenden Tickets gefunden.") print(f"\nSumme: {anzahl_tickets(gesamt, args.geschlossen)} in {repos_mit} von " f"{mehrzahl(1 if repo else len(namen), 'Repo', 'Repos')} | Alter in Tagen" f" | Details: python scripts/gitea.py ticket ") return 0 def befehl_ticket(basis: str, org: str, repo: str, nummer: int) -> int: eintrag = hole_issue(basis, org, repo, nummer) kommentare = hole_kommentare(basis, org, repo, nummer) art = "Pull Request" if eintrag.get("pull_request") else "Issue" status = "offen" if eintrag.get("state") == "open" else "geschlossen" print(f"# {repo} #{eintrag['number']} – {eintrag.get('title', '')}\n") print(f"- Art: {art}") print(f"- Status: {status}" + (f" (geschlossen am {datum(eintrag.get('closed_at'))})" if eintrag.get("closed_at") else "")) print(f"- Autor: {autor(eintrag)}") print(f"- Angelegt: {datum_zeit(eintrag.get('created_at'))}" f" (vor {alter_tage(eintrag.get('created_at'))} Tagen)" f", zuletzt geändert: {datum_zeit(eintrag.get('updated_at'))}") print(f"- Labels: {', '.join(labelnamen(eintrag)) or 'keine'}") print(f"- Zugewiesen: {', '.join(zuweisung(eintrag)) or 'niemandem'}") if eintrag.get("milestone"): print(f"- Meilenstein: {eintrag['milestone'].get('title', '')}") print(f"- URL: {eintrag.get('html_url', '')}\n") print("## Beschreibung\n") print((eintrag.get("body") or "").strip() or "_(kein Text)_") print(f"\n## Kommentare ({len(kommentare)})") if not kommentare: print("\n_(keine)_") for nr, kommentar in enumerate(kommentare, start=1): print(f"\n### {nr}. {autor(kommentar)} – {datum_zeit(kommentar.get('created_at'))}\n") print((kommentar.get("body") or "").strip() or "_(kein Text)_") return 0 def fundstelle(eintrag: dict, begriff: str, breite: int) -> str: """Kurzer Textausschnitt um die erste Fundstelle im Text.""" text = " ".join((eintrag.get("body") or "").split()) stelle = text.lower().find(begriff.lower()) if stelle < 0: if begriff.lower() in (eintrag.get("title") or "").lower(): return "(Treffer im Titel)" return "(Treffer in einem Kommentar)" start = max(0, stelle - breite // 3) ausschnitt = text[start : start + breite] return ("…" if start else "") + ausschnitt + ("…" if start + breite < len(text) else "") def befehl_suche(basis: str, org: str, repo: str | None, namen: list[str], args: argparse.Namespace) -> int: # Gesucht wird standardmäßig in offenen UND geschlossenen Tickets: die Antwort # auf eine Fachfrage steht oft in einem längst geschlossenen Ticket. state = "open" if args.offen else ("closed" if args.geschlossen else "all") gefunden = sammle_issues(basis, org, repo, namen, state, suchtext=args.begriff) if not any(issues for _, issues in gefunden): # Zweiter Versuch ohne Server-Suche: Gitea findet je nach Index nicht jeden Teilstring. gefunden = [(name, [i for i in issues if enthaelt(i, args.begriff)]) for name, issues in sammle_issues(basis, org, repo, namen, state)] breite = terminalbreite(args.breite) gruppen = [(name, [i for i in issues if passt_zum_label(i, args.label)]) for name, issues in gefunden] def ausschnitt(eintrag: dict, breiten: dict[str, int]) -> str: zustand = "" if state != "all" else zustandskuerzel(eintrag) + " " return zustand + fundstelle(eintrag, args.begriff, breite - breiten["nr"] - 10) zustaende = {"open": "nur offene", "closed": "nur geschlossene", "all": "offene und geschlossene"} print(f"Suche nach '{args.begriff}' in {org} ({zustaende[state]} Tickets," f" Titel, Text und Kommentare)") gesamt, _ = drucke_gruppen(gruppen, breite, "Keine Treffer.", ausschnitt) print(f"\nSumme: {gesamt} Treffer | Details: python scripts/gitea.py ticket ") return 0 def befehl_pulls(basis: str, org: str, repo: str | None, namen: list[str], args: argparse.Namespace) -> int: state = "closed" if args.geschlossen else "open" bezeichnung = "geschlossene" if args.geschlossen else "offene" gefunden = sammle_issues(basis, org, repo, namen, state, art="pulls") breite = terminalbreite(args.breite) gruppen = [(name, [p for p in pulls if passt_zum_label(p, args.label)]) for name, pulls in gefunden] print(f"{org} – {bezeichnung} Pull Requests (Stand {datetime.now():%Y-%m-%d})") gesamt, _ = drucke_gruppen(gruppen, breite, "Keine passenden Pull Requests gefunden.", lambda eintrag, breiten: f"von {autor(eintrag)}") print(f"\nSumme: {anzahl_tickets(gesamt, args.geschlossen, pull=True)} in " f"{mehrzahl(1 if repo else len(namen), 'Repo', 'Repos')}") return 0 def befehl_entwurf(basis: str, org: str, repo: str, art: str, nummer: int | None) -> int: """Legt eine Entwurfsdatei an. Abgeschickt wird sie ausschließlich von Hand.""" zeitstempel = datetime.now().strftime("%Y%m%d-%H%M%S") web = f"{basis}/{org}/{repo}" if art == "issue": ziel = ENTWURFSORDNER / f"{zeitstempel}-issue-{repo}.md" kopf = f"# Entwurf: neues Issue in {org}/{repo}" vorlage = (VORLAGE_ISSUE.read_text(encoding="utf-8").strip() if VORLAGE_ISSUE.exists() else "## Ziel\n\n\n## Abnahmekriterien\n- [ ] <…>") quelle = ("docs/vorlagen/issue.md" if VORLAGE_ISSUE.exists() else "Notvorlage (docs/vorlagen/issue.md fehlt)") koerper = f"Titel: \n\n{vorlage}" absenden = (f"1. Seite öffnen: {web}/issues/new\n" "2. Titelzeile und Text unterhalb von `--- ENTWURF ---` von Hand einfügen" " (die Zeile `Titel:` selbst nicht mitkopieren).\n" "3. Labels, Zuweisung und Meilenstein setzen und das Issue anlegen.") hinweis = f"Vorlage: {quelle}" else: eintrag = hole_issue(basis, org, repo, int(nummer)) ziel = ENTWURFSORDNER / f"{zeitstempel}-kommentar-{repo}-{nummer}.md" kopf = f"# Entwurf: Kommentar zu {org}/{repo} #{nummer}" koerper = "" absenden = (f"1. Seite öffnen: {web}/issues/{nummer}\n" "2. Text unterhalb von `--- ENTWURF ---` in das Kommentarfeld einfügen.\n" "3. Kommentar abschicken.") hinweis = (f"Ticket: #{nummer} – {eintrag.get('title', '')}" f" (Status: {'offen' if eintrag.get('state') == 'open' else 'geschlossen'})") inhalt = "\n".join([ kopf, "", f"- Ziel-Repo: {org}/{repo} ({web})", f"- {hinweis}", f"- Angelegt: {datetime.now():%Y-%m-%d %H:%M} von scripts/gitea.py", "", "> **Dieser Entwurf wird NICHT automatisch abgeschickt.** KI-Agenten verändern auf", "> Gitea nichts. Ein Mensch prüft den Text und stellt ihn selbst über die", "> Weboberfläche ein.", "", "--- ENTWURF ---", "", koerper, "", "--- ENDE ENTWURF ---", "", "## Absenden (von Hand)", "", absenden, "", ]) ENTWURFSORDNER.mkdir(parents=True, exist_ok=True) ziel.write_text(inhalt, encoding="utf-8") print(f"Entwurf angelegt: {ziel.relative_to(ROOT)}") print("Bitte prüfen, ergänzen und selbst auf Gitea einstellen – das Skript sendet nichts.") return 0 # ------------------------------------------------------------------ main ------ def main() -> int: breite_eltern = argparse.ArgumentParser(add_help=False) breite_eltern.add_argument("--breite", type=int, help="Terminalbreite erzwingen (Standard: automatisch)") ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) unter = ap.add_subparsers(dest="befehl", required=True) p = unter.add_parser("tickets", parents=[breite_eltern], help="Issues auflisten (Standard: alle Repos, offen)") p.add_argument("repo", nargs="?", help="Repo oder Kurzname (z.B. Projektwissen); ohne Angabe: alle") p.add_argument("--geschlossen", action="store_true", help="geschlossene statt offener Tickets") p.add_argument("--label", help="nur Tickets mit diesem Label (z.B. Bug)") p = unter.add_parser("ticket", help="ein Issue mit allen Kommentaren als Markdown") p.add_argument("repo", help="Repo oder Kurzname (z.B. Projektwissen)") p.add_argument("nummer", type=int, help="Issue-Nummer") p = unter.add_parser("suche", parents=[breite_eltern], help="Volltextsuche über Titel und Text") p.add_argument("begriff", help="Suchbegriff (Groß-/Kleinschreibung egal)") p.add_argument("repo", nargs="?", help="Repo oder Kurzname; ohne Angabe: alle") p.add_argument("--offen", action="store_true", help="nur offene Tickets (Standard: offene und geschlossene)") p.add_argument("--geschlossen", action="store_true", help="nur geschlossene Tickets") p.add_argument("--label", help="zusätzlich nach Label filtern") p = unter.add_parser("pulls", parents=[breite_eltern], help="Pull Requests auflisten") p.add_argument("repo", nargs="?", help="Repo oder Kurzname; ohne Angabe: alle") p.add_argument("--geschlossen", action="store_true", help="geschlossene statt offener PRs") p.add_argument("--label", help="nur PRs mit diesem Label") p = unter.add_parser("entwurf", help="Entwurfsdatei für einen Menschen vorbereiten (sendet nichts)") p.add_argument("art", choices=["issue", "kommentar"], help="Art des Entwurfs") p.add_argument("repo", help="Repo oder Kurzname (z.B. Projektwissen)") p.add_argument("nummer", nargs="?", type=int, help="Issue-Nummer (nur bei 'kommentar')") args = ap.parse_args() try: manifest = lade_manifest() basis, org = zugangsdaten(manifest) namen = repo_namen(manifest) if not namen: raise GiteaFehler("workspace.toml nennt weder [workspace] repo noch [[repos]]-Einträge.") if args.befehl == "entwurf" and args.art == "kommentar" and args.nummer is None: print("Für 'entwurf kommentar' wird die Issue-Nummer gebraucht, " "z.B. python scripts/gitea.py entwurf kommentar Projektwissen 12") return 2 repo = loese_repo(args.repo, namen) if getattr(args, "repo", None) else None if args.befehl == "tickets": return befehl_tickets(basis, org, repo, namen, args) if args.befehl == "ticket": return befehl_ticket(basis, org, repo, args.nummer) if args.befehl == "suche": return befehl_suche(basis, org, repo, namen, args) if args.befehl == "pulls": return befehl_pulls(basis, org, repo, namen, args) if args.befehl == "entwurf": return befehl_entwurf(basis, org, repo, args.art, args.nummer) except EingabeFehler as fehler: print(fehler) return 2 except NichtGefunden as fehler: print(str(fehler)) return 1 except GiteaFehler as fehler: print(str(fehler)) return 1 except KeyboardInterrupt: print("Abgebrochen.") return 1 return 0 if __name__ == "__main__": raise SystemExit(main())