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())
+415
View File
@@ -0,0 +1,415 @@
#!/usr/bin/env python3
"""Sitzungsbeginn: zeigt den Stand aller Klone und aktualisiert die Hauptbranches.
Für jedes Repository aus workspace.toml – und für den Workspace-Ordner selbst, sofern
er ein Git-Repository ist – holt das Skript den Stand vom Server (git fetch --prune),
ermittelt Branch, ungesicherte Änderungen und den Abstand zu origin und macht genau
dann einen fast-forward-Pull, wenn das gefahrlos ist:
* das Arbeitsverzeichnis ist sauber (git status --porcelain leer),
* der aktuelle Branch ist der Hauptbranch aus workspace.toml,
* es gibt einen Upstream,
* wir sind hinter origin und haben keine eigenen Commits davor.
In allen anderen Fällen wird nur berichtet und nichts verändert. Am Ende stehen eine
Tabelle (eine Zeile je Repository) und, falls nötig, konkrete Handlungsempfehlungen.
Absolut verboten und deshalb nirgends im Code enthalten:
git merge, git rebase, git stash, git reset, git checkout/switch auf einen anderen
Branch, git push, git tag sowie jedes Verwerfen von Änderungen.
Ungesicherte oder unversionierte Dateien werden nie angefasst. Die einzige schreibende
Git-Operation des Skripts ist `git pull --ff-only` unter den vier Bedingungen oben.
Aufrufe (aus dem Workspace-Root):
python scripts/hallo.py # fetch + fast-forward-Pull + Bericht
python scripts/hallo.py --nur-pruefen # fetch + Bericht, kein Pull
python scripts/hallo.py --ohne-netz # nur lokaler Stand, kein fetch
python scripts/hallo.py --repo Projektwissen
Der Hauptbranch des Workspace-Repos selbst kann in workspace.toml unter
[workspace] branch = "..." gesetzt werden; ohne Eintrag gilt sein aktueller Branch.
Exit-Code: 0 = nichts Kritisches, 1 = ein Repo-Ordner fehlt oder ein Git-Befehl ist
unerwartet gescheitert, 2 = Aufruffehler (unbekanntes Repo, workspace.toml unlesbar).
"""
from __future__ import annotations
import argparse
import os
import subprocess
import textwrap
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "workspace.toml"
LOKALE_ÜBERSCHREIBUNG = ROOT / "workspace.local.toml"
def repos_root() -> Path:
"""Ordner, unter dem die weiteren Repositories aus [[repos]] liegen.
Standard: ROOT selbst (Repos als direkte Unterordner, wie in workspace.toml
dokumentiert). Wer die Klone woanders liegen hat, kann das lokal und nicht
versioniert in workspace.local.toml überschreiben:
[workspace]
repos_root = "D:/Klone/AG_Prozesse"
"""
if not LOKALE_ÜBERSCHREIBUNG.is_file():
return ROOT
with LOKALE_ÜBERSCHREIBUNG.open("rb") as f:
lokal = tomllib.load(f)
pfad = lokal.get("workspace", {}).get("repos_root")
return Path(pfad).expanduser() if pfad else ROOT
BREITE = 100 # Zielbreite der Tabelle (lesbar im 100-Zeichen-Terminal)
FETCH_TIMEOUT = 60 # Sekunden je git fetch, damit ein hängender Server nicht blockiert
# Git ohne interaktive Rückfragen: sonst wartet ein fetch ewig auf eine Passworteingabe.
GIT_UMGEBUNG = dict(os.environ, GIT_TERMINAL_PROMPT="0", GCM_INTERACTIVE="never")
def git(*args: str, cwd: Path, timeout: int | None = None) -> tuple[int, str]:
"""Führt git aus und liefert (Rückgabecode, Ausgabe). Wirft nie eine Ausnahme."""
try:
p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True,
errors="replace", env=GIT_UMGEBUNG, timeout=timeout)
except FileNotFoundError:
return 127, "git wurde nicht gefunden (steht git im PATH?)"
except subprocess.TimeoutExpired:
return 124, f"Zeitüberschreitung nach {timeout} s"
return p.returncode, (p.stdout + p.stderr).strip()
def kurz(text: str, breite: int = 70) -> str:
"""Letzte, gekürzte Zeile einer Git-Ausgabe – für einzeilige Meldungen."""
zeilen = [z for z in text.splitlines() if z.strip()]
letzte = zeilen[-1].strip() if zeilen else ""
return letzte if len(letzte) <= breite else letzte[: breite - 1] + "…"
def kuerze(text: str, breite: int) -> str:
return text if len(text) <= breite else text[: max(1, breite - 1)] + "~"
def umbrechen(zeile: str) -> str:
"""Bricht Fließtext auf BREITE um; Befehlszeilen (vier Leerzeichen Einzug) bleiben ganz."""
if zeile.startswith(" ") or len(zeile) <= BREITE:
return zeile
einzug = " " * (len(zeile) - len(zeile.lstrip()))
return textwrap.fill(zeile.strip(), width=BREITE, initial_indent=einzug,
subsequent_indent=einzug)
def commits(anzahl: int) -> str:
return f"{anzahl} Commit" if anzahl == 1 else f"{anzahl} Commits"
def aenderungen(anzahl: int) -> str:
return f"{anzahl} ungesicherte Änderung" if anzahl == 1 else f"{anzahl} ungesicherte Änderungen"
@dataclass
class Befund:
"""Zustand eines Klons – wird erst gefüllt, dann als Tabellenzeile ausgegeben."""
name: str
kanal: str
pfad: Path
ist_wurzel: bool = False
vorhanden: bool = True
lesbar: bool = True
branch: str = "-"
geaendert: int = 0
hinter: int = 0
vor: int = 0
upstream: bool = False
remote: bool = True
offline: bool = False
aktualisiert: bool = False
zustand: str = "-"
aktion: str = "-"
kritisch: bool = False
hinweise: list[str] = field(default_factory=list)
def befehl(self, rest: str) -> str:
"""Kopierbarer Git-Befehl, aufzurufen aus dem Workspace-Root."""
return f"git {rest}" if self.ist_wurzel else f"git -C {self.name} {rest}"
@property
def abstand(self) -> str:
return "-" if not (self.vorhanden and self.upstream) else f"{self.hinter}/{self.vor}"
def zustand_ermitteln(b: Befund) -> str:
"""Ein Klartextwort für die Spalte Zustand (ohne Emoji, auch für KI-Agenten lesbar)."""
if b.geaendert:
return f"geaendert ({b.geaendert})"
if b.branch == "HEAD":
return "detached HEAD"
if b.branch != b.kanal:
return "fremder branch"
if not b.upstream:
return "kein upstream"
if b.offline:
return "offline"
return "sauber"
def einlesen(b: Befund, ohne_netz: bool) -> None:
"""Nur lesende Git-Befehle: fetch, rev-parse, status, rev-list."""
if not b.pfad.is_dir():
b.vorhanden, b.zustand, b.aktion, b.kritisch = False, "fehlt", "Ordner fehlt", True
return
if not (b.pfad / ".git").exists():
b.vorhanden, b.zustand, b.aktion, b.kritisch = False, "kein git-repo", "Ordner ohne .git", True
return
_, remotes = git("remote", cwd=b.pfad)
b.remote = bool(remotes.strip())
if not b.remote:
b.hinweise.append("kein Remote konfiguriert – kein Abgleich mit origin möglich.")
elif not ohne_netz:
rc, ausgabe = git("fetch", "--prune", "--quiet", cwd=b.pfad, timeout=FETCH_TIMEOUT)
if rc != 0:
b.offline = True
b.hinweise.append(f"origin nicht erreichbar: {kurz(ausgabe)}")
rc, branch = git("rev-parse", "--abbrev-ref", "HEAD", cwd=b.pfad)
if rc != 0:
b.lesbar, b.zustand, b.aktion, b.kritisch = False, "git-fehler", f"FEHLER: {kurz(branch, 30)}", True
b.hinweise.append(f"git rev-parse fehlgeschlagen: {kurz(branch)}")
return
b.branch = branch
if not b.kanal: # Workspace-Repo ohne Vorgabe in workspace.toml
b.kanal = branch
rc, status = git("status", "--porcelain", cwd=b.pfad)
if rc != 0:
b.lesbar, b.zustand, b.aktion, b.kritisch = False, "git-fehler", f"FEHLER: {kurz(status, 30)}", True
b.hinweise.append(f"git status fehlgeschlagen: {kurz(status)}")
return
b.geaendert = len([z for z in status.splitlines() if z.strip()])
rc, _ = git("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}", cwd=b.pfad)
b.upstream = rc == 0
if b.upstream:
rc, zaehl = git("rev-list", "--left-right", "--count", "HEAD...@{upstream}", cwd=b.pfad)
teile = zaehl.split()
if rc == 0 and len(teile) == 2 and all(t.isdigit() for t in teile):
b.vor, b.hinter = int(teile[0]), int(teile[1])
else:
b.kritisch = True
b.hinweise.append(f"Abstand zu origin nicht bestimmbar: {kurz(zaehl)}")
b.zustand = zustand_ermitteln(b)
def abgleichen(b: Befund, nur_pruefen: bool, ohne_netz: bool) -> None:
"""Entscheidet über den fast-forward-Pull. Bei jedem Zweifel: nichts tun, nur melden."""
if not (b.vorhanden and b.lesbar):
return
if b.geaendert:
b.aktion = "uebersprungen: Aenderungen vorhanden"
return
if b.branch == "HEAD":
b.aktion = "uebersprungen: detached HEAD"
return
if b.branch != b.kanal:
b.aktion = "uebersprungen: fremder branch"
return
if not b.upstream:
b.aktion = "uebersprungen: kein upstream"
return
if b.offline: # ohne erfolgreichen fetch ist der Abstand nur geraten
b.aktion = "uebersprungen: offline"
return
if b.vor and b.hinter:
b.aktion = "uebersprungen: divergiert"
return
if b.vor:
eigene = "1 eigener Commit" if b.vor == 1 else f"{b.vor} eigene Commits"
b.aktion = f"uebersprungen: {eigene}"
return
if not b.hinter:
b.aktion = "bereits aktuell"
return
if nur_pruefen or ohne_netz:
b.aktion = f"hinter origin ({commits(b.hinter)})"
return
rc, ausgabe = git("pull", "--ff-only", "--quiet", cwd=b.pfad)
if rc == 0:
b.aktion = f"aktualisiert ({commits(b.hinter)})"
b.aktualisiert, b.hinter = True, 0
b.zustand = zustand_ermitteln(b)
else:
b.aktion, b.kritisch = "FEHLER: pull fehlgeschlagen", True
b.hinweise.append(f"git pull --ff-only fehlgeschlagen: {kurz(ausgabe)}")
def tabelle_ausgeben(befunde: list[Befund]) -> None:
"""Eine Zeile je Repository, insgesamt hoechstens BREITE Zeichen breit."""
spalten = [
("Repo", 34, [b.name for b in befunde]),
("Branch", 14, [b.branch for b in befunde]),
("Zustand", 16, [b.zustand for b in befunde]),
("hinter/vor", 10, [b.abstand for b in befunde]),
]
breiten = [min(hoechst, max([len(kopf)] + [len(w) for w in werte]))
for kopf, hoechst, werte in spalten]
platz = max(20, BREITE - (sum(breiten) + len(breiten)))
b_aktion = min(platz, max([len("Aktion")] + [len(b.aktion) for b in befunde]))
def zeile(felder: list[str]) -> str:
gefuellt = [kuerze(w, br).ljust(br) for w, br in zip(felder[:-1], breiten)]
return " ".join(gefuellt) + " " + kuerze(felder[-1], b_aktion)
kopfzeile = zeile(["Repo", "Branch", "Zustand", "hinter/vor", "Aktion"])
print(kopfzeile)
print("-" * (sum(breiten) + len(breiten) + b_aktion))
for b in befunde:
print(zeile([b.name, b.branch, b.zustand, b.abstand, b.aktion]).rstrip())
def empfehlungen(befunde: list[Befund], nur_pruefen: bool, ohne_netz: bool) -> list[str]:
"""Konkrete, kopierbare Handlungsempfehlungen – nur für das, was wirklich ansteht."""
zeilen: list[str] = []
fehlend = [b.name for b in befunde if b.zustand == "fehlt"]
if fehlend:
zeilen.append(f"Repository-Ordner fehlen: {', '.join(fehlend)}. Klonen mit:")
zeilen.append(" python scripts/setup_workspace.py")
ohne_git = [b.name for b in befunde if b.zustand == "kein git-repo"]
if ohne_git:
zeilen.append(f"Ordner ohne .git (kein Klon): {', '.join(ohne_git)}. Von Hand prüfen und "
"wegräumen, danach: python scripts/setup_workspace.py")
for b in befunde:
if not b.vorhanden:
continue
block: list[str] = []
if b.offline:
block.append("origin nicht erreichbar – Netz/VPN prüfen. Die Angaben zu hinter/vor "
"stammen vom letzten erfolgreichen fetch.")
if not b.remote:
block.append("kein Remote konfiguriert – gehört der Ordner wirklich zum Workspace?")
if b.geaendert:
block.append(f"{aenderungen(b.geaendert)} – dieses Skript fasst sie nicht an. "
"Selbst sichten, committen oder verwerfen:")
block.append(f" {b.befehl('status')}")
if b.branch == "HEAD":
block.append("HEAD ist losgelöst (kein Branch). Vor der Arbeit auf einen Branch wechseln – "
"das macht das Skript bewusst nicht selbst.")
elif b.branch != b.kanal:
block.append(f"steht auf Branch '{b.branch}' statt auf dem Hauptbranch '{b.kanal}'. "
f"Das ist richtig so, solange daran gearbeitet wird: Der Hauptbranch wird nie "
f"direkt bearbeitet, die Rückführung läuft über einen Pull Request auf Gitea. "
f"Ist der Branch fertig, den PR stellen; '{b.kanal}' wird von diesem Skript "
f"nur aktualisiert, wenn er ausgecheckt ist.")
if b.upstream and b.vor and b.hinter:
block.append(f"divergiert: {commits(b.vor)} lokal, {commits(b.hinter)} auf origin. "
"Weder Merge noch Rebase noch Reset macht dieses Skript – bitte selbst entscheiden.")
elif b.upstream and b.vor:
block.append(f"{commits(b.vor)} lokal, nicht gepusht. Pushen ist Sache des Menschen "
"und passiert nur nach Absprache:")
block.append(f" {b.befehl(f'push origin {b.branch}')}")
if not b.upstream and b.remote and b.branch != "HEAD":
block.append(f"Branch '{b.branch}' hat keinen Upstream. Erst nach Absprache und nur vom "
"Menschen:")
block.append(f" {b.befehl(f'push -u origin {b.branch}')}")
if b.hinter and not b.geaendert and b.branch == b.kanal and not b.vor:
grund = ("fehlendem Netzkontakt" if b.offline else "--nur-pruefen" if nur_pruefen
else "--ohne-netz" if ohne_netz else "einem Hindernis")
block.append(f"{commits(b.hinter)} hinter origin, wegen {grund} nicht geholt. Nachholen:")
block.append(f" python scripts/hallo.py --repo {b.name}")
elif b.hinter and (b.geaendert or b.branch != b.kanal):
block.append(f"Hauptbranch liegt {commits(b.hinter)} hinter origin – erst nach dem "
f"Aufräumen oben aktualisierbar.")
for meldung in b.hinweise:
if meldung.startswith(("origin nicht erreichbar", "kein Remote")):
continue # oben bereits in Klartext ausgegeben
block.append(meldung)
if block:
zeilen.append(f"{b.name}:")
zeilen.extend(f" {z}" if not z.startswith(" ") else z for z in block)
return zeilen
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--nur-pruefen", action="store_true", help="kein Pull, nur Bericht (fetch findet statt)")
ap.add_argument("--ohne-netz", action="store_true", help="kein fetch und kein Pull, nur lokaler Zustand")
ap.add_argument("--repo", action="append", metavar="NAME", help="nur dieses Repo (mehrfach angebbar)")
args = ap.parse_args()
try:
with MANIFEST.open("rb") as f:
manifest = tomllib.load(f)
except (OSError, tomllib.TOMLDecodeError) as fehler:
print(f"workspace.toml nicht lesbar: {fehler}")
return 2
repos = manifest.get("repos", [])
# Das Workspace-Repo heißt wie in [workspace] repo angegeben, sonst wie sein Ordner.
wurzel_name = manifest.get("workspace", {}).get("repo") or ROOT.name
bekannt = [wurzel_name] + [r["name"] for r in repos]
if args.repo:
unbekannt = [n for n in args.repo if n not in bekannt]
if unbekannt:
print(f"Unbekannte Repositories: {', '.join(unbekannt)}")
print(f"Bekannt sind: {', '.join(bekannt)}")
return 2
def gewuenscht(name: str) -> bool:
return args.repo is None or name in args.repo
befunde: list[Befund] = []
wurzel_ist_repo = (ROOT / ".git").exists()
if wurzel_ist_repo and gewuenscht(wurzel_name):
befunde.append(Befund(name=wurzel_name, kanal=manifest.get("workspace", {}).get("branch", ""),
pfad=ROOT, ist_wurzel=True))
rroot = repos_root()
befunde += [Befund(name=r["name"], kanal=r["branch"], pfad=rroot / r["name"])
for r in repos if gewuenscht(r["name"])]
modus = ("ohne Netz – kein fetch, kein Pull" if args.ohne_netz
else "nur prüfen – fetch, kein Pull" if args.nur_pruefen
else "fetch und fast-forward-Pull")
print(f"Hallo. Workspace: {ROOT}")
if rroot != ROOT:
print(f"Repositories liegen laut workspace.local.toml unter: {rroot}")
print(f"Modus: {modus} | Repositories: {len(befunde)}")
if not wurzel_ist_repo and gewuenscht(wurzel_name):
print(f"Hinweis: '{wurzel_name}' selbst ist noch kein Git-Repository – geprüft werden nur die Klone.")
if not args.ohne_netz:
print("Hole den Stand von origin (git fetch --prune) ...")
print()
for b in befunde:
einlesen(b, args.ohne_netz)
abgleichen(b, args.nur_pruefen, args.ohne_netz)
tabelle_ausgeben(befunde)
aktualisiert = sum(1 for b in befunde if b.aktualisiert)
aktuell = sum(1 for b in befunde if b.aktion == "bereits aktuell")
offen = len(befunde) - aktualisiert - aktuell
wort = "Repository" if len(befunde) == 1 else "Repositories"
print(f"\n{len(befunde)} {wort}: {aktualisiert} aktualisiert, {aktuell} bereits aktuell, "
f"{offen} mit Befund.")
if args.ohne_netz:
print("Ohne Netz gelaufen: hinter/vor stammt vom letzten fetch und kann veraltet sein.")
zu_tun = empfehlungen(befunde, args.nur_pruefen, args.ohne_netz)
if zu_tun:
print("\nZu tun")
print("-" * 6)
for zeile in zu_tun:
print(umbrechen(zeile))
return 1 if any(b.kritisch for b in befunde) else 0
if __name__ == "__main__":
raise SystemExit(main())
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Findet deutschen Text in .py-Dateien, der offenbar ohne Umlaute/ß geschrieben wurde.
Hintergrund: AG_QGIS, Plugin_SN_Basis #42. KI-generierter Text weicht gelegentlich auf eine
ASCII-Ersatzschreibweise aus ("gueltig" statt "gültig", "ueber" statt "über") statt
echte Umlaute zu verwenden. Das betrifft vor allem Meldungstexte, Beschriftungen
(etwa BPMN-Elementnamen) und Docstrings/Kommentare.
Technische Bezeichner (Variablen-, Funktions- und Schlüsselnamen) sind davon
ausdrücklich nicht betroffen: Sie sind bewusst ASCII und enthalten kein Leerzeichen, während geschriebener deutscher Text so gut
wie immer eines enthält. Deshalb prüft dieses Skript nur String-Inhalte mit
mindestens einem Leerzeichen sowie Kommentartext - keine Bezeichner.
Kein Ersatz fürs Korrekturlesen: Die Liste verdächtiger Wortstämme ist bewusst kurz
gehalten (nur eindeutige Fälle), um keine Fehlalarme zu erzeugen. Neue, hier nicht
erfasste Wortstämme ergänzen.
Aufrufe (aus dem Workspace-Root):
python scripts/pruefe_umlaute.py # alle .py-Dateien im Workspace
python scripts/pruefe_umlaute.py .agents/skills # nur unterhalb dieser Ordner
Exit-Code: 0 = keine Funde, 1 = mindestens ein Fund.
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
# Ordner, die nie geprüft werden (Git-Interna, gespiegelte Skills, lokale Entwürfe).
AUSGESCHLOSSEN = {".git", ".claude", "__pycache__", "entwuerfe"}
# Dateien, die bewusst ASCII-Ersatzschreibweisen enthalten: hallo.py schreibt seine
# Tabellenwerte absichtlich ohne Umlaute, dieses Skript nennt die Muster selbst.
DATEIEN_OHNE_PRUEFUNG = {"scripts/hallo.py", "scripts/pruefe_umlaute.py"}
# Nur eindeutige Fälle: Wortstämme, die als ASCII-Ersatzschreibweise so gut wie nie
# etwas anderes als ein fehlender Umlaut/ß sein können. Absichtlich kein "ss" für
# "ß" (zu viele legitime Doppel-S-Wörter) und kein "ae/oe/ue" pauschal (zu viele
# Fehlalarme in technischen Bezeichnern und Fremdwörtern).
VERDAECHTIGE_STAEMME = [
r"gueltig\w*", r"ungueltig\w*", r"verfuegbar\w*", r"ausgewaehlt\w*",
r"gewaehlt\w*", r"waehlen\b", r"auswaehlen\b", r"loeschen\b", r"loescht\w*",
r"groesse\w*", r"zuruecksetzen\b", r"erlaeuter\w*", r"bestaetig\w*",
r"natuerlich\w*", r"moeglich\w*", r"zusaetzlich\w*", r"abhaengig\w*",
r"unzulaessig\w*", r"ueberpruef\w*", r"vollstaendig\w*", r"geaendert\w*",
r"aenderung\w*", r"einfuegen\b", r"ausfuehr\w*", r"benoetig\w*",
r"gewuenscht\w*", r"ueber\w*", r"fuer\b", r"hoehe\w*", r"waere\b",
]
_STAMM_PATTERN = re.compile(
r"\b(?:" + "|".join(VERDAECHTIGE_STAEMME) + r")\b", re.IGNORECASE
)
# Erlaubt genau diese exakten Fundstellen (Datei, Zeile) - für Ausnahmefälle, die
# geprüft und bewusst akzeptiert wurden. Aktuell leer: siehe Docstring, warum
# technische Bezeichner die Heuristik ohnehin nicht erreichen.
ALLOWLIST: set[tuple[str, int]] = set()
_STRING_PATTERN = re.compile(
r'"""(?P<triple_d>(?:.|\n)*?)"""'
r"|'''(?P<triple_s>(?:.|\n)*?)'''"
r'|"(?P<double>(?:[^"\\\n]|\\.)*)"'
r"|'(?P<single>(?:[^'\\\n]|\\.)*)'"
)
_KOMMENTAR_PATTERN = re.compile(r"#(?P<text>.*)$", re.MULTILINE)
def _kandidaten(quelltext: str):
"""Liefert (start_offset, text) für String-Inhalte und Kommentare mit Leerzeichen."""
for m in _STRING_PATTERN.finditer(quelltext):
text = next(g for g in m.groups() if g is not None)
if " " in text:
yield m.start(), text
for m in _KOMMENTAR_PATTERN.finditer(quelltext):
text = m.group("text")
if " " in text:
yield m.start("text"), text
def pruefe_datei(pfad: Path) -> list[tuple[int, str, str]]:
"""Liefert Funde als (Zeilennummer, Wortstamm, Kontext) für eine Datei.
Ein Kandidat (String-Inhalt oder Kommentar) kann mehrzeilig sein (Docstrings);
deshalb wird für jeden Treffer einzeln die Zeile anhand seiner eigenen Position
berechnet, nicht anhand des Kandidaten-Anfangs.
"""
quelltext = pfad.read_text(encoding="utf-8", errors="replace")
funde = []
for offset, text in _kandidaten(quelltext):
for treffer in _STAMM_PATTERN.finditer(text):
zeile = quelltext.count("\n", 0, offset + treffer.start()) + 1
zeilen_text = text.splitlines()[treffer.string.count("\n", 0, treffer.start())] \
if "\n" in text else text
kontext = zeilen_text.strip()
if len(kontext) > 90:
kontext = kontext[:87] + "..."
funde.append((zeile, treffer.group(0), kontext))
return funde
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("ordner", nargs="*", help="Ordner relativ zum Workspace-Root (Standard: alles)")
args = ap.parse_args()
wurzeln = [ROOT / o for o in args.ordner] if args.ordner else [ROOT]
fehlend = [w for w in wurzeln if not w.is_dir()]
if fehlend:
print("Ordner nicht gefunden: " + ", ".join(str(w) for w in fehlend))
return 2
gesamt_funde = 0
dateien = 0
for wurzel in wurzeln:
for py_datei in sorted(wurzel.rglob("*.py")):
if any(teil in AUSGESCHLOSSEN for teil in py_datei.relative_to(ROOT).parts):
continue
relativ = py_datei.relative_to(ROOT).as_posix()
if relativ in DATEIEN_OHNE_PRUEFUNG:
continue
dateien += 1
for zeile, stamm, kontext in pruefe_datei(py_datei):
if (relativ, zeile) in ALLOWLIST:
continue
print(f"{relativ}:{zeile}: '{stamm}' in: {kontext}")
gesamt_funde += 1
status = "GRÜN" if gesamt_funde == 0 else "ROT"
print(f"\n{status}: {gesamt_funde} Fund/e in {dateien} Datei(en).")
return 1 if gesamt_funde else 0
if __name__ == "__main__":
raise SystemExit(main())
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Richtet den AG_Prozesse-Workspace ein bzw. aktualisiert ihn.
1. Klont fehlende Repositories aus workspace.toml in Unterordner (Default-Branch).
2. Holt für vorhandene Klone den Stand vom Server (git fetch) und zeigt Branch,
Vorsprung/Rückstand und ungesicherte Änderungen an. Ein Pull passiert nur
mit --pull und nur bei sauberem Arbeitsverzeichnis (fast-forward).
3. Spiegelt die Skills aus .agents/skills/ nach .claude/skills/ (Claude Code liest
nur dort; der Agent-Skills-Standard und andere Werkzeuge lesen .agents/skills/).
Aufrufe:
python scripts/setup_workspace.py # klonen + Status + Skills
python scripts/setup_workspace.py --pull # zusätzlich fast-forward pull
python scripts/setup_workspace.py --nur-skills
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOKALE_ÜBERSCHREIBUNG = ROOT / "workspace.local.toml"
def repos_root() -> Path:
"""Ordner für die weiteren Repository-Klone, siehe workspace.local.toml (nicht versioniert)."""
if not LOKALE_ÜBERSCHREIBUNG.is_file():
return ROOT
with LOKALE_ÜBERSCHREIBUNG.open("rb") as f:
lokal = tomllib.load(f)
pfad = lokal.get("workspace", {}).get("repos_root")
return Path(pfad).expanduser() if pfad else ROOT
def git(*args: str, cwd: Path) -> tuple[int, str]:
p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, errors="replace")
return p.returncode, (p.stdout + p.stderr).strip()
def sync_skills() -> None:
quelle = ROOT / ".agents" / "skills"
ziel = ROOT / ".claude" / "skills"
if not quelle.is_dir():
print("Keine Skills in .agents/skills/ gefunden.")
return
ziel.parent.mkdir(exist_ok=True)
# Alten Spiegel entfernen (Symlink, Junction oder Kopie)
if ziel.is_symlink() or (ziel.exists() and not ziel.is_dir()):
ziel.unlink()
elif ziel.is_dir():
try:
os.rmdir(ziel) # leere Junction/Ordner
except OSError:
shutil.rmtree(ziel)
art = "symlink"
try:
os.symlink(quelle, ziel, target_is_directory=True)
except (OSError, NotImplementedError):
art = "kopie"
if os.name == "nt":
try:
import _winapi # type: ignore[attr-defined]
_winapi.CreateJunction(str(quelle), str(ziel))
art = "junction"
except Exception:
pass
if art == "kopie":
shutil.copytree(quelle, ziel, ignore=shutil.ignore_patterns("__pycache__"))
anzahl = len([p for p in quelle.iterdir() if (p / "SKILL.md").is_file()])
print(f"Skills: {anzahl} aus .agents/skills/ nach .claude/skills/ gespiegelt ({art}).")
if art == "kopie":
print(" Hinweis: Kopie statt Link – nach Änderungen an .agents/skills/ dieses Skript erneut ausführen.")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--pull", action="store_true", help="saubere Klone per fast-forward aktualisieren")
ap.add_argument("--nur-skills", action="store_true", help="nur Skills spiegeln, kein Git")
args = ap.parse_args()
if not args.nur_skills:
with (ROOT / "workspace.toml").open("rb") as f:
manifest = tomllib.load(f)
ws = manifest["workspace"]
basis_url = f"{ws['gitea']}/{ws['org']}"
rroot = repos_root()
print(f"Workspace: {ROOT}\nGitea: {basis_url}\n")
if rroot != ROOT:
print(f"Repositories liegen laut workspace.local.toml unter: {rroot}\n")
rroot.mkdir(parents=True, exist_ok=True)
for repo in manifest.get("repos", []):
name, branch = repo["name"], repo["branch"]
pfad = rroot / name
if not (pfad / ".git").is_dir():
print(f"[{name}] klone Branch {branch} ...")
rc, out = git("clone", "--branch", branch, f"{basis_url}/{name}.git", name, cwd=rroot)
print(" " + ("ok" if rc == 0 else f"FEHLER: {out}"))
continue
git("fetch", "--quiet", "--prune", cwd=pfad)
_, aktuell = git("rev-parse", "--abbrev-ref", "HEAD", cwd=pfad)
_, dirty = git("status", "--porcelain", cwd=pfad)
n_dirty = len([z for z in dirty.splitlines() if z.strip()])
_, ab = git("rev-list", "--left-right", "--count", f"HEAD...origin/{aktuell}", cwd=pfad)
vor, _, zurueck = ab.partition("\t") if "\t" in ab else ("?", "", "?")
meldung = f"[{name}] Branch {aktuell} (+{vor}/-{zurueck.strip()} zu origin), {n_dirty} ungesicherte Änderungen"
if aktuell != branch:
meldung += f" <- Hinweis: Manifest erwartet {branch}"
if args.pull and n_dirty == 0 and aktuell == branch:
rc, out = git("pull", "--ff-only", "--quiet", cwd=pfad)
meldung += " -> pull " + ("ok" if rc == 0 else f"FEHLER: {out.splitlines()[-1] if out else rc}")
print(meldung)
print()
sync_skills()
return 0
if __name__ == "__main__":
raise SystemExit(main())