Files
Projektwissen/scripts/gitea.py
T
DanielandClaude Opus 5.5 fd58d75502 Regeln für PR- und Issue-Texte an AG_QGIS angleichen (#3)
Beim Übernehmen aus AG_QGIS/Projektwissen (main @ a0bb40a) waren Regeln
verloren gegangen oder eingeengt worden:

- PR-Vorlage: Abschnitt „Änderungen an Syntax und Ablageformat“ (dort
  „Änderungen an Verträgen“), Checklistenpunkt „keine Debug-print“.
- Issue-Vorlage: Frage nach Syntax/Ablageformat, Testpflicht nicht nur
  für den Konverter.
- AGENTS.md: Testpflicht wieder für jede neue Funktionalität; Syntax und
  Ablageformat als Verträge; kein Arbeitsbranch ohne Issue; PR-Text
  vollständig mit Checkliste. Skill sn-aufgabe-bearbeiten und Checkliste
  in Leitfaden 03 entsprechend.
- ADR 0001 und STATUS: Quell-Commit war a0bb40a, nicht decd2a0.

gitea.py prüft vor jeder Abfrage, ob die Repos ohne Anmeldung lesbar
sind, und bricht sonst mit klarer Meldung ab – bisher meldete die
Sammelsuche bei einem privaten Repo „Keine passenden Tickets“. Die
Ausgabe läuft jetzt über UTF-8 (cp1252-Konsolen stürzten bei „→“ ab).

Tests: python -m pytest -q -> 61 bestanden (6 neu in tests/test_gitea.py).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 13:50:24 +02:00

655 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 sys
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 pruefe_sichtbar(basis: str, org: str, namen: list[str]) -> None:
"""Bricht mit klarer Meldung ab, wenn ein Repo ohne Anmeldung nicht lesbar ist.
Ohne diese Prüfung liefert die Sammelsuche über die Organisation für ein privates
Repo einfach keine Treffer – das sieht aus wie „keine offenen Tickets“.
"""
unsichtbar = []
for name in namen:
try:
hole_json(baue_url(basis, f"/repos/{urllib.parse.quote(org)}/{urllib.parse.quote(name)}"))
except NichtGefunden:
unsichtbar.append(name)
if unsichtbar:
raise NichtGefunden(
f"Ohne Anmeldung nicht lesbar: {', '.join(f'{org}/{n}' for n in unsichtbar)}. "
"Das Repo ist privat oder existiert nicht. scripts/gitea.py liest bewusst ohne Token; "
"das Repo muss in Gitea öffentlich sein (Repo → Einstellungen → Sichtbarkeit)."
)
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 <Repo> <Nr>")
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 <Repo> <Nr>")
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<Was soll am Ende möglich sein?>\n\n## Abnahmekriterien\n- [ ] <…>")
quelle = ("docs/vorlagen/issue.md" if VORLAGE_ISSUE.exists()
else "Notvorlage (docs/vorlagen/issue.md fehlt)")
koerper = f"Titel: <Kurzer, sprechender 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 = "<Kommentartext hier eintragen>"
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:
# Windows-Konsolen und Pipes laufen oft mit cp1252; Zeichen wie „→“ in Tickettexten
# oder Meldungen führten sonst zum Absturz der Ausgabe.
for strom in (sys.stdout, sys.stderr):
if hasattr(strom, "reconfigure"):
strom.reconfigure(encoding="utf-8", errors="replace")
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 not (args.befehl == "entwurf" and args.art == "issue"): # nur der Issue-Entwurf kommt ohne Netz aus
pruefe_sichtbar(basis, org, [repo] if repo else namen)
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())