feat(recipe-report): newspaper front-page layout — editorial lead + CVE security bulletin first

Masthead + opus 'lead' editorial (overall fleet state + what to focus on), a Security Bulletin of
critical-CVE upgrades up top (mined from per-recipe upgrade_notes_md), then needs-attention/routine,
and the comprehensive table as 'the full wire' at the end. survey now includes each recipe's
upgrade_notes_md (breaking-change/CVE analysis) so opus can lead with security.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
autonomic-bot
2026-06-02 23:13:40 +00:00
parent 856df8cb37
commit 6cf59130db
2 changed files with 104 additions and 72 deletions

View File

@ -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-<DATE>.md`. `/srv/cc-ci/.cc-ci-logs/upgrades/upgrade-all-<DATE>.md`.
2. **Survey.** `python3 /srv/cc-ci/cc-ci-plan/recipe-report.py survey <DATE> > /tmp/survey.json`. This 2. **Survey.** `python3 /srv/cc-ci/cc-ci-plan/recipe-report.py survey <DATE> > /tmp/survey.json`. This
gives you the raw `/upgrade-all` summary markdown plus, for every recipe mirror, its open PRs and the gives you: the raw `/upgrade-all` summary; for every recipe mirror its open PRs + the `cc-ci/testme`
`cc-ci/testme` CI verdict on each PR head. Read it all. 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: 3. **Review & classify — this is a front page, write it like an editor.**
- **Needs attention** — PRs that are GREEN and ready to merge, and errors/failures to investigate - **Security first.** Scan the per-recipe `upgrade_notes_md` + the summary (and use your own knowledge
(RED `!testme`, recipe bugs, anything blocking). Lead with these. Short, specific prose per item. of the version bumps) for upgrades that fix **CVEs / security issues**. Anything **critical/high**
- **Routine** — minor/clean bumps, stale-test PRs (need operator `--with-tests`), up-to-date or leads the page → the `security` bulletin (recipe · CVE id(s) + severity · what it fixes · PR link).
skipped-by-design. Brief. This is the most important section; be specific about severity and what's exposed if not merged.
Also flag cross-cutting issues (e.g. a recipe ending with two open PRs to reconcile). - **Lead / editorial.** Write a short `lead` (24 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` 4. **Write the spec** `/tmp/report-spec.json` (shape in the helper header): `date`, `subtitle`
("Week of <human date>"), a one-line `headline`, `needs_attention[]`, `routine[]`, and a ("Week of <human date>"), `lead` (the editorial, blank-line-separated paragraphs), `security[]`
**comprehensive `table[]`** with EVERY recipe (`recipe`, `change` "a → b", `status` (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 ✓` 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.** or `RED · restore` with `ci_url`, `pr`/`pr_url`, `notes`). **CI = link + number/info only; no images.**

View File

@ -1,23 +1,27 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""recipe-report — data + HTML helper for the weekly "Recipe Report" (/recipe-report skill). """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: A newspaper-style front page: masthead, an editorial LEAD (overall recipe-fleet state + what to focus
survey [DATE] print structured JSON of the run + every recipe's open PRs + CI verdict on), a SECURITY BULLETIN of critical-CVE upgrades up top, then needs-attention / routine, and the
(DATE defaults to today UTC; reads the dated /upgrade-all summary if present) comprehensive table ("the full wire") at the end.
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
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): SPEC SHAPE (the agent writes this JSON):
{"date":"YYYY-MM-DD","subtitle":"Week of <human date>","headline":"one line", {"date":"YYYY-MM-DD","subtitle":"Week of <human date>",
"needs_attention":[{"title":"...","body":"<plain text>","links":[{"text":"PR #4","url":"..."}]}], "lead":"<editorial: overall fleet state + what to focus on; blank-line-separated paragraphs>",
"security":[{"title":"recipe — CVE-… (critical)","body":"what it fixes","links":[{"text":"PR #","url":""}]}],
"needs_attention":[{"title":"","body":"","links":[…]}],
"routine":[ {same shape} ], "routine":[ {same shape} ],
"table":[{"recipe":"x","change":"a → b","status":"GREEN","ci":"level 8","ci_url":"...", "table":[{"recipe":"x","change":"a → b","status":"GREEN","ci":"build 154","ci_url":"",
"pr":"#4","pr_url":"...","notes":"..."}]} "pr":"#4","pr_url":"","notes":""}]}
PUBLIC PAGE — include only public-safe data (no secrets/tokens/raw logs). 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 from datetime import datetime, timezone
LOGDIR = "/srv/cc-ci/.cc-ci-logs" LOGDIR = "/srv/cc-ci/.cc-ci-logs"
@ -41,9 +45,8 @@ def _env():
def _gitea_get(path, env): def _gitea_get(path, env):
url = f"https://{env['GITEA_URL']}/api/v1{path}" url = f"https://{env['GITEA_URL']}/api/v1{path}"
auth = "%s:%s" % (env["GITEA_USERNAME"], env["GITEA_PASSWORD"]) auth = base64.b64encode(f"{env['GITEA_USERNAME']}:{env['GITEA_PASSWORD']}".encode()).decode()
import base64 req = urllib.request.Request(url, headers={"Authorization": "Basic " + auth})
req = urllib.request.Request(url, headers={"Authorization": "Basic " + base64.b64encode(auth.encode()).decode()})
try: try:
with urllib.request.urlopen(req, timeout=20) as r: with urllib.request.urlopen(req, timeout=20) as r:
return json.load(r) return json.load(r)
@ -54,10 +57,8 @@ def _gitea_get(path, env):
def survey(date): def survey(date):
env = _env() env = _env()
out = {"date": date, "generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")} 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") 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 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 [] 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-")) recipes = sorted(r["name"] for r in repos if r["name"] not in INFRA and not r["name"].startswith("archived-"))
out["recipes"] = [] out["recipes"] = []
@ -73,28 +74,46 @@ def survey(date):
ci = {"state": s.get("state"), "url": s.get("target_url"), "desc": s.get("description")} ci = {"state": s.get("state"), "url": s.get("target_url"), "desc": s.get("description")}
rec["open_prs"].append({"number": p["number"], "title": p["title"], rec["open_prs"].append({"number": p["number"], "title": p["title"],
"url": p["html_url"], "head": sha[:10], "ci": ci}) "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) out["recipes"].append(rec)
print(json.dumps(out, indent=2)) print(json.dumps(out, indent=2))
# ---------- HTML rendering ---------- # ---------- newspaper HTML ----------
CSS = """ CSS = """
:root{--fg:#1a1a1a;--mut:#666;--bd:#e3e3e3;--bg:#fafafa;--card:#fff;--accent:#c9760a;--red:#c0392b;--green:#218a4e} :root{--ink:#1a1a1a;--mut:#555;--rule:#111;--bg:#e8e4d9;--paper:#fbf9f3;--red:#9b1c1c;--accent:#6b4e00}
*{box-sizing:border-box}body{font:16px/1.6 system-ui,-apple-system,Segoe UI,sans-serif;color:var(--fg); *{box-sizing:border-box}
background:var(--bg);margin:0;padding:2.5rem 1rem 4rem}.wrap{max-width:56rem;margin:0 auto} body{font:18px/1.65 Georgia,'Times New Roman',serif;color:var(--ink);background:var(--bg);margin:0;padding:2rem 1rem 4rem}
h1{font-size:2.4rem;margin:0;letter-spacing:-.02em}.sub{color:var(--mut);font-size:1.15rem;margin:.2rem 0 .2rem} .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)}
.headline{font-size:1.1rem;margin:.6rem 0 2rem;padding:.7rem 1rem;background:#fff7ec;border-left:4px solid var(--accent);border-radius:4px} .mast{text-align:center;border-bottom:4px double var(--rule);padding-bottom:.4rem}
h2{font-size:1.45rem;margin:2.4rem 0 .8rem;border-bottom:2px solid var(--bd);padding-bottom:.3rem} .mast .kicker{font-size:.78rem;font-weight:700;text-transform:uppercase;letter-spacing:.22em;color:var(--mut)}
.item{background:var(--card);border:1px solid var(--bd);border-radius:8px;padding:.9rem 1.1rem;margin:.7rem 0} .mast h1{font-size:3.7rem;line-height:1;margin:.25rem 0 .35rem;font-weight:900;letter-spacing:-.02em}
.item.attn{border-left:4px solid var(--red)}.item .t{font-weight:600}.item .b{color:#333;margin:.25rem 0 .4rem} .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}
.links a{margin-right:.8rem;font-size:.92rem}a{color:#0b62c4;text-decoration:none}a:hover{text-decoration:underline} .lead{font-size:1.16rem;line-height:1.72}
table{border-collapse:collapse;width:100%;font-size:.93rem;margin-top:.6rem} .lead p{margin:.4rem 0}
th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--bd);vertical-align:top} .lead p:first-of-type::first-letter{float:left;font-size:3.6rem;line-height:.78;padding:.08rem .55rem .05rem 0;font-weight:900}
th{color:var(--mut);font-weight:600;font-size:.82rem;text-transform:uppercase;letter-spacing:.03em} .kicker{font-size:.74rem;font-weight:700;text-transform:uppercase;letter-spacing:.14em;color:var(--red)}
.s-GREEN{color:var(--green);font-weight:600}.s-RED,.s-FAILED{color:var(--red);font-weight:600} h2{font-size:1.55rem;border-top:2px solid var(--rule);padding-top:.5rem;margin:2.1rem 0 .5rem;letter-spacing:-.01em}
.s-STALE{color:var(--accent);font-weight:600}.s-SKIPPED,.s-UPTODATE{color:var(--mut)} .bulletin{border:2px solid var(--red);background:#fdf2f2;padding:1rem 1.2rem;margin:1.6rem 0}
footer{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--bd);color:var(--mut);font-size:.9rem} .bulletin h2{border:0;margin:.1rem 0 .5rem;padding:0;color:var(--red);font-size:1.5rem}
.idx li{margin:.4rem 0}.idx .d{color:var(--mut);font-size:.9rem} .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): def _links(links):
if not links: if not links:
return "" return ""
a = " · ".join(f'<a href="{_esc(l["url"])}">{_esc(l["text"])}</a>' for l in links) return '<div class="links">' + " · ".join(
return f'<div class="links">{a}</div>' f'<a href="{_esc(l["url"])}">{_esc(l["text"])}</a>' for l in links) + "</div>"
def _items(items, attn=False): def _stories(items):
if not items: if not items:
return "<p><em>None.</em></p>" return "<p><em>Nothing this week.</em></p>"
out = [] return "\n".join(
for it in items: f'<div class="story"><div class="h">{_esc(it.get("title"))}</div>'
out.append( f'<div class="b">{_esc(it.get("body"))}</div>{_links(it.get("links"))}</div>' for it in items)
f'<div class="item{" attn" if attn else ""}">'
f'<div class="t">{_esc(it.get("title"))}</div>'
f'<div class="b">{_esc(it.get("body"))}</div>{_links(it.get("links"))}</div>')
return "\n".join(out)
def _table(rows): def _table(rows):
@ -143,38 +158,48 @@ def _table(rows):
def _page(title, body): def _page(title, body):
return (f'<!doctype html><html lang="en"><head><meta charset="utf-8">' return (f'<!doctype html><html lang="en"><head><meta charset="utf-8">'
f'<meta name="viewport" content="width=device-width,initial-scale=1">' f'<meta name="viewport" content="width=device-width,initial-scale=1">'
f"<title>{_esc(title)}</title><style>{CSS}</style></head><body><div class=wrap>{body}</div></body></html>") f"<title>{_esc(title)}</title><style>{CSS}</style></head>"
f'<body><div class="paper">{body}</div></body></html>')
def _mast():
return ('<div class="mast"><div class="kicker">Co-op Cloud Recipe CI · Weekly Edition</div>'
'<h1>The Recipe Report</h1></div>')
def render(spec_path, out_path): def render(spec_path, out_path):
s = json.load(open(spec_path)) s = json.load(open(spec_path))
gen = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") gen = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
body = ( sub = s.get("subtitle", "Week of " + s["date"])
f'<h1>🌻 The Recipe Report</h1>' lead = s.get("lead", "") or ""
f'<div class="sub">{_esc(s.get("subtitle", "Week of " + s["date"]))}</div>' if lead and "<p>" not in lead:
f'<div class="headline">{_esc(s.get("headline"))}</div>' lead = "".join(f"<p>{_esc(p.strip())}</p>" for p in lead.split("\n\n") if p.strip())
f'<h2>⚑ Needs attention</h2>{_items(s.get("needs_attention"), attn=True)}' body = (_mast() +
f'<h2>Routine</h2>{_items(s.get("routine"))}' f'<div class="dateline"><span>{_esc(sub)}</span>'
f'<h2>All recipes</h2>{_table(s.get("table"))}' f'<span>report.ci.commoninternet.net</span><span>{gen}</span></div>'
f'<footer>Generated {gen} · ' f'<div class="lead">{lead}</div>')
f'<a href="https://ci.commoninternet.net/">dashboard</a> · ' if s.get("security"):
f'<a href="./">all reports</a></footer>') body += ('<div class="bulletin"><div class="kicker">Security Bulletin</div>'
'<h2>🔒 Critical CVE upgrades — merge first</h2>' + _stories(s["security"]) + "</div>")
body += f'<h2>⚑ Needs attention</h2>{_stories(s.get("needs_attention"))}'
body += f'<h2>Routine</h2><div class="cols">{_stories(s.get("routine"))}</div>'
body += f'<h2>The full wire — every recipe</h2>{_table(s.get("table"))}'
body += (f'<footer>The Recipe Report · generated {gen} · '
f'<a href="https://ci.commoninternet.net/">dashboard</a> · <a href="./">archive</a></footer>')
open(out_path, "w").write(_page("The Recipe Report — " + s["date"], body)) open(out_path, "w").write(_page("The Recipe Report — " + s["date"], body))
print("wrote", out_path) print("wrote", out_path)
def publish(html_path, date): 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" page = f"week-{date}.html"
data = open(html_path, "rb").read() subprocess.run(["ssh", "cc-ci", f"cat > {HOST_REPORTS}/{page}"], input=open(html_path, "rb").read(), check=True)
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 <title>/subtitle hint = date.
listing = subprocess.run(["ssh", "cc-ci", f"ls -1 {HOST_REPORTS}/week-*.html 2>/dev/null"], listing = subprocess.run(["ssh", "cc-ci", f"ls -1 {HOST_REPORTS}/week-*.html 2>/dev/null"],
capture_output=True, text=True).stdout.split() capture_output=True, text=True).stdout.split()
dates = sorted({os.path.basename(p)[5:-5] for p in listing}, reverse=True) 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) 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>' idx = _page("The Recipe Report — Archive", _mast() +
f'<div class="sub">Weekly review of Co-op Cloud recipe upgrades + CI</div>' '<div class="dateline"><span>Weekly review of Co-op Cloud recipe upgrades &amp; CI</span>'
'<span>report.ci.commoninternet.net</span></div>'
f'<ul class="idx">{lis or "<li><em>No reports yet.</em></li>"}</ul>') 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) 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)") print(f"published https://report.ci.commoninternet.net/{page} (+ index)")