Projektwissen der AG Prozesse aus AG_QGIS/Projektwissen abgeleitet

Übernommen ohne Historie aus AG_QGIS/Projektwissen (main @ decd2a0),
Skill sn-bpmn vom Branch BPMN-Skill @ 6efcac4 (ADR 0001).

- Skripte hallo.py, gitea.py, setup_workspace.py auf die Organisation
  AG_Prozesse umgestellt; das Workspace-Repo zählt über [workspace] repo
  als Ticket-Repo, auch ohne weitere [[repos]].
- pruefe_umlaute.py prüft jetzt die .py-Dateien des Workspace selbst.
- Branchmodell main plus Arbeitsbranches (ADR 0002).
- AGENTS.md, README, Leitfäden, Skills, Vorlagen, Fallstricke und Glossar
  auf die Prozessmodellierung zugeschnitten; QGIS- und Plugin-Spezifisches
  entfernt.
- Tests: python -m pytest -q .agents/skills -> 32 bestanden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 12:34:37 +02:00
co-authored by Claude Opus 5.5
commit 012509b6d1
36 changed files with 4414 additions and 0 deletions
+626
View File
@@ -0,0 +1,626 @@
#!/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 <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:
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())