diff --git a/.claude/skills/recipe-report/SKILL.md b/.claude/skills/recipe-report/SKILL.md index d51ff93..8168c89 100644 --- a/.claude/skills/recipe-report/SKILL.md +++ b/.claude/skills/recipe-report/SKILL.md @@ -20,19 +20,26 @@ Helper: `python3 /srv/cc-ci/cc-ci-plan/recipe-report.py {survey|render|publish}` `/srv/cc-ci/.cc-ci-logs/upgrades/upgrade-all-.md`. 2. **Survey.** `python3 /srv/cc-ci/cc-ci-plan/recipe-report.py survey > /tmp/survey.json`. This - gives you the raw `/upgrade-all` summary markdown plus, for every recipe mirror, its open PRs and the - `cc-ci/testme` CI verdict on each PR head. Read it all. + gives you: the raw `/upgrade-all` summary; for every recipe mirror its open PRs + the `cc-ci/testme` + verdict; and each recipe's **per-recipe upgrade notes** (`upgrade_notes_md` — the breaking-change / + **CVE / security** analysis the upgrade did). Read it all. -3. **Review & classify.** Judge the run and the recipe/PR state. Sort everything into: - - **Needs attention** — PRs that are GREEN and ready to merge, and errors/failures to investigate - (RED `!testme`, recipe bugs, anything blocking). Lead with these. Short, specific prose per item. - - **Routine** — minor/clean bumps, stale-test PRs (need operator `--with-tests`), up-to-date or - skipped-by-design. Brief. - Also flag cross-cutting issues (e.g. a recipe ending with two open PRs to reconcile). +3. **Review & classify — this is a front page, write it like an editor.** + - **Security first.** Scan the per-recipe `upgrade_notes_md` + the summary (and use your own knowledge + of the version bumps) for upgrades that fix **CVEs / security issues**. Anything **critical/high** + leads the page → the `security` bulletin (recipe · CVE id(s) + severity · what it fixes · PR link). + This is the most important section; be specific about severity and what's exposed if not merged. + - **Lead / editorial.** Write a short `lead` (2–4 short paragraphs): the **overall state of the recipe + fleet** this week (how healthy, what moved, any worrying trend) and **specific, opinionated + suggestions of what to focus on** — like a newspaper lead. This is opus's voice; be useful, concrete. + - **Needs attention** — GREEN PRs ready to merge + errors/failures to investigate (RED `!testme`, + recipe bugs). Short, specific prose + links. Flag cross-cutting issues (e.g. two open PRs to reconcile). + - **Routine** — minor/clean bumps, stale-test PRs (need operator `--with-tests`), up-to-date / skipped. 4. **Write the spec** `/tmp/report-spec.json` (shape in the helper header): `date`, `subtitle` - ("Week of "), a one-line `headline`, `needs_attention[]`, `routine[]`, and a - **comprehensive `table[]`** with EVERY recipe (`recipe`, `change` "a → b", `status` + ("Week of "), `lead` (the editorial, blank-line-separated paragraphs), `security[]` + (critical-CVE upgrades — may be empty), `needs_attention[]`, `routine[]`, and a **comprehensive + `table[]`** with EVERY recipe (`recipe`, `change` "a → b", `status` GREEN/STALE/FAILED/SKIPPED/UPTODATE, `ci` as a level/result number+info string e.g. `build 154 ✓` or `RED · restore` with `ci_url`, `pr`/`pr_url`, `notes`). **CI = link + number/info only; no images.** diff --git a/cc-ci-plan/recipe-report.py b/cc-ci-plan/recipe-report.py index 3635421..c6fd5a3 100755 --- a/cc-ci-plan/recipe-report.py +++ b/cc-ci-plan/recipe-report.py @@ -1,23 +1,27 @@ #!/usr/bin/env python3 """recipe-report — data + HTML helper for the weekly "Recipe Report" (/recipe-report skill). -Three subcommands; the /recipe-report agent runs them around its own review/classification: - survey [DATE] print structured JSON of the run + every recipe's open PRs + CI verdict - (DATE defaults to today UTC; reads the dated /upgrade-all summary if present) - render SPEC.json OUT.html render the agent's report spec -> a self-contained HTML page - publish OUT.html DATE copy the page to cc-ci:/var/lib/cc-ci-reports/week-DATE.html and regen index +A newspaper-style front page: masthead, an editorial LEAD (overall recipe-fleet state + what to focus +on), a SECURITY BULLETIN of critical-CVE upgrades up top, then needs-attention / routine, and the +comprehensive table ("the full wire") at the end. -The agent: `survey` -> review the data, write a SPEC (see SPEC SHAPE below) -> `render` -> `publish`. +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 SPEC SHAPE (the agent writes this JSON): - {"date":"YYYY-MM-DD","subtitle":"Week of ","headline":"one line", - "needs_attention":[{"title":"...","body":"","links":[{"text":"PR #4","url":"..."}]}], + {"date":"YYYY-MM-DD","subtitle":"Week of ", + "lead":"", + "security":[{"title":"recipe — CVE-… (critical)","body":"what it fixes","links":[{"text":"PR #","url":"…"}]}], + "needs_attention":[{"title":"…","body":"…","links":[…]}], "routine":[ {same shape} ], - "table":[{"recipe":"x","change":"a → b","status":"GREEN","ci":"level 8 ✓","ci_url":"...", - "pr":"#4","pr_url":"...","notes":"..."}]} + "table":[{"recipe":"x","change":"a → b","status":"GREEN","ci":"build 154 ✓","ci_url":"…", + "pr":"#4","pr_url":"…","notes":"…"}]} PUBLIC PAGE — include only public-safe data (no secrets/tokens/raw logs). """ -import html, json, os, subprocess, sys, urllib.request, urllib.parse +import base64, html, json, os, subprocess, sys, urllib.request from datetime import datetime, timezone LOGDIR = "/srv/cc-ci/.cc-ci-logs" @@ -41,9 +45,8 @@ def _env(): def _gitea_get(path, env): url = f"https://{env['GITEA_URL']}/api/v1{path}" - auth = "%s:%s" % (env["GITEA_USERNAME"], env["GITEA_PASSWORD"]) - import base64 - req = urllib.request.Request(url, headers={"Authorization": "Basic " + base64.b64encode(auth.encode()).decode()}) + 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) @@ -54,10 +57,8 @@ def _gitea_get(path, env): def survey(date): env = _env() out = {"date": date, "generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")} - # The /upgrade-all dated summary (raw markdown; the agent reads its narrative). 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 - # Every recipe mirror -> open PRs + the cc-ci/testme verdict on each PR head. 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"] = [] @@ -73,28 +74,46 @@ def survey(date): 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)) -# ---------- HTML rendering ---------- +# ---------- newspaper HTML ---------- CSS = """ -:root{--fg:#1a1a1a;--mut:#666;--bd:#e3e3e3;--bg:#fafafa;--card:#fff;--accent:#c9760a;--red:#c0392b;--green:#218a4e} -*{box-sizing:border-box}body{font:16px/1.6 system-ui,-apple-system,Segoe UI,sans-serif;color:var(--fg); -background:var(--bg);margin:0;padding:2.5rem 1rem 4rem}.wrap{max-width:56rem;margin:0 auto} -h1{font-size:2.4rem;margin:0;letter-spacing:-.02em}.sub{color:var(--mut);font-size:1.15rem;margin:.2rem 0 .2rem} -.headline{font-size:1.1rem;margin:.6rem 0 2rem;padding:.7rem 1rem;background:#fff7ec;border-left:4px solid var(--accent);border-radius:4px} -h2{font-size:1.45rem;margin:2.4rem 0 .8rem;border-bottom:2px solid var(--bd);padding-bottom:.3rem} -.item{background:var(--card);border:1px solid var(--bd);border-radius:8px;padding:.9rem 1.1rem;margin:.7rem 0} -.item.attn{border-left:4px solid var(--red)}.item .t{font-weight:600}.item .b{color:#333;margin:.25rem 0 .4rem} -.links a{margin-right:.8rem;font-size:.92rem}a{color:#0b62c4;text-decoration:none}a:hover{text-decoration:underline} -table{border-collapse:collapse;width:100%;font-size:.93rem;margin-top:.6rem} -th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--bd);vertical-align:top} -th{color:var(--mut);font-weight:600;font-size:.82rem;text-transform:uppercase;letter-spacing:.03em} -.s-GREEN{color:var(--green);font-weight:600}.s-RED,.s-FAILED{color:var(--red);font-weight:600} -.s-STALE{color:var(--accent);font-weight:600}.s-SKIPPED,.s-UPTODATE{color:var(--mut)} -footer{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--bd);color:var(--mut);font-size:.9rem} -.idx li{margin:.4rem 0}.idx .d{color:var(--mut);font-size:.9rem} +: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} """ @@ -105,20 +124,16 @@ def _esc(s): def _links(links): if not links: return "" - a = " · ".join(f'{_esc(l["text"])}' for l in links) - return f'' + return '" -def _items(items, attn=False): +def _stories(items): if not items: - return "

None.

" - out = [] - for it in items: - out.append( - f'
' - f'
{_esc(it.get("title"))}
' - f'
{_esc(it.get("body"))}
{_links(it.get("links"))}
') - return "\n".join(out) + return "

Nothing this week.

" + return "\n".join( + f'
{_esc(it.get("title"))}
' + f'
{_esc(it.get("body"))}
{_links(it.get("links"))}
' for it in items) def _table(rows): @@ -143,38 +158,48 @@ def _table(rows): def _page(title, body): return (f'' f'' - f"{_esc(title)}
{body}
") + 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") - body = ( - f'

🌻 The Recipe Report

' - f'
{_esc(s.get("subtitle", "Week of " + s["date"]))}
' - f'
{_esc(s.get("headline"))}
' - f'

⚑ Needs attention

{_items(s.get("needs_attention"), attn=True)}' - f'

Routine

{_items(s.get("routine"))}' - f'

All recipes

{_table(s.get("table"))}' - f'') + sub = s.get("subtitle", "Week of " + s["date"]) + lead = s.get("lead", "") or "" + if lead and "

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

{_esc(p.strip())}

" for p in lead.split("\n\n") if p.strip()) + body = (_mast() + + f'' + f'
{lead}
') + if s.get("security"): + body += ('
Security Bulletin
' + '

🔒 Critical CVE upgrades — merge first

' + _stories(s["security"]) + "
") + body += f'

⚑ Needs attention

{_stories(s.get("needs_attention"))}' + body += f'

Routine

{_stories(s.get("routine"))}
' + body += f'

The full wire — every recipe

{_table(s.get("table"))}' + body += (f'') open(out_path, "w").write(_page("The Recipe Report — " + s["date"], body)) print("wrote", out_path) def publish(html_path, date): - # Copy the page to the host, then rebuild index.html from the host's week-*.html list. page = f"week-{date}.html" - data = open(html_path, "rb").read() - subprocess.run(["ssh", "cc-ci", f"cat > {HOST_REPORTS}/{page}"], input=data, check=True) - # Rebuild index: list week-*.html, newest first, with each page's /subtitle hint = date. + 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'<li><a href="week-{d}.html">Week of {d}</a> <span class="d">— {d}</span></li>' for d in dates) - idx = _page("The Recipe Report", f'<h1>🌻 The Recipe Report</h1>' - f'<div class="sub">Weekly review of Co-op Cloud recipe upgrades + CI</div>' + lis = "\n".join(f'<li><a href="week-{d}.html">Week of {d}</a><span class="d">{d}</span></li>' for d in dates) + idx = _page("The Recipe Report — Archive", _mast() + + '<div class="dateline"><span>Weekly review of Co-op Cloud recipe upgrades & CI</span>' + '<span>report.ci.commoninternet.net</span></div>' f'<ul class="idx">{lis or "<li><em>No reports yet.</em></li>"}</ul>') 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)")