95 lines
3.9 KiB
Bash
95 lines
3.9 KiB
Bash
#!/usr/bin/env bash
|
||
# Ampel-Wächter (22.07.2026, Wasserdicht-Runde): meldet ROTE CI-Läufe aller
|
||
# Gitea-Repos per Telegram. Läuft als no-agent-Cron (stdout leer = still,
|
||
# klassisches Watchdog-Muster). Nur NEUE Fehlschläge werden gemeldet —
|
||
# Status-Merkliste unter ~/.hermes/state/ampel-waechter-gesehen.txt.
|
||
set -uo pipefail
|
||
|
||
STATE="$HOME/.hermes/state/ampel-waechter-gesehen.txt"
|
||
mkdir -p "$(dirname "$STATE")"; touch "$STATE"
|
||
|
||
TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null | sed -E 's#https://[^:]+:([^@]+)@.*#\1#')
|
||
[ -n "$TOKEN" ] || exit 0
|
||
|
||
python3 - "$TOKEN" "$STATE" <<'PY'
|
||
import json, sys, urllib.request, subprocess, os
|
||
|
||
token, state_pfad = sys.argv[1], sys.argv[2]
|
||
# API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026);
|
||
# WEB bleibt die Domain — Telegram-Links muessen auch von unterwegs klickbar sein.
|
||
BASE = "http://192.168.178.153:3000/api/v1"
|
||
WEB = "https://git.tobisniceshomelab.ddnsfree.com"
|
||
|
||
|
||
def api(pfad):
|
||
req = urllib.request.Request(BASE + pfad, headers={"Authorization": "token " + token})
|
||
with urllib.request.urlopen(req, timeout=20) as r:
|
||
return json.load(r)
|
||
|
||
|
||
try:
|
||
gesehen = set(open(state_pfad).read().split())
|
||
except OSError:
|
||
gesehen = set()
|
||
|
||
neu = []
|
||
try:
|
||
# /repos/search statt /user/repos: das Token hat nur write:repository-Scope,
|
||
# /user/repos verlangt read:user -> 403. Der stille exit 0 hat diesen Defekt
|
||
# seit Inbetriebnahme versteckt (Fund 24.07.2026, Merkliste blieb leer).
|
||
repos = api("/repos/search?limit=50&private=true").get("data", [])
|
||
except Exception as e:
|
||
print(f"ampel-waechter: Repo-Liste fehlgeschlagen: {e}", file=sys.stderr)
|
||
sys.exit(0)
|
||
|
||
for repo in repos:
|
||
full = repo.get("full_name", "")
|
||
if not full or repo.get("archived"):
|
||
continue
|
||
try:
|
||
d = api(f"/repos/{full}/actions/tasks?limit=1")
|
||
except Exception:
|
||
continue
|
||
rows = d.get("workflow_runs", d if isinstance(d, list) else d.get("entries", []))
|
||
for t in rows[:1]:
|
||
kennung = f"{full}#{t.get('id')}"
|
||
status = t.get("status")
|
||
if status == "failure" and kennung not in gesehen:
|
||
# Status-Lügen-Wache (Fund 22.07.): alter Runner meldete Abschluss nicht,
|
||
# Gitea zombie-markierte GRÜNE Jobs als failure. Das Job-LOG ist die
|
||
# Wahrheit — steht dort „Job succeeded", ist es KEIN Alarm.
|
||
try:
|
||
req = urllib.request.Request(
|
||
f"{BASE}/repos/{full}/actions/jobs/{t.get('id')}/logs",
|
||
headers={"Authorization": "token " + token})
|
||
with urllib.request.urlopen(req, timeout=20) as r:
|
||
log_ende = r.read().decode(errors="replace")[-4000:]
|
||
wirklich_rot = "Job succeeded" not in log_ende
|
||
except Exception:
|
||
wirklich_rot = True
|
||
if wirklich_rot:
|
||
neu.append((full, t.get("head_sha", "")[:7], t.get("name", "")))
|
||
# Nur ABGESCHLOSSENE Läufe merken — ein 'running', das später rot wird,
|
||
# darf nicht schon als gesehen gelten.
|
||
if status in ("failure", "success", "cancelled", "skipped"):
|
||
gesehen.add(kennung)
|
||
|
||
if neu:
|
||
print("🔴 CI-AMPEL ROT:")
|
||
for full, sha, name in neu:
|
||
print(f"• {full} @ {sha} ({name})")
|
||
print(f" {WEB}/{full}/actions")
|
||
try:
|
||
subprocess.run([
|
||
os.path.expanduser("~/.local/bin/hermes"), "kanban", "create",
|
||
"--title", f"CI rot: {full} @ {sha}",
|
||
"--body", f"Der CI Lauf '{name}' in {full} ist fehlgeschlagen. Bitte Log ansehen und Fix vorschlagen."
|
||
], capture_output=True)
|
||
except Exception as e:
|
||
print(f"Fehler bei kanban_create: {e}", file=sys.stderr)
|
||
print("Nichts aus diesen Ständen ist ‚fertig', solange die Ampel rot ist.")
|
||
|
||
with open(state_pfad, "w") as f:
|
||
f.write("\n".join(sorted(gesehen)))
|
||
PY
|