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>
655 lines
28 KiB
Python
655 lines
28 KiB
Python
#!/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())
|