Files
stack/dev/scripts/nb_issue_filer.py
kert 187e77615d
Some checks failed
CI / lint (push) Successful in 32s
CI / notebooks-smoke (push) Failing after 1m39s
Deploy / notebooks (push) Successful in 6m26s
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 48s
Infra CI / zotero (push) Successful in 27s
Infra CI / docs (push) Successful in 14s
Infra CI / api (push) Successful in 24s
Infra CI / mc (push) Successful in 13s
CI / test (push) Failing after 16m28s
Deploy / report (push) Successful in 16s
feat(notebooks): quality gates — headless integration test, FE smoke gate, dedup issue auto-filer
Three gates so notebook breakage can't ship or linger silently again
(spec: docs/superpowers/specs/2026-07-09-notebook-quality-gates-design.md):

1. nb_integration.py: runs notebooks headless via 'marimo export session',
   parses the JSON snapshots for cell errors, emits a report, exits 1 on
   unexpected failures. New ci.yml notebooks-smoke job runs the
   data-independent [ci_smoke] set (infra/marimo/nb-tests.toml) on every
   push; new nightly notebooks-integration.yml runs the full set inside
   the prod container against real data, filing failures as issues.

2. nb_fe_smoke.py: headless-browser gate that loads the editor and fails
   on any console/page error — the test that would have blocked the
   'd is not a constructor' bundle. Wired into infra-ci.yml after the
   notebooks image build (all traffic over the docker socket; -v bind
   mounts silently arrive empty in CI). Verified: exit 0 on the fixed
   image and live prod, exit 1 on a synthetic crashing page.

3. nb_issue_filer.py + nb-watcher compose sidecar: one issue per error
   signature (notebook + ename + normalized message), rate-limited
   recurrence comments, auto-close after 24h quiet — the watcher is the
   single closing authority. Tails container logs via the docker socket
   (stdlib unix-socket HTTP, stream demux) and parses live session
   snapshots. First production tick filed 9 real deduplicated issues
   (#546-#554: a real skin_subs_explorer bug, missing pyzotero/trino,
   nessie/api connectivity) under the new 'notebooks' label.

Also: notebooks.Dockerfile now lets corepack honor marimo's pinned
packageManager instead of 'pnpm@latest' — the last floating input to the
frontend build after the lockfile fix.

Tests: 6 new unit-test groups (snapshot parsing, signature stability,
dedup decisions); full suite green including notebook-layout policy
(config placed in infra/marimo/, not notebooks/).
2026-07-10 10:14:46 -04:00

295 lines
10 KiB
Python

"""Dedup/auto-close Gitea issue filer for notebook errors.
One open issue per error signature (notebook + ename + normalized message).
Recurrences add a rate-limited comment instead of a new issue; signatures
that stop occurring get auto-closed. This replaces the api.diag.ci
one-issue-per-run pattern that accumulated 23 duplicates for a single
workflow.
Stdlib only — runs on a bare python image (nb-watcher sidecar), in CI, and
on the host.
Usage:
# file/refresh issues for findings on stdin (JSON list, see FINDING)
python nb_issue_filer.py report --source nightly-integration < findings.json
# close open nb issues whose signature is NOT in the active set
python nb_issue_filer.py sweep --active-sigs sig1,sig2 --source nightly-integration
# or close signatures unseen since a state file's cutoff
python nb_issue_filer.py sweep --state data/nb-watcher-state.json --max-age-h 24
FINDING = {"notebook": "x.py", "ename": "exception", "evalue": "...",
"detail": "traceback/console excerpt"}
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
# Repo root when run in-tree; "/" in shallow deployed contexts (sidecar
# mount, docker cp) where ROOT is only a .env-fallback location anyway.
_parents = Path(__file__).resolve().parents
ROOT = _parents[2] if len(_parents) > 2 else Path("/")
API_BASE = os.environ.get("GITEA_API_BASE", "https://git.fhirworx.io/api/v1")
OWNER_REPO = os.environ.get("GITEA_OWNER_REPO", "homelab/stack")
LABEL_NAME = os.environ.get("NB_ISSUE_LABEL", "notebooks")
MARKER = "nb-sig:"
COOLDOWN_S = int(os.environ.get("NB_ISSUE_COOLDOWN_S", str(6 * 3600)))
# ── pure logic ───────────────────────────────────────────────────
def normalize_evalue(evalue: str) -> str:
"""Collapse volatile details so recurring errors hash identically."""
s = evalue.strip()
s = re.sub(r"0x[0-9a-fA-F]+", "0xN", s) # addresses
s = re.sub(r"(/[\w.\-]+)+", "/PATH", s) # absolute paths
s = re.sub(r"\d+(\.\d+)?", "N", s) # counts, line numbers, durations
s = re.sub(r"\s+", " ", s)
return s.lower()[:300]
def signature(notebook: str, ename: str, evalue: str) -> str:
key = f"{notebook}|{ename}|{normalize_evalue(evalue)}"
return hashlib.sha1(key.encode()).hexdigest()[:12]
def decide(
existing: dict | None, last_comment_age_s: float | None, cooldown_s: int
) -> str:
"""create | comment | skip for one observed signature."""
if existing is None:
return "create"
if last_comment_age_s is None or last_comment_age_s >= cooldown_s:
return "comment"
return "skip"
def build_body(
*, notebook: str, ename: str, evalue: str, source: str, detail: str, sig: str
) -> str:
detail = detail.strip()[:4000]
return (
f"Auto-filed by the notebook error filer (`{MARKER}{sig}` — do not edit "
f"this marker; dedup and auto-close key on it).\n\n"
f"- **Notebook:** `{notebook}`\n"
f"- **Error:** `{ename}`: {evalue[:500]}\n"
f"- **Source:** {source}\n\n"
f"```\n{detail or '(no traceback captured)'}\n```\n\n"
f"This issue is closed automatically when the error stops occurring."
)
def extract_sig(body: str) -> str | None:
m = re.search(rf"{MARKER}([0-9a-f]{{12}})", body or "")
return m.group(1) if m else None
# ── Gitea API (stdlib) ───────────────────────────────────────────
def _token() -> str:
tok = os.environ.get("GITEA_TOKEN", "")
if not tok:
env_file = ROOT / ".env"
if env_file.exists():
for line in env_file.read_text().splitlines():
if line.startswith("GITEA_TOKEN="):
tok = line.split("=", 1)[1].strip().strip('"').strip("'")
if not tok:
print("ERROR: GITEA_TOKEN not set and not found in .env", file=sys.stderr)
raise SystemExit(2)
return tok
def api(method: str, path: str, data: dict | None = None) -> tuple[int, object]:
url = f"{API_BASE}/{path}"
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(
url,
data=body,
method=method,
headers={
"Authorization": f"token {_token()}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode()
return resp.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e: # noqa: PERF203 — single call site
return e.code, e.read().decode()[:300]
except urllib.error.URLError as e:
return 0, str(e)
def _open_nb_issues() -> list[dict]:
"""All open issues carrying our marker, keyed lookup done by caller."""
issues: list[dict] = []
page = 1
while True:
status, batch = api(
"GET",
f"repos/{OWNER_REPO}/issues?state=open&type=issues"
f"&q={urllib.parse.quote(MARKER)}&page={page}&limit=50",
)
if status != 200 or not isinstance(batch, list) or not batch:
break
issues.extend(batch)
if len(batch) < 50:
break
page += 1
return [i for i in issues if extract_sig(i.get("body", ""))]
def _label_id() -> int | None:
status, labels = api("GET", f"repos/{OWNER_REPO}/labels?limit=50")
if status == 200 and isinstance(labels, list):
for lab in labels:
if lab.get("name") == LABEL_NAME:
return lab.get("id")
return None
def _last_comment_age_s(issue: dict) -> float | None:
ts = issue.get("updated_at")
if not ts:
return None
try:
then = time.mktime(time.strptime(ts[:19], "%Y-%m-%dT%H:%M:%S"))
return max(0.0, time.time() - time.timezone - (then - time.timezone))
except ValueError:
return None
# ── commands ─────────────────────────────────────────────────────
def cmd_report(findings: list[dict], source: str) -> int:
open_by_sig = {extract_sig(i["body"]): i for i in _open_nb_issues()}
label = _label_id()
seen: set[str] = set()
failures = 0
for f in findings:
sig = signature(f["notebook"], f["ename"], f.get("evalue", ""))
if sig in seen: # same error many times in one run → one action
continue
seen.add(sig)
existing = open_by_sig.get(sig)
age = _last_comment_age_s(existing) if existing else None
action = decide(existing, age, COOLDOWN_S)
if action == "create":
payload: dict = {
"title": f"[nb] {f['notebook']}: {f['ename']}: "
f"{f.get('evalue', '')[:120]}",
"body": build_body(
notebook=f["notebook"],
ename=f["ename"],
evalue=f.get("evalue", ""),
source=source,
detail=f.get("detail", ""),
sig=sig,
),
}
if label:
payload["labels"] = [label]
status, resp = api("POST", f"repos/{OWNER_REPO}/issues", payload)
if status == 201 and isinstance(resp, dict):
print(f" created #{resp.get('number')} [{sig}] {f['notebook']}")
else:
print(f" CREATE FAILED ({status}) [{sig}]: {resp}", file=sys.stderr)
failures += 1
elif action == "comment":
n = existing["number"]
status, _ = api(
"POST",
f"repos/{OWNER_REPO}/issues/{n}/comments",
{
"body": f"Still occurring via {source} at "
f"{time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime())}."
},
)
print(
f" commented #{n} [{sig}]"
if status == 201
else f" COMMENT FAILED ({status}) #{n}"
)
else:
print(f" skip [{sig}] (within cooldown)")
return 1 if failures else 0
def cmd_sweep(active_sigs: set[str], source: str) -> int:
closed = 0
for issue in _open_nb_issues():
sig = extract_sig(issue["body"])
if sig in active_sigs:
continue
n = issue["number"]
api(
"POST",
f"repos/{OWNER_REPO}/issues/{n}/comments",
{"body": f"No longer occurring ({source}); auto-closing."},
)
status, _ = api("PATCH", f"repos/{OWNER_REPO}/issues/{n}", {"state": "closed"})
if status in (200, 201):
print(f" closed #{n} [{sig}]")
closed += 1
else:
print(f" CLOSE FAILED ({status}) #{n}", file=sys.stderr)
print(f"sweep: closed {closed}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
p_report = sub.add_parser("report", help="file/refresh issues for findings")
p_report.add_argument("--source", required=True)
p_report.add_argument("--findings", help="path to findings JSON (default: stdin)")
p_sweep = sub.add_parser("sweep", help="close issues for inactive signatures")
p_sweep.add_argument("--source", required=True)
p_sweep.add_argument(
"--active-sigs", default="", help="comma-separated signatures still failing"
)
p_sweep.add_argument("--state", help="watcher state JSON: {sig: last_seen_epoch}")
p_sweep.add_argument("--max-age-h", type=float, default=24.0)
args = parser.parse_args()
if args.cmd == "report":
raw = Path(args.findings).read_text() if args.findings else sys.stdin.read()
findings = json.loads(raw) if raw.strip() else []
if not findings:
print("no findings — nothing to file")
return 0
return cmd_report(findings, args.source)
active: set[str] = {s for s in args.active_sigs.split(",") if s}
if args.state and Path(args.state).exists():
state = json.loads(Path(args.state).read_text())
cutoff = time.time() - args.max_age_h * 3600
active |= {sig for sig, ts in state.items() if ts >= cutoff}
return cmd_sweep(active, args.source)
if __name__ == "__main__":
raise SystemExit(main())