#!/usr/bin/env python3 """stack-upstream.py — der Blick nach draussen, in Fakten (04.09.2026). Ergaenzt stack-ist.sh: das prueft, ob die Box STIMMT. Dieses Skript prueft, ob DRAUSSEN etwas passiert ist, das uns betrifft — neue Releases, gemergte PRs, neue Entwurfsmodelle, neue Modelle der Familien, die wir fahren. Warum es das braucht: Am 19.08. lagen die DFlash2-Entwuerfe fuer den Coder auf der Platte, am 27.08. konnte llama.cpp sie, und bis zum 04.09. hat es niemand gemerkt — der Coder lief zwei Wochen mit 12,6 statt 31 t/s. Der KISS-Radar sah nur nach innen. Jetzt sieht er auch nach draussen, aber ohne Modell-Fantasie: jede Zeile hier ist ein abgerufener Wert, verglichen mit dem Wert vom letzten Lauf. Quelle: deploy/trend-radar-watchlist.json (Eintraege mit `typ`, siehe unten). Zustand: ~/.hermes/state/stack-upstream.json (Wert je Eintrag vom letzten Lauf). Ausgabe: im Format von stack-ist.sh — `OK` / `ACHTUNG NEU:` / `ACHTUNG ... nicht abrufbar`. Beim ERSTEN Lauf wird nur erfasst (OK), nicht gemeldet — sonst waere jeder Eintrag sofort "neu". Typen: github_issue repo, nummer → offen/geschlossen github_pr repo, nummer → offen/gemergt/geschlossen github_release repo → juengster Release-Tag github_release_major repo → nur Major-Sprung meldet github_branch repo, branch → juengster Commit (Datum) pypi paket → Version hf_author author → neue Modell-Repos des Autors url_zeilen url, muster, max_zeilen → Zeilen mit Muster, Aenderung = neu Kein set -e-Aequivalent: jeder Eintrag wird einzeln behandelt, ein Fehler nimmt die anderen nicht mit. """ from __future__ import annotations import hashlib import json import os import re import sys import urllib.error import urllib.request from pathlib import Path WATCHLIST = Path(sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/mission-control-v2/deploy/trend-radar-watchlist.json")) STATE = Path(os.environ.get("STACK_UPSTREAM_STATE", os.path.expanduser("~/.hermes/state/stack-upstream.json"))) TIMEOUT = 20 UA = "mc2-stack-upstream/1.0 (+https://github.com/Hitonabi)" def ok(msg: str) -> None: print(f" OK {msg}") def neu(msg: str) -> None: print(f" ACHTUNG NEU: {msg}") def warn(msg: str) -> None: print(f" ACHTUNG {msg}") def hole(url: str, json_erwartet: bool = True): req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"}) tok = os.environ.get("GITHUB_TOKEN") if tok and "api.github.com" in url: req.add_header("Authorization", f"Bearer {tok}") with urllib.request.urlopen(req, timeout=TIMEOUT) as r: roh = r.read().decode("utf-8", "replace") return json.loads(roh) if json_erwartet else roh # ------------------------------------------------------------- Pruefer --- # Jeder Pruefer liefert (wert, anzeige). `wert` wird mit dem letzten Lauf # verglichen, `anzeige` ist die menschenlesbare Fassung. def p_github_issue(e): d = hole(f"https://api.github.com/repos/{e['repo']}/issues/{e['nummer']}") st = "geschlossen" if d.get("state") == "closed" else "offen" return st, f"#{e['nummer']} {e['repo']} ist {st} — {d.get('title', '')[:70]}" def p_github_pr(e): d = hole(f"https://api.github.com/repos/{e['repo']}/pulls/{e['nummer']}") if d.get("merged_at"): st = f"gemergt am {d['merged_at'][:10]}" elif d.get("state") == "closed": st = "geschlossen ohne Merge" else: st = "offen" + (" (Entwurf)" if d.get("draft") else "") return st, f"PR #{e['nummer']} {e['repo']} {st} — {d.get('title', '')[:70]}" def _release(e): d = hole(f"https://api.github.com/repos/{e['repo']}/releases/latest") return d.get("tag_name", "?"), (d.get("published_at") or "")[:10] def p_github_release(e): tag, wann = _release(e) return tag, f"{e['repo']} juengster Release {tag} ({wann})" def p_github_release_major(e): tag, wann = _release(e) m = re.search(r"(\d+)", tag) major = m.group(1) if m else tag return major, f"{e['repo']} Major {major} (Release {tag}, {wann})" def p_github_branch(e): d = hole(f"https://api.github.com/repos/{e['repo']}/commits?sha={e['branch']}&per_page=1") c = d[0] wann = c["commit"]["committer"]["date"][:10] return c["sha"][:10], f"{e['repo']}@{e['branch']} letzter Commit {wann} — {c['commit']['message'].splitlines()[0][:60]}" def p_pypi(e): d = hole(f"https://pypi.org/pypi/{e['paket']}/json") v = d["info"]["version"] return v, f"PyPI {e['paket']} = {v}" def p_hf_author(e): d = hole(f"https://huggingface.co/api/models?author={e['author']}&sort=lastModified&direction=-1&limit={e.get('limit', 15)}") ids = sorted(m["id"] for m in d) return "\n".join(ids), f"{len(ids)} juengste Repos von {e['author']} auf Hugging Face" def p_url_zeilen(e): roh = hole(e["url"], json_erwartet=False) zeilen = [z.strip() for z in roh.splitlines() if e["muster"] in z][: int(e.get("max_zeilen", 30))] h = hashlib.sha1("\n".join(zeilen).encode()).hexdigest()[:10] return h, f"{len(zeilen)} Zeilen mit '{e['muster']}' in {e['url'].split('/')[2]} (Stand {h})" PRUEFER = { "github_issue": p_github_issue, "github_pr": p_github_pr, "github_release": p_github_release, "github_release_major": p_github_release_major, "github_branch": p_github_branch, "pypi": p_pypi, "hf_author": p_hf_author, "url_zeilen": p_url_zeilen, } def main() -> int: print("UPSTREAM (Watchlist — was hat sich draussen getan?)") try: eintraege = json.loads(WATCHLIST.read_text(encoding="utf-8")).get("eintraege", []) except Exception as exc: # noqa: BLE001 warn(f"Watchlist {WATCHLIST} nicht lesbar: {exc}") return 0 try: state = json.loads(STATE.read_text(encoding="utf-8")) if STATE.exists() else {} except Exception: # noqa: BLE001 state = {} for e in eintraege: key, typ = e.get("key", "?"), e.get("typ", "?") pruefer = PRUEFER.get(typ) if not pruefer: warn(f"{key}: unbekannter Typ '{typ}' — Eintrag wird ignoriert") continue try: wert, anzeige = pruefer(e) except urllib.error.HTTPError as exc: warn(f"{key}: nicht abrufbar (HTTP {exc.code})") continue except Exception as exc: # noqa: BLE001 warn(f"{key}: nicht abrufbar ({type(exc).__name__}: {str(exc)[:60]})") continue alt = state.get(key, {}).get("wert") if alt is None: ok(f"{key}: erstmals erfasst — {anzeige}") elif alt == wert: ok(f"{key}: unveraendert — {anzeige}") else: if typ == "hf_author": neue = sorted(set(wert.split("\n")) - set(alt.split("\n"))) anzeige = f"neue Repos von {e['author']}: " + ", ".join(neue[:8]) if neue else anzeige hinweis = e.get("warum", "") neu(f"{key}: {anzeige}" + (f"\n → {hinweis[:220]}" if hinweis else "")) state[key] = {"wert": wert, "anzeige": anzeige} try: STATE.parent.mkdir(parents=True, exist_ok=True) tmp = STATE.with_suffix(".tmp") tmp.write_text(json.dumps(state, ensure_ascii=False, indent=1), encoding="utf-8") os.replace(tmp, STATE) except Exception as exc: # noqa: BLE001 warn(f"Zustand nicht speicherbar ({exc}) — naechster Lauf meldet alles erneut") return 0 if __name__ == "__main__": sys.exit(main())