2026-08-11 12:00:00 +00:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""gruppenpruefung.py — Verbund-Prüfungen über die Gruppe (Lab-CI).
|
|
|
|
|
|
|
|
|
|
|
|
Gehört zur Stillstandsprüfungs-Familie: Befunde färben die Pipeline
|
|
|
|
|
|
rot (das ist die Alarmanlage), fehlender Zugang bricht ab statt still
|
|
|
|
|
|
zu überspringen, und jede Prüfung bildet einen real passierten Fall ab
|
|
|
|
|
|
(Design 2026-08-11, Muster A/B; ADR-0012/0013):
|
|
|
|
|
|
|
|
|
|
|
|
1. Gruppenliste ↔ docs/components/ — die Projektliste wird zur
|
|
|
|
|
|
Laufzeit gelesen, nie im Code gepflegt (Retro-Lehre); ein neues
|
|
|
|
|
|
Repo wird Befund statt Lücke. (F-008/F-009)
|
|
|
|
|
|
2. Pointer-Präsenz — jede active/staged-Komponente trägt CLAUDE.md.
|
|
|
|
|
|
(F-011: 4 von 5 fehlten, niemand prüfte)
|
|
|
|
|
|
3. Meilenstein- und Prioritätspflicht über ALLE offenen
|
|
|
|
|
|
Gruppen-Issues. (realer Fall: gitops#61, entstanden 2026-08-11,
|
|
|
|
|
|
ohne Meilenstein)
|
2026-08-18 12:00:00 +00:00
|
|
|
|
4. Issue-Drift GitLab ↔ docs/issues/ — Titel, Zustand, Meilenstein,
|
|
|
|
|
|
Priorität, Status-Label; seit ADR-0019 über management UND die
|
|
|
|
|
|
adoptierten Komponenten-Tracker (Feld `projekt`).
|
|
|
|
|
|
(F-001/F-017-Klasse)
|
2026-08-11 12:00:00 +00:00
|
|
|
|
5. Git-Hygiene seit 2026-08-07 — Autor- und Committer-Zeit 12:00:00
|
2026-08-15 12:00:00 +00:00
|
|
|
|
UTC, kanonische Identität; ausgenommen sind maschinelle Absender
|
|
|
|
|
|
(MASCHINEN, per Adresse — ADR-0009). (F-002/F-003)
|
2026-08-19 12:00:00 +00:00
|
|
|
|
Komponenten mit dem Feld `fremdhistorie` prüfen nur Commits, die
|
|
|
|
|
|
von eigenen Identitäten COMMITTET wurden: Seit dem Upstream-Merge
|
|
|
|
|
|
(ADR-0022) trägt ThreadNet-Web 70.265 fremde Commits, die unserer
|
|
|
|
|
|
Konvention nie folgen konnten. Die Ausnahme ist erklärungspflichtig
|
|
|
|
|
|
und gilt nur dort, wo sie deklariert ist — nicht global.
|
2026-08-11 12:00:00 +00:00
|
|
|
|
|
|
|
|
|
|
Demo-Modus für den Altbestand (Muster-B-Nachweis, Feldtest F-002):
|
|
|
|
|
|
--hygiene-lokal <klonverzeichnis> [--seit JJJJ-MM-TT]
|
|
|
|
|
|
prüft die volle Historie lokaler Klone offline; nur eigene
|
|
|
|
|
|
Identitäten zählen (die fünf aus F-003).
|
|
|
|
|
|
|
|
|
|
|
|
Usage: GITLAB_TOKEN=… python3 scripts/gruppenpruefung.py [repo-root]
|
|
|
|
|
|
python3 scripts/gruppenpruefung.py --hygiene-lokal DIR [--seit DATUM]
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import re
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import urllib.parse
|
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
2026-08-18 12:00:00 +00:00
|
|
|
|
import quittungen
|
|
|
|
|
|
|
2026-08-11 12:00:00 +00:00
|
|
|
|
API = "https://git.lab/api/v4"
|
|
|
|
|
|
GRUPPE = "axion1337.chat"
|
|
|
|
|
|
GRENZE = "2026-08-07"
|
|
|
|
|
|
KANONISCH = ("Thore Cimbal", "cfx@riot.8shield.net")
|
2026-08-15 12:00:00 +00:00
|
|
|
|
# Maschinelle Absender: ihre Commits entstehen ohne Menschen am Gerät, eine
|
|
|
|
|
|
# Zuschreibung an eine Person wäre falsch (ADR-0009, dort für den Rotations-Bot
|
|
|
|
|
|
# entschieden; dieselbe Begründung trägt für jeden weiteren Automaten).
|
|
|
|
|
|
# Gematcht wird die ADRESSE, nicht der Name: "Administrator" ist als Name viel zu
|
|
|
|
|
|
# generisch, um damit Befunde stillzulegen.
|
|
|
|
|
|
MASCHINEN = {
|
|
|
|
|
|
"turn-secret-rotation@axion1337.chat", # TURN-Secret-Rotation (CronJob im Cluster)
|
|
|
|
|
|
"admin@axion1337.chat", # Wiki.js Git-Storage (Anzeigename "Administrator")
|
|
|
|
|
|
"wiki@axion1337.chat", # Wiki.js Git-Storage (Dienst-Identität)
|
|
|
|
|
|
}
|
2026-08-11 12:00:00 +00:00
|
|
|
|
EIGENE_MAILS = {"cfx@riot.8shield.net", "gamemaster@axion1337.de",
|
|
|
|
|
|
"cfxqriot.8shield.net", "Scrublord@Mac.Bad"}
|
|
|
|
|
|
STATUS_LABEL = {"open": None, "next": "status:next",
|
|
|
|
|
|
"in-progress": "status:doing", "waiting": "status:wartet"}
|
|
|
|
|
|
|
|
|
|
|
|
befunde: list[str] = []
|
|
|
|
|
|
hinweise: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def api(pfad: str, token: str):
|
|
|
|
|
|
daten, seite = [], 1
|
|
|
|
|
|
while True:
|
|
|
|
|
|
trenner = "&" if "?" in pfad else "?"
|
|
|
|
|
|
req = urllib.request.Request(
|
|
|
|
|
|
f"{API}/{pfad}{trenner}per_page=100&page={seite}",
|
|
|
|
|
|
headers={"PRIVATE-TOKEN": token})
|
|
|
|
|
|
with urllib.request.urlopen(req) as antwort:
|
|
|
|
|
|
batch = json.load(antwort)
|
|
|
|
|
|
if not isinstance(batch, list):
|
|
|
|
|
|
return batch
|
|
|
|
|
|
daten += batch
|
|
|
|
|
|
if len(batch) < 100:
|
|
|
|
|
|
return daten
|
|
|
|
|
|
seite += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def frontmatter(p: Path) -> dict:
|
|
|
|
|
|
zeilen = p.read_text(encoding="utf-8").splitlines()
|
|
|
|
|
|
if not zeilen or zeilen[0] != "---":
|
|
|
|
|
|
return {}
|
|
|
|
|
|
meta = {}
|
|
|
|
|
|
for z in zeilen[1:]:
|
|
|
|
|
|
if z == "---":
|
|
|
|
|
|
break
|
|
|
|
|
|
m = re.match(r"^(\w+):\s*(.*)$", z)
|
|
|
|
|
|
if m:
|
|
|
|
|
|
meta[m.group(1)] = m.group(2).strip().strip('"')
|
|
|
|
|
|
return meta
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def titel(p: Path) -> str:
|
|
|
|
|
|
for z in p.read_text(encoding="utf-8").splitlines():
|
|
|
|
|
|
if z.startswith("# "):
|
|
|
|
|
|
return z[2:].strip()
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def zeit_ok(iso: str) -> bool:
|
|
|
|
|
|
# GitLab liefert z. B. 2026-08-11T12:00:00.000+00:00
|
|
|
|
|
|
m = re.match(r"^\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}:\d{2})(?:\.\d+)?"
|
|
|
|
|
|
r"(Z|[+-]\d{2}:?\d{2})$", iso)
|
|
|
|
|
|
if not m:
|
|
|
|
|
|
return False
|
|
|
|
|
|
zeit, offset = m.groups()
|
|
|
|
|
|
return zeit == "12:00:00" and offset in ("Z", "+00:00", "+0000")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def hygiene_lokal(klondir: Path, seit: str) -> int:
|
|
|
|
|
|
gesamt = 0
|
|
|
|
|
|
for repo in sorted(d for d in klondir.iterdir() if (d / ".git").exists()):
|
|
|
|
|
|
umgebung = dict(os.environ, TZ="UTC") # Session-1-Lehre: TZ am git-Prozess
|
|
|
|
|
|
raus = subprocess.run(
|
|
|
|
|
|
["git", "-C", str(repo), "log", "--all",
|
|
|
|
|
|
f"--since={seit}", "--date=format-local:%H:%M:%S",
|
|
|
|
|
|
"--format=%ae\t%ad\t%cd"],
|
|
|
|
|
|
capture_output=True, text=True, env=umgebung).stdout
|
|
|
|
|
|
zaehler = 0
|
|
|
|
|
|
for zeile in raus.splitlines():
|
|
|
|
|
|
mail, ad, cd = zeile.split("\t")
|
|
|
|
|
|
if mail in EIGENE_MAILS and (ad != "12:00:00" or cd != "12:00:00"):
|
|
|
|
|
|
zaehler += 1
|
|
|
|
|
|
if zaehler:
|
|
|
|
|
|
befunde.append(f"{repo.name}: {zaehler} Commits eigener "
|
|
|
|
|
|
f"Identitäten mit Echtzeit-Stempel (seit {seit})")
|
|
|
|
|
|
gesamt += zaehler
|
|
|
|
|
|
return gesamt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
|
if "--hygiene-lokal" in sys.argv:
|
|
|
|
|
|
klondir = Path(sys.argv[sys.argv.index("--hygiene-lokal") + 1])
|
|
|
|
|
|
seit = (sys.argv[sys.argv.index("--seit") + 1]
|
|
|
|
|
|
if "--seit" in sys.argv else GRENZE)
|
|
|
|
|
|
gesamt = hygiene_lokal(klondir, seit)
|
|
|
|
|
|
for b in befunde:
|
|
|
|
|
|
print(f"BEFUND {b}")
|
|
|
|
|
|
print(f"gruppenpruefung --hygiene-lokal: {len(befunde)} Befunde, "
|
|
|
|
|
|
f"{gesamt} Commits betroffen")
|
|
|
|
|
|
return 1 if befunde else 0
|
|
|
|
|
|
|
|
|
|
|
|
token = os.environ.get("GITLAB_TOKEN", "")
|
|
|
|
|
|
if not token:
|
|
|
|
|
|
sys.exit("GITLAB_TOKEN fehlt — ohne Lesezugriff kann nichts "
|
|
|
|
|
|
"geprüft werden; Abbruch statt stillem Skip.")
|
|
|
|
|
|
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
|
|
|
|
|
|
|
|
|
|
|
# 1) Gruppenliste (Laufzeit) ↔ docs/components/
|
|
|
|
|
|
live = {p["path"]: p for p in
|
|
|
|
|
|
api(f"groups/{GRUPPE}/projects?include_subgroups=false", token)
|
|
|
|
|
|
if not p.get("archived")}
|
|
|
|
|
|
erklaert = {}
|
|
|
|
|
|
for f in sorted((root / "docs/components").glob("*.md")):
|
|
|
|
|
|
if f.name == "template.md":
|
|
|
|
|
|
continue
|
|
|
|
|
|
erklaert[frontmatter(f)["slug"]] = frontmatter(f)
|
|
|
|
|
|
for slug in sorted(set(live) - set(erklaert)):
|
|
|
|
|
|
befunde.append(f"Projekt {slug} existiert in der Gruppe, ist aber "
|
|
|
|
|
|
f"in docs/components/ nicht deklariert")
|
|
|
|
|
|
for slug in sorted(set(erklaert) - set(live)):
|
|
|
|
|
|
befunde.append(f"docs/components/{slug}.md deklariert ein Projekt, "
|
|
|
|
|
|
f"das die Gruppe nicht (mehr) führt")
|
|
|
|
|
|
|
|
|
|
|
|
# 2) Pointer-Präsenz (active/staged, außer management selbst)
|
|
|
|
|
|
for slug, meta in sorted(erklaert.items()):
|
|
|
|
|
|
if meta.get("phase") not in ("active", "staged") or slug == "management":
|
|
|
|
|
|
continue
|
|
|
|
|
|
ref = live.get(slug, {}).get("default_branch", "main")
|
|
|
|
|
|
pfad = urllib.parse.quote(f"{GRUPPE}/{slug}", safe="")
|
|
|
|
|
|
try:
|
|
|
|
|
|
api(f"projects/{pfad}/repository/files/CLAUDE.md?ref={ref}", token)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
befunde.append(f"{slug}: keine CLAUDE.md-Pointer-Datei "
|
|
|
|
|
|
f"(F-011; Rollout-Issue vorhanden)")
|
|
|
|
|
|
|
|
|
|
|
|
# 3) Meilenstein-/Prioritätspflicht gruppenweit
|
|
|
|
|
|
for i in api(f"groups/{GRUPPE}/issues?state=opened", token):
|
|
|
|
|
|
ref = i["references"]["full"]
|
|
|
|
|
|
ms = (i.get("milestone") or {}).get("title", "")
|
|
|
|
|
|
prios = [l for l in i["labels"] if l.startswith("priority:")]
|
|
|
|
|
|
if not re.match(r"^M[1-5]\b", ms):
|
|
|
|
|
|
befunde.append(f"{ref}: offenes Issue ohne Meilenstein "
|
|
|
|
|
|
f"M1–M5 ({ms or 'keiner'})")
|
|
|
|
|
|
if len(prios) != 1:
|
|
|
|
|
|
befunde.append(f"{ref}: {len(prios)} priority-Labels statt 1")
|
|
|
|
|
|
|
2026-08-18 12:00:00 +00:00
|
|
|
|
# 4) Issue-Drift GitLab ↔ docs/issues/ (management + adoptierte
|
|
|
|
|
|
# Komponenten-Tracker, ADR-0019)
|
|
|
|
|
|
spiegel_projekte = {
|
|
|
|
|
|
"management": "axion1337.chat/management",
|
|
|
|
|
|
"gitops": "axion1337.chat/axion1337.chat-gitops",
|
|
|
|
|
|
"threadnet-web": "axion1337.chat/ThreadNet-Web",
|
|
|
|
|
|
"threadnet-call": "axion1337.chat/threadnet-call",
|
|
|
|
|
|
}
|
|
|
|
|
|
gl = {}
|
|
|
|
|
|
for kurz, pfad in spiegel_projekte.items():
|
|
|
|
|
|
for i in api(f"projects/{urllib.parse.quote(pfad, safe='')}"
|
|
|
|
|
|
f"/issues?state=opened", token):
|
|
|
|
|
|
gl[(kurz, i["iid"])] = i
|
2026-08-11 12:00:00 +00:00
|
|
|
|
dateien = {}
|
|
|
|
|
|
for f in sorted((root / "docs/issues").glob("[0-9]*.md")):
|
|
|
|
|
|
meta = frontmatter(f)
|
2026-08-18 12:00:00 +00:00
|
|
|
|
proj = meta.get("projekt") or "management"
|
2026-08-11 12:00:00 +00:00
|
|
|
|
if meta.get("gitlab_iid"):
|
2026-08-18 12:00:00 +00:00
|
|
|
|
dateien[(proj, int(meta["gitlab_iid"]))] = (f, meta)
|
2026-08-11 12:00:00 +00:00
|
|
|
|
elif meta.get("status") not in ("done", "rejected"):
|
|
|
|
|
|
hinweise.append(f"{f.name}: noch nicht gespiegelt "
|
|
|
|
|
|
f"(gitlab_iid fehlt — erwartet bis zum "
|
|
|
|
|
|
f"ersten Spiegel-Lauf)")
|
2026-08-18 12:00:00 +00:00
|
|
|
|
for proj, iid in sorted(set(gl) - set(dateien)):
|
|
|
|
|
|
befunde.append(f"{proj}#{iid} ist offen auf GitLab, hat aber "
|
2026-08-11 12:00:00 +00:00
|
|
|
|
f"keine kanonische Datei (zweites Backlog!)")
|
2026-08-18 12:00:00 +00:00
|
|
|
|
for (proj, iid), (f, meta) in sorted(dateien.items()):
|
|
|
|
|
|
if (proj, iid) not in gl:
|
2026-08-11 12:00:00 +00:00
|
|
|
|
if meta.get("status") not in ("done", "rejected"):
|
|
|
|
|
|
befunde.append(f"{f.name}: offen im Repo, aber auf GitLab "
|
|
|
|
|
|
f"geschlossen/fehlend — nachziehen")
|
|
|
|
|
|
continue
|
2026-08-18 12:00:00 +00:00
|
|
|
|
i = gl[(proj, iid)]
|
2026-08-11 12:00:00 +00:00
|
|
|
|
if titel(f) != i["title"].strip():
|
|
|
|
|
|
befunde.append(f"{f.name}: Titel weicht von GitLab ab")
|
|
|
|
|
|
ms = (i.get("milestone") or {}).get("title", "")
|
|
|
|
|
|
if not ms.startswith(meta.get("milestone", "")):
|
|
|
|
|
|
befunde.append(f"{f.name}: Meilenstein {meta.get('milestone')} "
|
|
|
|
|
|
f"↔ GitLab {ms or 'keiner'}")
|
|
|
|
|
|
if f"priority:{meta.get('priority')}" not in i["labels"]:
|
|
|
|
|
|
befunde.append(f"{f.name}: Priorität {meta.get('priority')} "
|
|
|
|
|
|
f"fehlt auf GitLab")
|
|
|
|
|
|
soll = STATUS_LABEL.get(meta.get("status"))
|
|
|
|
|
|
ist = [l for l in i["labels"] if l.startswith("status:")]
|
|
|
|
|
|
if (soll and soll not in ist) or (not soll and ist):
|
|
|
|
|
|
befunde.append(f"{f.name}: Status {meta.get('status')} ↔ "
|
|
|
|
|
|
f"GitLab-Labels {ist or 'keine'}")
|
|
|
|
|
|
|
|
|
|
|
|
# 5) Git-Hygiene seit der Regel-Grenze (API)
|
|
|
|
|
|
for slug, meta in sorted(erklaert.items()):
|
|
|
|
|
|
if meta.get("phase") == "external":
|
|
|
|
|
|
continue
|
2026-08-19 12:00:00 +00:00
|
|
|
|
# Repos mit erklärter Fremdhistorie (Feld `fremdhistorie`) tragen Commits,
|
|
|
|
|
|
# die nie unserer Konvention folgen konnten, weil sie nicht bei uns
|
|
|
|
|
|
# entstanden sind. Sie zu bemängeln hiesse, einen Alarm dauerhaft rot zu
|
|
|
|
|
|
# faerben, an dem niemand etwas aendern kann - und ein Dauerrot ist kein
|
|
|
|
|
|
# Alarm mehr (#0104). Der Trennschnitt ist der COMMITTER, nicht der Autor:
|
|
|
|
|
|
# gemessen an ThreadNet-Web nach dem Upstream-Merge trennt er exakt (18
|
|
|
|
|
|
# eigene Commits committet von uns, 39 fremde von GitHub/RiotRobot, keine
|
|
|
|
|
|
# Ueberschneidung). Der Autor taugt nicht dafuer - unsere eigenen Commits
|
|
|
|
|
|
# koennen fremde Autoren tragen (Cherry-Picks), und fremde Commits tragen
|
|
|
|
|
|
# Autoren, die wie Menschen aussehen.
|
|
|
|
|
|
#
|
|
|
|
|
|
# ⚠️ Preis: In diesen Repos faellt ein Commit durchs Raster, den jemand von
|
|
|
|
|
|
# uns unter einer voellig unbekannten Identitaet erzeugt - der Fall, den die
|
|
|
|
|
|
# Identitaetspruefung sonst faengt. Deshalb gilt die Ausnahme NUR fuer
|
|
|
|
|
|
# Komponenten, die sie ausdruecklich erklaeren, und nicht global.
|
|
|
|
|
|
nur_eigene_committer = bool(meta.get("fremdhistorie"))
|
2026-08-11 12:00:00 +00:00
|
|
|
|
pfad = urllib.parse.quote(f"{GRUPPE}/{slug}", safe="")
|
|
|
|
|
|
for c in api(f"projects/{pfad}/repository/commits"
|
|
|
|
|
|
f"?since={GRENZE}T00:00:00Z&all=true", token):
|
2026-08-15 12:00:00 +00:00
|
|
|
|
if (c.get("author_email") or "").lower() in MASCHINEN:
|
2026-08-11 12:00:00 +00:00
|
|
|
|
continue
|
2026-08-19 12:00:00 +00:00
|
|
|
|
if (nur_eigene_committer
|
|
|
|
|
|
and (c.get("committer_email") or "").lower() not in EIGENE_MAILS):
|
|
|
|
|
|
continue
|
2026-08-11 12:00:00 +00:00
|
|
|
|
wer = f"{c.get('author_name')} <{c.get('author_email')}>"
|
|
|
|
|
|
if not zeit_ok(c["authored_date"]) or not zeit_ok(c["committed_date"]):
|
|
|
|
|
|
befunde.append(f"{slug} {c['short_id']}: Echtzeit-Stempel "
|
|
|
|
|
|
f"({wer})")
|
|
|
|
|
|
elif (c.get("author_name"), c.get("author_email")) != KANONISCH:
|
|
|
|
|
|
befunde.append(f"{slug} {c['short_id']}: nicht-kanonische "
|
|
|
|
|
|
f"Identität {wer}")
|
|
|
|
|
|
|
2026-08-18 12:00:00 +00:00
|
|
|
|
# Bekanntes quittieren (#0104) — siehe scripts/quittungen.py.
|
|
|
|
|
|
offen, quittiert, quittungsmeldungen = quittungen.anwenden(befunde, root, "gruppe")
|
|
|
|
|
|
quittungen.ausgeben(quittiert, quittungsmeldungen)
|
|
|
|
|
|
|
|
|
|
|
|
for b in offen:
|
2026-08-11 12:00:00 +00:00
|
|
|
|
print(f"BEFUND {b}")
|
|
|
|
|
|
for h in hinweise:
|
|
|
|
|
|
print(f"HINWEIS {h}")
|
2026-08-18 12:00:00 +00:00
|
|
|
|
print(f"gruppenpruefung: {len(offen)} offene Befunde, "
|
|
|
|
|
|
f"{len(quittiert)} quittiert, {len(hinweise)} Hinweise")
|
|
|
|
|
|
return 1 if offen else 0
|
2026-08-11 12:00:00 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
sys.exit(main())
|