#!/usr/bin/env python3 """recipe-report — data + HTML helper for the weekly "Recipe Report" (/recipe-report skill). A newspaper-style front page: masthead, a SHORT editorial LEAD, then the comprehensive table ("the full wire" — priority-sorted, CVEs column), an ADDENDUM of special issues, a SECURITY BULLETIN, and a per-recipe "what changed" section at the end. Subcommands (the /recipe-report agent runs them around its own review/classification): survey [DATE] JSON of the run + every recipe's open PRs + CI verdict + per-recipe upgrade notes (breaking-change/CVE analysis), and the /upgrade-all summary. render SPEC.json OUT.html render the agent's report spec -> a self-contained newspaper HTML page publish OUT.html DATE copy to cc-ci:/var/lib/cc-ci-reports/week-DATE.html and regen the archive index Page order: short lead → the full wire table (priority-sorted, CVEs column) → Addendum → Security Bulletin → per-recipe "What changed". The full-wire table has two distinct status columns: TESTS — the CI/test verdict for the PR (GREEN/FAILED/STALE/SKIPPED/UPTODATE; from the `status` field). STATUS — the PR's LIVE state, fetched client-side and refreshed every ~30s: `open` vs a ✓ for any not-open state (merged OR closed). Derived from `recipe` + `pr`; the inline script GETs the same-origin `/pr//` proxy served by the ccci-reports nginx stack (cc-ci repo, nix/modules/reports.nix). Needs the recipe mirrors public; degrades to a muted "?" if the proxy/repo is unreachable. No spec-shape change — STATUS uses existing row fields. SPEC SHAPE (the agent writes this JSON): {"date":"YYYY-MM-DD","subtitle":"Week of ", "lead":"", "table":[{"recipe":"x","change":"a → b","status":"GREEN|FAILED|STALE|SKIPPED|UPTODATE", # -> TESTS col "cve":16, # number of CVEs this PR fixes; 0/omit for none "ci":"build 154 ✓","ci_url":"…","pr":"#4","pr_url":"…","notes":"…"}], # ROWS SORTED by recommended priority to address — recipes WITH CVEs first, then failures, # then stale-tests, then routine green, then up-to-date/skipped. "addendum":["a special issue to look into (multiple open PRs, a CI-run oddity, a possible improvement) …"], # only real issues — omit/empty if everything's clean "security":[{"title":"recipe — CVE-… (high)","body":"what it fixes","links":[{"text":"PR #","url":"…"}]}], "changes":[{"recipe":"x","body":"what changed in this recipe's PR","links":[{"text":"PR #4","url":"…"}]}]} # one `changes` entry per recipe that has a PR this week. PUBLIC PAGE — include only public-safe data (no secrets/tokens/raw logs). """ import base64, html, json, os, re, subprocess, sys, urllib.request from datetime import datetime, timezone LOGDIR = "/srv/cc-ci/.cc-ci-logs" TESTENV = "/srv/cc-ci/.testenv" INFRA = {"cc-ci", "cc-ci-orchestrator", "cc-ci-secrets"} HOST_REPORTS = "/var/lib/cc-ci-reports" def _env(): e = {} try: for ln in open(TESTENV): ln = ln.strip() if "=" in ln and not ln.startswith("#"): k, v = ln.split("=", 1) e[k] = v.strip().strip('"').strip("'") except FileNotFoundError: pass return e def _gitea_get(path, env): url = f"https://{env['GITEA_URL']}/api/v1{path}" auth = base64.b64encode(f"{env['GITEA_USERNAME']}:{env['GITEA_PASSWORD']}".encode()).decode() req = urllib.request.Request(url, headers={"Authorization": "Basic " + auth}) try: with urllib.request.urlopen(req, timeout=20) as r: return json.load(r) except Exception: return None def survey(date): env = _env() out = {"date": date, "generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")} sm = os.path.join(LOGDIR, "upgrades", f"upgrade-all-{date}.md") out["upgrade_summary_md"] = open(sm).read() if os.path.exists(sm) else None repos = _gitea_get("/orgs/recipe-maintainers/repos?limit=100", env) or [] recipes = sorted(r["name"] for r in repos if r["name"] not in INFRA and not r["name"].startswith("archived-")) out["recipes"] = [] for r in recipes: prs = _gitea_get(f"/repos/recipe-maintainers/{r}/pulls?state=open&limit=50", env) or [] rec = {"recipe": r, "open_prs": []} for p in prs: sha = p["head"]["sha"] st = _gitea_get(f"/repos/recipe-maintainers/{r}/commits/{sha}/status", env) or {} ci = {"state": st.get("state")} for s in (st.get("statuses") or []): if s.get("context") == "cc-ci/testme": ci = {"state": s.get("state"), "url": s.get("target_url"), "desc": s.get("description")} rec["open_prs"].append({"number": p["number"], "title": p["title"], "url": p["html_url"], "head": sha[:10], "ci": ci}) # Per-recipe upgrade plan/summary (has the breaking-change + CVE/security analysis) — the agent # mines this to lead with critical-CVE upgrades. nf = os.path.join(LOGDIR, "upgrades", f"{r}-upgrade-{date}.md") rec["upgrade_notes_md"] = open(nf).read() if os.path.exists(nf) else None out["recipes"].append(rec) print(json.dumps(out, indent=2)) # ---------- newspaper HTML ---------- CSS = """ :root{--ink:#1a1a1a;--mut:#555;--rule:#111;--bg:#e8e4d9;--paper:#fbf9f3;--red:#9b1c1c;--accent:#6b4e00} *{box-sizing:border-box} body{font:18px/1.65 Georgia,'Times New Roman',serif;color:var(--ink);background:var(--bg);margin:0;padding:2rem 1rem 4rem} .paper{max-width:62rem;margin:0 auto;background:var(--paper);padding:2.4rem 2.6rem 3rem;box-shadow:0 2px 10px rgba(0,0,0,.12)} .mast{text-align:center;border-bottom:4px double var(--rule);padding-bottom:.4rem} .mast .kicker{font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.22em;color:var(--mut)} .mast h1{font-size:3.7rem;line-height:1;margin:.25rem 0 .35rem;font-weight:900;letter-spacing:-.02em} .dateline{display:flex;flex-wrap:wrap;gap:.5rem;justify-content:space-between;font-size:.76rem;text-transform:uppercase;letter-spacing:.09em;color:var(--mut);border-bottom:1px solid var(--rule);padding:.35rem 0;margin-bottom:1.5rem} .lead{font-size:1.16rem;line-height:1.72} .lead p{margin:.4rem 0} .lead p:first-of-type::first-letter{float:left;font-size:3.6rem;line-height:.78;padding:.08rem .55rem .05rem 0;font-weight:900} .kicker{font-size:.74rem;font-weight:700;text-transform:uppercase;letter-spacing:.14em;color:var(--red)} h2{font-size:1.55rem;border-top:2px solid var(--rule);padding-top:.5rem;margin:2.1rem 0 .5rem;letter-spacing:-.01em} .bulletin{border:2px solid var(--red);background:#fdf2f2;padding:1rem 1.2rem;margin:1.6rem 0} .bulletin h2{border:0;margin:.1rem 0 .5rem;padding:0;color:var(--red);font-size:1.5rem} .story{margin:.7rem 0;padding-bottom:.7rem;border-bottom:1px solid #d8d2c2} .story .h{font-weight:700;font-size:1.1rem;line-height:1.3} .story .b{margin:.2rem 0 .35rem;color:#2b2b2b} .links a{font-size:.84rem;margin-right:.7rem} a{color:#0b3d91;text-decoration:none}a:hover{text-decoration:underline} .cols{column-count:2;column-gap:2.2rem}.cols .story{break-inside:avoid} @media(max-width:640px){.cols{column-count:1}.mast h1{font-size:2.5rem}.paper{padding:1.4rem}} table{border-collapse:collapse;width:100%;font:14px/1.45 Georgia,serif;margin-top:.5rem} th,td{text-align:left;padding:.42rem .55rem;border-bottom:1px solid #ccc;vertical-align:top} th{border-bottom:2px solid var(--rule);font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)} .s-GREEN{color:#1a7a3c;font-weight:700}.s-RED,.s-FAILED{color:var(--red);font-weight:700} .s-STALE{color:var(--accent);font-weight:700}.s-SKIPPED,.s-UPTODATE{color:var(--mut)} footer{margin-top:2.6rem;border-top:4px double var(--rule);padding-top:.8rem;font-size:.82rem;color:var(--mut);text-align:center} .idx{list-style:none;padding:0}.idx li{padding:.55rem 0;border-bottom:1px solid #d8d2c2;font-size:1.1rem} .idx .d{color:var(--mut);font-size:.85rem;float:right} td .cve{color:var(--red);font-weight:800}.muted{color:#999} .pr-status{white-space:nowrap} .pr-open{display:inline-block;font:700 .66rem/1 Georgia,serif;text-transform:uppercase;letter-spacing:.07em;color:#9b6b00;background:#fff3d6;border:1px solid #e3c98a;border-radius:.2rem;padding:.13rem .42rem} .pr-done{color:#1a7a3c;font-weight:800;font-size:1.05rem} .addendum-h{font-size:1rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;margin:1.7rem 0 .3rem;color:var(--ink)} ul.addendum{margin:.2rem 0 1.2rem 1.2rem;padding:0}ul.addendum li{margin:.35rem 0;line-height:1.55} .change{margin:.8rem 0;padding-bottom:.7rem;border-bottom:1px solid #d8d2c2} .change .ch{font-weight:700;font-size:1.12rem}.change .cb{margin:.2rem 0 .35rem;color:#2b2b2b} """ # Inline (CSP-safe, dependency-free) script that lights up the live PR-STATUS column. For each # `.pr-status[data-pr]` cell it GETs the same-origin /pr// proxy (see reports.nix in the # cc-ci repo) and renders BINARY state: `state==='open'` -> an "open" badge; any other state # (closed/merged) -> a green ✓ (the title shows merged vs closed). It refreshes every 30s so an open # tab tracks state in realtime, and is fully resilient — a network/parse error leaves a muted "?" and # never overwrites a value already resolved, so the page renders fully even if the proxy is down. STATUS_JS = """ (function(){ function refresh(){ document.querySelectorAll('.pr-status[data-pr]').forEach(function(cell){ var repo=cell.getAttribute('data-repo'), pr=cell.getAttribute('data-pr'); if(!repo||!pr) return; fetch('/pr/'+encodeURIComponent(repo)+'/'+encodeURIComponent(pr),{headers:{'Accept':'application/json'}}) .then(function(r){ if(!r.ok) throw 0; return r.json(); }) .then(function(d){ var st=d&&d.state; if(st==='open'){ cell.innerHTML='open'; cell.dataset.ok='1'; } else if(st){ cell.innerHTML='\\u2713'; cell.dataset.ok='1'; } else throw 0; }) .catch(function(){ if(!cell.dataset.ok){ cell.innerHTML='?'; } }); }); } if(document.readyState!=='loading'){ refresh(); } else { document.addEventListener('DOMContentLoaded', refresh); } setInterval(refresh, 30000); })(); """ def _esc(s): return html.escape(str(s or "")) def _linkify_recipes(text, repo_url): """Linkify whole-word recipe-name mentions to their mirror repo. ONE pass over the (already HTML-escaped) text, longest names first so 'custom-html-tiny' wins over 'custom-html'; re.sub does not re-scan inserted hrefs, so URLs that end in a recipe name aren't double-linked.""" if not repo_url: return text names = sorted(repo_url, key=len, reverse=True) pat = re.compile(r"(?{m.group(1)}', text) def _links(links): if not links: return "" return '" def _stories(items, repo_url=None): if not items: return "

Nothing this week.

" lk = (lambda x: _linkify_recipes(_esc(x), repo_url)) if repo_url else _esc return "\n".join( f'
{lk(it.get("title"))}
' f'
{lk(it.get("body"))}
{_links(it.get("links"))}
' for it in items) def _table(rows, repo_url=None): if not rows: return "" # TESTS = the CI/test verdict (GREEN/FAILED/STALE/…); STATUS = the PR's LIVE state, fetched # client-side (open vs ✓ for any not-open state) by the inline script in _page(). head = ("RecipeChangeTESTSCVEs" "CIPRSTATUSNotes") trs = [] for r in rows: scls = "s-" + str(r.get("status", "")).upper().replace("-", "").replace(" ", "") name = _esc(r.get("recipe")) if repo_url and r.get("recipe") in repo_url: name = f'{name}' cve = r.get("cve") cve_cell = (f'{int(cve)}' if isinstance(cve, (int, float)) and cve else 'none') ci = _esc(r.get("ci")) if r.get("ci_url"): ci = f'{ci}' pr = _esc(r.get("pr")) if r.get("pr_url"): pr = f'{pr}' # Live PR-STATUS cell: a JS hook carrying the repo + PR number derived from the existing # `recipe` + `pr` fields. The inline script fills it from /pr//. Blank when no PR. recipe = r.get("recipe") or "" m = re.search(r"\d+", str(r.get("pr") or "")) if m and recipe: status_cell = (f'') else: status_cell = "" trs.append(f"{name}{_esc(r.get('change'))}" f'{_esc(r.get("status"))}{cve_cell}' f"{ci}{pr}{status_cell}{_esc(r.get('notes'))}") return f"{head}{''.join(trs)}
" def _changes(items, repo_url=None): """Per-recipe 'what changed' sections (one per recipe that has a PR).""" if not items: return "" lk = (lambda x: _linkify_recipes(_esc(x), repo_url)) if repo_url else _esc out = [] for c in items: name = c.get("recipe") hdr = _esc(name) if repo_url and name in repo_url: hdr = f'{hdr}' out.append(f'
{hdr}
' f'
{lk(c.get("body"))}
{_links(c.get("links"))}
') return "\n".join(out) def _page(title, body): # The live-status script ships on every page (reports + archive index); harmless where there are # no `.pr-status` cells. Self-contained: inline CSS + inline JS, no external requests. return (f'' f'' f"{_esc(title)}" f'
{body}
') def _mast(): return ('
Co-op Cloud Recipe CI · Weekly Edition
' '

The Recipe Report

') def render(spec_path, out_path): s = json.load(open(spec_path)) gen = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") sub = s.get("subtitle", "Week of " + s["date"]) lead = s.get("lead", "") or "" # Auto-link recipe-name mentions in the lead to their mirror repos. gitea = _env().get("GITEA_URL", "git.autonomic.zone") repo_url = {r["recipe"]: f"https://{gitea}/recipe-maintainers/{r['recipe']}" for r in (s.get("table") or []) if r.get("recipe")} if lead and "

" not in lead: lead = "".join(f"

{_linkify_recipes(_esc(p.strip()), repo_url)}

" for p in lead.split("\n\n") if p.strip()) body = (_mast() + f'' f'
{lead}
') # 1) the full wire — every recipe, in the agent's recommended priority order (CVEs first); CVEs column. body += f'

The full wire — every recipe, in priority order

{_table(s.get("table"), repo_url)}' # 2) addendum — special issues to look into (normal-size header); omitted entirely if there are none. add = [a for a in (s.get("addendum") or []) if str(a).strip()] if add: body += ('

Addendum

    ' + "".join(f"
  • {_linkify_recipes(_esc(b), repo_url)}
  • " for b in add) + "
") # 3) security bulletin if s.get("security"): body += ('
Security Bulletin
' '

🔒 Critical CVE upgrades

' + _stories(s["security"], repo_url) + "
") # 4) what changed — a short section per recipe that has a PR if s.get("changes"): body += f'

What changed

{_changes(s.get("changes"), repo_url)}' body += (f'') open(out_path, "w").write(_page("The Recipe Report — " + s["date"], body)) print("wrote", out_path) def publish(html_path, date): page = f"week-{date}.html" subprocess.run(["ssh", "cc-ci", f"cat > {HOST_REPORTS}/{page}"], input=open(html_path, "rb").read(), check=True) listing = subprocess.run(["ssh", "cc-ci", f"ls -1 {HOST_REPORTS}/week-*.html 2>/dev/null"], capture_output=True, text=True).stdout.split() dates = sorted({os.path.basename(p)[5:-5] for p in listing}, reverse=True) lis = "\n".join(f'
  • Week of {d}{d}
  • ' for d in dates) idx = _page("The Recipe Report — Archive", _mast() + '' f'
      {lis or "
    • No reports yet.
    • "}
    ') subprocess.run(["ssh", "cc-ci", f"cat > {HOST_REPORTS}/index.html"], input=idx.encode(), check=True) print(f"published https://report.ci.commoninternet.net/{page} (+ index)") def main(): a = sys.argv[1:] cmd = a[0] if a else "survey" today = datetime.now(timezone.utc).strftime("%Y-%m-%d") if cmd == "survey": survey(a[1] if len(a) > 1 else today) elif cmd == "render": render(a[1], a[2]) elif cmd == "publish": publish(a[1], a[2]) else: print(__doc__); sys.exit(2) if __name__ == "__main__": main()