All three scheduled checks were permanently red, which is how a nine-day outage of the canonize job went unnoticed: one more red cross among red crosses is invisible. A check that can only ever be red cannot report anything. An acknowledgement takes a known finding out of the red verdict without hiding it - it still prints, with its reason and its deadline. Red is reserved for what is not acknowledged, which is to say: for the new. Three rules keep the list from becoming the next blind spot, which is the obvious objection to this whole idea: - Every entry needs a deadline. Once it passes, the entry stops acknowledging and says so, so the finding counts again. - "Permanent" is only expressible as an ADR reference. A permanent exception without a decision record is already an error per AGENTS.md; here it cannot even be written down. - An entry that matches nothing reports itself, so the file cannot quietly accumulate lines for problems that no longer exist. Four entries to start: notfallhandbuch (ADR-0016, deliberately unmirrored), threadnet-wiki (ADR-0015, the wiki mirrors the other way round), gameserver (sorb: not part of ThreadNet - dated, so the ADR-or-move decision does not drift), and the zero-job pipeline artifact. Verified rather than argued, including the counter-proofs #0104 asks for: today four are acknowledged and one real finding stays red; adding a fresh finding still turns it red; and with the clock moved past the deadlines the dated entries stop acknowledging and report themselves. The scope column was added after the first run showed each check reporting the other's entries as ineffective. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
275 lines
12 KiB
Python
275 lines
12 KiB
Python
#!/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)
|
||
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)
|
||
5. Git-Hygiene seit 2026-08-07 — Autor- und Committer-Zeit 12:00:00
|
||
UTC, kanonische Identität; ausgenommen sind maschinelle Absender
|
||
(MASCHINEN, per Adresse — ADR-0009). (F-002/F-003)
|
||
|
||
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
|
||
|
||
import quittungen
|
||
|
||
API = "https://git.lab/api/v4"
|
||
GRUPPE = "axion1337.chat"
|
||
GRENZE = "2026-08-07"
|
||
KANONISCH = ("Thore Cimbal", "cfx@riot.8shield.net")
|
||
# 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)
|
||
}
|
||
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")
|
||
|
||
# 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
|
||
dateien = {}
|
||
for f in sorted((root / "docs/issues").glob("[0-9]*.md")):
|
||
meta = frontmatter(f)
|
||
proj = meta.get("projekt") or "management"
|
||
if meta.get("gitlab_iid"):
|
||
dateien[(proj, int(meta["gitlab_iid"]))] = (f, meta)
|
||
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)")
|
||
for proj, iid in sorted(set(gl) - set(dateien)):
|
||
befunde.append(f"{proj}#{iid} ist offen auf GitLab, hat aber "
|
||
f"keine kanonische Datei (zweites Backlog!)")
|
||
for (proj, iid), (f, meta) in sorted(dateien.items()):
|
||
if (proj, iid) not in gl:
|
||
if meta.get("status") not in ("done", "rejected"):
|
||
befunde.append(f"{f.name}: offen im Repo, aber auf GitLab "
|
||
f"geschlossen/fehlend — nachziehen")
|
||
continue
|
||
i = gl[(proj, iid)]
|
||
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
|
||
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):
|
||
if (c.get("author_email") or "").lower() in MASCHINEN:
|
||
continue
|
||
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}")
|
||
|
||
# Bekanntes quittieren (#0104) — siehe scripts/quittungen.py.
|
||
offen, quittiert, quittungsmeldungen = quittungen.anwenden(befunde, root, "gruppe")
|
||
quittungen.ausgeben(quittiert, quittungsmeldungen)
|
||
|
||
for b in offen:
|
||
print(f"BEFUND {b}")
|
||
for h in hinweise:
|
||
print(f"HINWEIS {h}")
|
||
print(f"gruppenpruefung: {len(offen)} offene Befunde, "
|
||
f"{len(quittiert)} quittiert, {len(hinweise)} Hinweise")
|
||
return 1 if offen else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|