632 lines
27 KiB
Python
632 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""Render a PR walkthrough HTML page from a JSON data file.
|
|
|
|
Usage:
|
|
python3 build.py <data.json> [output.html]
|
|
|
|
The model writes only the JSON. This script owns the fixed shell, HTML
|
|
escaping, the dual canvas/list views, canvas node layout, GitHub file
|
|
anchors, and required-field validation. It fails loudly on a missing field,
|
|
so a walkthrough is never silently incomplete.
|
|
"""
|
|
import re
|
|
import sys
|
|
import json
|
|
import html
|
|
import hashlib
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
SHELL = HERE / "shell.html"
|
|
|
|
# ---- canvas layout constants (canvas pixels) ----
|
|
NODE_W = 340
|
|
# Node column stride (node width + gap). The gap must stay wide enough for an
|
|
# edge label pill to sit in the clear space between two adjacent group boxes:
|
|
# clear gap = COL_STRIDE - NODE_W - 2*GROUP_PAD, kept around 120px so a
|
|
# nowrap label ("passes close") reads without overlapping either box.
|
|
COL_STRIDE = 500
|
|
ROW_STRIDE = 240 # node row stride
|
|
ORIGIN_X = 40
|
|
ORIGIN_Y = 60
|
|
GROUP_PAD = 20
|
|
GROUP_TOP_PAD = 28
|
|
NODE_EST_H = 150 # estimated node height for group-box sizing
|
|
STAGE_PAD = 120
|
|
|
|
|
|
class BuildError(Exception):
|
|
pass
|
|
|
|
|
|
def esc(s):
|
|
"""Escape text for HTML body content."""
|
|
return html.escape("" if s is None else str(s), quote=False)
|
|
|
|
|
|
def esc_attr(s):
|
|
"""Escape text for an HTML attribute value."""
|
|
return html.escape("" if s is None else str(s), quote=True)
|
|
|
|
|
|
def require(cond, msg):
|
|
if not cond:
|
|
raise BuildError(msg)
|
|
|
|
|
|
def sha256_hex(s):
|
|
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def file_url(change, pr):
|
|
"""The GitHub link for a change: an explicit URL, or the diff anchor."""
|
|
if change.get("file_url"):
|
|
value = change["file_url"]
|
|
parsed = urlparse(value)
|
|
require(parsed.scheme in {"http", "https"} and bool(parsed.netloc),
|
|
"file_url must be an HTTP or HTTPS URL")
|
|
return value
|
|
return f'{pr["url"]}/files#diff-{sha256_hex(change["file"])}'
|
|
|
|
|
|
# Comment token per Shiki language, for the "// [!code ++]" marker a raw
|
|
# patch is converted into. Falls back to "//" for anything not listed.
|
|
COMMENT_TOKENS = {
|
|
"python": "#", "py": "#", "bash": "#", "sh": "#", "shell": "#",
|
|
"shellscript": "#", "zsh": "#", "fish": "#", "ruby": "#", "rb": "#",
|
|
"yaml": "#", "yml": "#", "toml": "#", "makefile": "#", "dockerfile": "#",
|
|
"r": "#", "perl": "#", "elixir": "#",
|
|
"sql": "--", "haskell": "--", "lua": "--",
|
|
"lisp": ";", "clojure": ";", "scheme": ";",
|
|
}
|
|
|
|
|
|
def patch_to_marked(patch):
|
|
"""Turn a raw unified diff into clean code plus added/removed line indices.
|
|
|
|
Each patch line drives the output: '+' becomes an added line, '-' a
|
|
removed line, and a leading space is context. Diff headers (---, +++,
|
|
@@, diff --git, index) are dropped. The +/- classification is returned as
|
|
two lists of 1-based line indices, not as inline comment markers, so a
|
|
changed line whose body contains a comment token inside a string (for
|
|
example a Rust `r#"...//..."#` literal) is still tagged correctly.
|
|
|
|
File headers (---, +++, diff , index ) only appear before the first @@ hunk,
|
|
so they are stripped only while not yet inside a hunk. Once a hunk starts,
|
|
every line is classified by its first character. This keeps content lines
|
|
whose body starts with "--" (a removed "-- comment") or "++" (an added
|
|
"++i") instead of mistaking them for headers and dropping them.
|
|
|
|
Returns (code, added, removed): the joined source, and two 1-based index
|
|
lists for the added and removed lines.
|
|
"""
|
|
out, added, removed = [], [], []
|
|
in_hunk = False
|
|
for raw in patch.splitlines():
|
|
if raw.startswith("@@"):
|
|
in_hunk = True
|
|
continue
|
|
if not in_hunk and raw.startswith(("+++", "---", "diff ", "index ")):
|
|
continue
|
|
if not raw:
|
|
out.append("")
|
|
continue
|
|
tag, body = raw[0], raw[1:]
|
|
if tag == "+":
|
|
out.append(body)
|
|
added.append(len(out))
|
|
elif tag == "-":
|
|
out.append(body)
|
|
removed.append(len(out))
|
|
elif tag == "\\":
|
|
# "\ No newline at end of file" is a git marker, not source.
|
|
continue
|
|
else:
|
|
out.append(body if tag == " " else raw)
|
|
return "\n".join(out), added, removed
|
|
|
|
|
|
# Line-risk severities that get a colored heatmap bar. "low" is accepted in
|
|
# the schema but draws no bar (it is not a concern the reviewer must see).
|
|
RISK_SEVERITIES = ("low", "medium", "high")
|
|
RISK_TINTED = ("medium", "high")
|
|
|
|
|
|
def validate_block_risk(risk, ctx):
|
|
"""Check an optional per-block "risk" object. Return it unchanged."""
|
|
require(isinstance(risk, dict), f'{ctx}: "risk" must be an object')
|
|
if risk.get("score") is not None:
|
|
s = int(risk["score"])
|
|
require(1 <= s <= 10, f'{ctx}: risk.score must be 1-10')
|
|
for j, ln in enumerate(risk.get("lines") or []):
|
|
lctx = f'{ctx}: risk.lines[{j}]'
|
|
require(ln.get("match"), f'{lctx}.match is required')
|
|
sev = (ln.get("severity") or "").lower()
|
|
require(sev in RISK_SEVERITIES,
|
|
f'{lctx}.severity must be one of {RISK_SEVERITIES}')
|
|
return risk
|
|
|
|
|
|
def block_risk_attr(risk):
|
|
"""A JSON data-risk attribute the page JS reads after Shiki renders.
|
|
|
|
Only the fields the JS needs (lines: match/severity/note) are carried;
|
|
the score/reason drive the chip, which build.py renders directly.
|
|
"""
|
|
lines = [
|
|
{"match": ln["match"],
|
|
"severity": (ln.get("severity") or "").lower(),
|
|
"note": ln.get("note", "")}
|
|
for ln in (risk.get("lines") or [])
|
|
]
|
|
if not lines:
|
|
return ""
|
|
return f" data-risk='{esc_attr(json.dumps(lines))}'"
|
|
|
|
|
|
def block_risk_chip(risk):
|
|
"""A colored risk chip for the block label row, colored client-side.
|
|
|
|
Carries data-score so initRisk's shared riskStyle colors it with the
|
|
same thresholds as the risk section and the topbar badge.
|
|
"""
|
|
if risk.get("score") is None:
|
|
return ""
|
|
score = int(risk["score"])
|
|
reason = risk.get("reason", "")
|
|
title = f' title="{esc_attr(reason)}"' if reason else ""
|
|
return (f'<span class="block-risk-chip" data-score="{esc_attr(score)}"'
|
|
f'{title}><span class="block-risk-word">Risk</span> '
|
|
f'<span class="block-risk-score">{esc(score)}</span>'
|
|
f'<span class="block-risk-level"></span></span>')
|
|
|
|
|
|
# A hand-marked diff line ends with a "[!code ++]" or "[!code --]" comment.
|
|
# The comment token before the marker is optional and any token is accepted,
|
|
# so the same regex works for every language.
|
|
_MARK_ADD = re.compile(r"\s*(?://|#|--|;)?\s*\[!code \+\+\]\s*$")
|
|
_MARK_DEL = re.compile(r"\s*(?://|#|--|;)?\s*\[!code --\]\s*$")
|
|
|
|
|
|
def parse_hand_marked(code):
|
|
"""Split hand-marked diff code into clean code plus added/removed indices.
|
|
|
|
A line ending with "[!code ++]" (added) or "[!code --]" (removed) is
|
|
stripped of its marker and its 1-based index is recorded. This removes the
|
|
marker at build time, so a marker inside a string never survives to the
|
|
page. Returns (code, added, removed).
|
|
"""
|
|
out, added, removed = [], [], []
|
|
for ln in code.splitlines():
|
|
if _MARK_ADD.search(ln):
|
|
out.append(_MARK_ADD.sub("", ln))
|
|
added.append(len(out))
|
|
elif _MARK_DEL.search(ln):
|
|
out.append(_MARK_DEL.sub("", ln))
|
|
removed.append(len(out))
|
|
else:
|
|
out.append(ln)
|
|
return "\n".join(out), added, removed
|
|
|
|
|
|
def all_added_indices(code):
|
|
"""Return the 1-based index of every line in an entirely-new block.
|
|
|
|
Used for a block that is entirely new in the PR: the agent sets
|
|
"diff": true but marks no lines, so every line reads as added. Blank lines
|
|
are included too, so the green background stays continuous across a blank
|
|
line in the middle of the excerpt instead of leaving an untinted gap.
|
|
"""
|
|
return list(range(1, len(code.splitlines()) + 1))
|
|
|
|
|
|
def diff_attrs(added, removed):
|
|
"""The data attributes the page JS reads to tint diff lines by index."""
|
|
out = ' data-diff="true"'
|
|
if added:
|
|
out += f' data-added="{",".join(str(i) for i in added)}"'
|
|
if removed:
|
|
out += f' data-removed="{",".join(str(i) for i in removed)}"'
|
|
return out
|
|
|
|
|
|
def render_block(block, ctx):
|
|
"""One code block: a Shiki excerpt, a diff, or rendered Markdown."""
|
|
if block.get("render") == "markdown":
|
|
require(block.get("code") is not None, f'{ctx}: block missing "code"')
|
|
lang = block.get("lang", "markdown")
|
|
return (f'<pre class="code-block" data-lang="{esc_attr(lang)}" '
|
|
f'data-render="markdown"><code>{esc(block["code"])}</code></pre>')
|
|
require(block.get("lang"), f'{ctx}: block missing "lang"')
|
|
added, removed = [], []
|
|
if block.get("patch") is not None:
|
|
require(block.get("code") is None,
|
|
f'{ctx}: set "patch" or "code", not both')
|
|
require(not block.get("diff"),
|
|
f'{ctx}: "patch" already implies a diff; drop "diff"')
|
|
code, added, removed = patch_to_marked(block["patch"])
|
|
is_diff = True
|
|
else:
|
|
require(block.get("code") is not None, f'{ctx}: block missing "code"')
|
|
code, is_diff = block["code"], block.get("diff")
|
|
if is_diff and "[!code " in code:
|
|
# Hand-marked lines: strip the markers and record their indices.
|
|
code, added, removed = parse_hand_marked(code)
|
|
elif is_diff:
|
|
# A diff block with no marker is a fully-added excerpt, so every
|
|
# non-blank line reads as added and the block tints green.
|
|
added = all_added_indices(code)
|
|
diff = diff_attrs(added, removed) if is_diff else ""
|
|
risk_attr = block_risk_attr(block["risk"]) if block.get("risk") else ""
|
|
return (f'<pre class="code-block" data-lang="{esc_attr(block["lang"])}"'
|
|
f'{diff}{risk_attr}><code>{esc(code)}</code></pre>')
|
|
|
|
|
|
def layout(changes):
|
|
"""Assign an id and (x, y) to each change. Return group boxes.
|
|
|
|
When changes carry a "group", each group is one column of nodes and gets
|
|
a boundary box. Otherwise the nodes fall into a 2 or 3 column grid.
|
|
"""
|
|
for i, c in enumerate(changes):
|
|
c.setdefault("id", f"n{i + 1}")
|
|
if any(c.get("group") for c in changes):
|
|
order, cols = [], {}
|
|
for c in changes:
|
|
g = c.get("group") or ""
|
|
if g not in cols:
|
|
cols[g] = []
|
|
order.append(g)
|
|
cols[g].append(c)
|
|
boxes = []
|
|
for col_idx, g in enumerate(order):
|
|
for row_idx, c in enumerate(cols[g]):
|
|
c["_x"] = ORIGIN_X + col_idx * COL_STRIDE
|
|
c["_y"] = ORIGIN_Y + row_idx * ROW_STRIDE
|
|
if g:
|
|
rows = len(cols[g])
|
|
boxes.append((
|
|
g,
|
|
ORIGIN_X + col_idx * COL_STRIDE - GROUP_PAD,
|
|
ORIGIN_Y - GROUP_TOP_PAD,
|
|
NODE_W + GROUP_PAD * 2,
|
|
(rows - 1) * ROW_STRIDE + NODE_EST_H + GROUP_PAD * 2,
|
|
))
|
|
return boxes
|
|
ncols = 3 if len(changes) > 4 else 2
|
|
for i, c in enumerate(changes):
|
|
c["_x"] = ORIGIN_X + (i % ncols) * COL_STRIDE
|
|
c["_y"] = ORIGIN_Y + (i // ncols) * ROW_STRIDE
|
|
return []
|
|
|
|
|
|
def stage_size(changes, boxes):
|
|
"""Size the stage so every node and box fits inside it."""
|
|
max_x = max([c["_x"] + NODE_W for c in changes]
|
|
+ [b[1] + b[3] for b in boxes] + [0])
|
|
max_y = max([c["_y"] + NODE_EST_H for c in changes]
|
|
+ [b[2] + b[4] for b in boxes] + [0])
|
|
return max(2400, max_x + STAGE_PAD), max(1400, max_y + STAGE_PAD)
|
|
|
|
|
|
def block_label_row(b):
|
|
"""The label line above a code block, with an optional risk chip.
|
|
|
|
Emitted when the block has a label or a risk chip; otherwise empty so a
|
|
plain block keeps its old markup.
|
|
"""
|
|
chip = block_risk_chip(b["risk"]) if b.get("risk") else ""
|
|
label = b.get("label")
|
|
if not label and not chip:
|
|
return ""
|
|
text = esc(label) if label else '<span class="block-risk-only">Code</span>'
|
|
return f'<div class="cpanel-block-label">{text}{chip}</div>'
|
|
|
|
|
|
def render_cnode(c, pr):
|
|
"""One canvas node with its hidden detail block for the panel."""
|
|
url = file_url(c, pr)
|
|
file_link = (f'<a href="{esc_attr(url)}" target="_blank" rel="noopener" '
|
|
f'class="cnode-file">{esc(c["file"])} \u2197</a>') if url else ""
|
|
blocks = "".join(
|
|
block_label_row({**b, "label": b.get("label", "Change")})
|
|
+ render_block(b, f'change {c["id"]}')
|
|
for b in c["blocks"]
|
|
)
|
|
group = c.get("group") or ""
|
|
group_attr = f' data-group="{esc_attr(group)}"' if group else ""
|
|
return (
|
|
f'<div class="cnode" data-id="{esc_attr(c["id"])}"{group_attr} '
|
|
f'style="left: {c["_x"]}px; top: {c["_y"]}px;">'
|
|
f'<div class="cnode-head"><span class="cnode-title">{esc(c["title"])}</span>'
|
|
f'{file_link}</div>'
|
|
f'<div class="cnode-sig">{esc(c.get("sig", ""))}</div>'
|
|
f'<div class="cnode-more">Click for details \u2192</div>'
|
|
f'<div class="cnode-detail" data-title="{esc_attr(c["title"])}" '
|
|
f'data-file="{esc_attr(c["file"])}" data-file-url="{esc_attr(url)}">'
|
|
f'<p class="cpanel-desc">{esc(c["why"])}</p>{blocks}</div></div>'
|
|
)
|
|
|
|
|
|
def render_article(c, pr):
|
|
"""The linear-list version of one change (plain-text fallback)."""
|
|
url = file_url(c, pr)
|
|
blocks = ""
|
|
for b in c["blocks"]:
|
|
blocks += block_label_row(b)
|
|
blocks += render_block(b, f'change {c["id"]}')
|
|
return (
|
|
'<article class="mb-9">'
|
|
f'<h3 class="font-semibold text-brand-soft mb-1">{esc(c["title"])}</h3>'
|
|
f'<a href="{esc_attr(url)}" target="_blank" rel="noopener" '
|
|
'class="inline-flex items-center gap-1 text-sm text-slate-500 '
|
|
'dark:text-slate-400 font-mono mb-2 hover:text-brand">'
|
|
f'{esc(c["file"])} <span aria-hidden="true">\u2197</span></a>'
|
|
f'<p class="mb-3">{esc(c["why"])}</p>{blocks}</article>'
|
|
)
|
|
|
|
|
|
def render_groups(boxes):
|
|
# The initial left/top/width/height are estimates. sizeGroups() in the shell
|
|
# recomputes each box from its member nodes' real heights after Shiki settles
|
|
# them, keyed on data-group, so the dashed box always wraps its nodes.
|
|
return "".join(
|
|
f'<div class="cgroup" data-group="{esc_attr(name)}" '
|
|
f'style="left: {left}px; top: {top}px; '
|
|
f'width: {w}px; height: {h}px;">'
|
|
f'<span class="cgroup-label"><span class="dot"></span>{esc(name)}</span></div>'
|
|
for name, left, top, w, h in boxes
|
|
)
|
|
|
|
|
|
def render_edges(edges, ids):
|
|
out = ""
|
|
for e in edges:
|
|
require(e.get("from") in ids, f'edge references unknown node "{e.get("from")}"')
|
|
require(e.get("to") in ids, f'edge references unknown node "{e.get("to")}"')
|
|
out += (f'<div class="cedge" data-from="{esc_attr(e["from"])}" '
|
|
f'data-to="{esc_attr(e["to"])}" '
|
|
f'data-label="{esc_attr(e.get("label", ""))}"></div>')
|
|
return out
|
|
|
|
|
|
def mermaid_box(src, extra=""):
|
|
return (f'<div class="rounded-xl border border-slate-200 dark:border-white/10 '
|
|
f'p-4 bg-slate-50/60 dark:bg-white/5{extra}">'
|
|
f'<pre class="mermaid-src"><code>{esc(src)}</code></pre></div>')
|
|
|
|
|
|
def sec_tldr(pr):
|
|
url = esc_attr(pr["url"])
|
|
return f'''<section id="tldr" class="anchor-target mb-14">
|
|
<h1 class="text-3xl font-bold tracking-tight"><a href="{url}" class="hover:text-brand">{esc(pr["title"])} <span class="text-slate-400 dark:text-slate-500 font-normal">\u2197</span></a></h1>
|
|
<div class="mt-3 flex flex-wrap gap-2 text-xs">
|
|
<span class="rounded-full bg-slate-100 dark:bg-white/10 px-2.5 py-1">{esc(pr["base"])} \u2190 {esc(pr["head"])}</span>
|
|
<span class="rounded-full bg-slate-100 dark:bg-white/10 px-2.5 py-1">{esc(pr.get("files_changed", "?"))} files</span>
|
|
<span class="rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300 px-2.5 py-1">+{esc(pr.get("added", "?"))}</span>
|
|
<span class="rounded-full bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300 px-2.5 py-1">\u2212{esc(pr.get("removed", "?"))}</span>
|
|
<a href="{url}" class="rounded-full border border-slate-300 dark:border-white/15 px-2.5 py-1 hover:text-brand">PR #{esc(pr["number"])} \u2197</a>
|
|
</div>
|
|
<p class="mt-5 text-lg text-slate-600 dark:text-slate-300">{esc(pr.get("tldr", ""))}</p>
|
|
</section>'''
|
|
|
|
|
|
def sec_why(why):
|
|
items = "".join(f'<li>{esc(w)}</li>' for w in why["what"])
|
|
return f'''<section id="why" class="anchor-target mb-14">
|
|
<h2 class="text-xl font-semibold mb-3">Why this change</h2>
|
|
<p class="mb-4">{esc(why["problem"])}</p>
|
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-2">What it does</h3>
|
|
<ul class="list-disc pl-5 space-y-1.5 marker:text-brand">{items}</ul>
|
|
</section>'''
|
|
|
|
|
|
def sec_arch(arch):
|
|
return f'''<section id="architecture" class="anchor-target mb-14">
|
|
<h2 class="text-xl font-semibold mb-3">Architecture, end to end</h2>
|
|
<p class="mb-4 text-slate-600 dark:text-slate-300">{esc(arch.get("caption", ""))}</p>
|
|
{mermaid_box(arch["mermaid"])}
|
|
</section>'''
|
|
|
|
|
|
def sec_changes(changes, edges, pr):
|
|
boxes = layout(changes)
|
|
ids = {c["id"] for c in changes}
|
|
sw, sh = stage_size(changes, boxes)
|
|
stage_inner = (f'<svg id="canvas-edges" class="absolute left-0 top-0 '
|
|
f'pointer-events-none" width="{sw}" height="{sh}" '
|
|
f'style="overflow: visible;"></svg>'
|
|
+ render_groups(boxes)
|
|
+ "".join(render_cnode(c, pr) for c in changes)
|
|
+ render_edges(edges, ids))
|
|
articles = "".join(render_article(c, pr) for c in changes)
|
|
return f'''<section id="changes" class="anchor-target mb-14">
|
|
<h2 class="text-xl font-semibold mb-2">Key code changes</h2>
|
|
<p class="mb-4 text-slate-600 dark:text-slate-300">Drag to pan. Use the + and \u2212 buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.</p>
|
|
<div id="canvas-wrap" class="canvas-full-bleed relative rounded-xl border border-slate-200 dark:border-white/10 bg-slate-50/60 dark:bg-white/5 overflow-hidden" style="height: 640px; touch-action: none;">
|
|
<div class="absolute right-3 top-3 z-20 flex gap-1">
|
|
<button data-cz="out" class="w-8 h-8 rounded-lg border border-slate-300 dark:border-white/15 bg-white/90 dark:bg-white/10 text-lg leading-none hover:bg-slate-100 dark:hover:bg-white/20">\u2212</button>
|
|
<button data-cz="in" class="w-8 h-8 rounded-lg border border-slate-300 dark:border-white/15 bg-white/90 dark:bg-white/10 text-lg leading-none hover:bg-slate-100 dark:hover:bg-white/20">+</button>
|
|
<button data-cz="fit" class="h-8 px-2 rounded-lg border border-slate-300 dark:border-white/15 bg-white/90 dark:bg-white/10 text-xs hover:bg-slate-100 dark:hover:bg-white/20">Fit</button>
|
|
<button data-cz="max" title="Maximize" aria-label="Maximize canvas" class="h-8 px-2 rounded-lg border border-slate-300 dark:border-white/15 bg-white/90 dark:bg-white/10 text-xs hover:bg-slate-100 dark:hover:bg-white/20"><span class="label-max">Maximize</span><span class="label-min" style="display:none">Close</span></button>
|
|
</div>
|
|
<p class="absolute left-3 bottom-2 z-20 text-[11px] text-slate-400 dark:text-slate-500 pointer-events-none">drag to pan \u00b7 +/\u2212 to zoom \u00b7 click a node for details</p>
|
|
<aside id="canvas-panel" class="cpanel" aria-hidden="true">
|
|
<div id="cpanel-resizer" class="cpanel-resizer" role="separator" aria-orientation="vertical" aria-label="Resize details panel"></div>
|
|
<div class="cpanel-head">
|
|
<div>
|
|
<div id="cpanel-title" class="cpanel-title"></div>
|
|
<a id="cpanel-file" class="cpanel-file" target="_blank" rel="noopener"></a>
|
|
</div>
|
|
<button id="cpanel-close" type="button" class="cpanel-close" aria-label="Close details">\u00d7</button>
|
|
</div>
|
|
<div id="cpanel-body" class="cpanel-body"></div>
|
|
</aside>
|
|
<div id="canvas-stage" class="absolute left-0 top-0 origin-top-left" style="width: {sw}px; height: {sh}px;">{stage_inner}</div>
|
|
</div>
|
|
<details class="mt-5 group">
|
|
<summary class="cursor-pointer select-none text-sm text-slate-500 dark:text-slate-400 hover:text-brand">Read the changes as a list</summary>
|
|
<div class="mt-5">{articles}</div>
|
|
</details>
|
|
</section>'''
|
|
|
|
|
|
def sec_data(data):
|
|
parts = []
|
|
if data.get("mermaid"):
|
|
parts.append(mermaid_box(data["mermaid"], extra=" mb-6"))
|
|
if data.get("fields"):
|
|
rows = "".join(
|
|
f'<tr><td class="px-4 py-2 font-mono">{esc(f.get("field", ""))}</td>'
|
|
f'<td class="px-4 py-2 font-mono">{esc(f.get("type", ""))}</td>'
|
|
f'<td class="px-4 py-2">{esc(f.get("note", ""))}</td></tr>'
|
|
for f in data["fields"]
|
|
)
|
|
parts.append(
|
|
'<div class="overflow-x-auto rounded-xl border border-slate-200 dark:border-white/10">'
|
|
'<table class="w-full text-sm"><thead class="bg-slate-100 dark:bg-white/5 text-left">'
|
|
'<tr><th class="px-4 py-2 font-semibold">Field</th>'
|
|
'<th class="px-4 py-2 font-semibold">Type</th>'
|
|
'<th class="px-4 py-2 font-semibold">Notes</th></tr></thead>'
|
|
f'<tbody class="divide-y divide-slate-200 dark:divide-white/10">{rows}</tbody></table></div>'
|
|
)
|
|
return f'''<section id="data" class="anchor-target mb-14">
|
|
<h2 class="text-xl font-semibold mb-3">Data and storage</h2>
|
|
<p class="mb-4 text-slate-600 dark:text-slate-300">{esc(data.get("caption", ""))}</p>
|
|
{"".join(parts)}
|
|
</section>'''
|
|
|
|
|
|
def sec_risk(risk):
|
|
score = int(risk["score"])
|
|
level = "Low" if score <= 3 else "Medium" if score <= 6 else "High"
|
|
reasons = "".join(f'<li>{esc(r)}</li>' for r in risk["reasons"])
|
|
return f'''<section id="risk" class="anchor-target mb-14">
|
|
<h2 class="text-xl font-semibold mb-3">Risk</h2>
|
|
<div class="rounded-xl border border-slate-200 dark:border-white/10 p-5 bg-slate-50/60 dark:bg-white/5">
|
|
<div class="flex items-baseline gap-3">
|
|
<span class="text-4xl font-bold" id="risk-score">{score}</span>
|
|
<span class="text-slate-500 dark:text-slate-400 text-lg">/ 10</span>
|
|
<span id="risk-level" class="ml-auto rounded-full px-3 py-1 text-sm font-semibold">{level}</span>
|
|
</div>
|
|
<div id="risk-meter" data-score="{score}" class="mt-4 relative">
|
|
<div class="risk-track"></div>
|
|
<div class="absolute -top-1" id="risk-knob-wrap" style="left: 0%;"><div class="risk-knob"></div></div>
|
|
<div class="mt-1 flex justify-between text-[11px] text-slate-400 dark:text-slate-500"><span>1 low</span><span>5 medium</span><span>10 high</span></div>
|
|
</div>
|
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mt-6 mb-2">Why this score</h3>
|
|
<ul class="list-disc pl-5 space-y-1.5 marker:text-brand">{reasons}</ul>
|
|
</div>
|
|
</section>'''
|
|
|
|
|
|
def sec_review(review):
|
|
tradeoffs = "".join(f'<li>{esc(t)}</li>' for t in review.get("tradeoffs", []))
|
|
focus = "".join(f'<li>{esc(f)}</li>' for f in review.get("focus", []))
|
|
return f'''<section id="review" class="anchor-target mb-8">
|
|
<h2 class="text-xl font-semibold mb-3">Trade-offs and review notes</h2>
|
|
<ul class="list-disc pl-5 space-y-1.5 marker:text-brand">{tradeoffs}</ul>
|
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mt-6 mb-2">Where to look first</h3>
|
|
<ol class="list-decimal pl-5 space-y-1.5">{focus}</ol>
|
|
</section>'''
|
|
|
|
|
|
def render_content(data):
|
|
pr = data["pr"]
|
|
parts = [sec_tldr(pr), sec_why(data["why"])]
|
|
if data.get("architecture"):
|
|
parts.append(sec_arch(data["architecture"]))
|
|
parts.append(sec_changes(data["changes"], data.get("edges", []), pr))
|
|
if data.get("data"):
|
|
parts.append(sec_data(data["data"]))
|
|
parts.append(sec_risk(data["risk"]))
|
|
parts.append(sec_review(data["review"]))
|
|
return "\n\n ".join(parts)
|
|
|
|
|
|
def require_str_list(value, ctx):
|
|
"""Require a non-empty list whose entries are all non-empty strings.
|
|
|
|
A plain string passes a truthiness check but the renderer iterates it
|
|
character by character into one <li> per letter, so the shape is checked
|
|
here to fail loudly on malformed walkthrough data.
|
|
"""
|
|
require(isinstance(value, list) and value,
|
|
f"{ctx} needs at least one item")
|
|
for k, item in enumerate(value):
|
|
require(isinstance(item, str) and item.strip(),
|
|
f"{ctx}[{k}] must be a non-empty string")
|
|
|
|
|
|
def validate(data):
|
|
require("pr" in data, 'missing "pr"')
|
|
pr = data["pr"]
|
|
for k in ("number", "title", "url", "base", "head", "repo"):
|
|
require(pr.get(k) not in (None, ""), f'pr.{k} is required')
|
|
require(data.get("why", {}).get("problem"), "why.problem is required")
|
|
require_str_list(data.get("why", {}).get("what"), "why.what")
|
|
changes = data.get("changes") or []
|
|
require(len(changes) >= 1, "changes needs at least one entry")
|
|
for i, c in enumerate(changes):
|
|
for k in ("title", "file", "why"):
|
|
require(c.get(k), f'changes[{i}].{k} is required')
|
|
require(c.get("blocks"), f'changes[{i}].blocks needs at least one block')
|
|
for j, b in enumerate(c["blocks"]):
|
|
if b.get("risk"):
|
|
validate_block_risk(b["risk"], f'changes[{i}].blocks[{j}]')
|
|
score = data.get("risk", {}).get("score")
|
|
require(score is not None, "risk.score is required")
|
|
try:
|
|
score = int(score)
|
|
except (TypeError, ValueError):
|
|
require(False, "risk.score must be a number")
|
|
require(1 <= score <= 10, "risk.score must be 1-10")
|
|
require_str_list(data.get("risk", {}).get("reasons"), "risk.reasons")
|
|
require("review" in data, 'missing "review"')
|
|
|
|
|
|
def build(data):
|
|
validate(data)
|
|
pr = data["pr"]
|
|
content = render_content(data)
|
|
shell = SHELL.read_text(encoding="utf-8")
|
|
require("{{CONTENT}}" in shell, "shell.html has no {{CONTENT}} marker")
|
|
files_url = pr.get("files_url") or f'{pr["url"]}/files'
|
|
for token, value in (
|
|
("{{PR_TITLE}}", esc(pr["title"])),
|
|
("{{PR_URL}}", esc_attr(pr["url"])),
|
|
("{{PR_FILES_URL}}", esc_attr(files_url)),
|
|
("{{PR_NUMBER}}", esc_attr(pr["number"])),
|
|
("{{REPO}}", esc_attr(pr.get("repo", ""))),
|
|
("{{RISK_SCORE}}", esc_attr(int(data["risk"]["score"]))),
|
|
):
|
|
shell = shell.replace(token, value)
|
|
shell = shell.replace("{{CONTENT}}", content)
|
|
leftover = [t for t in ("{{PR_TITLE}}", "{{PR_URL}}", "{{PR_FILES_URL}}",
|
|
"{{PR_NUMBER}}", "{{REPO}}", "{{RISK_SCORE}}",
|
|
"{{CONTENT}}")
|
|
if t in shell]
|
|
require(not leftover, f"unreplaced placeholder(s): {leftover}")
|
|
return shell
|
|
|
|
|
|
def main(argv):
|
|
if len(argv) < 2:
|
|
print(__doc__)
|
|
return 2
|
|
data = json.loads(Path(argv[1]).read_text(encoding="utf-8"))
|
|
out = Path(argv[2]) if len(argv) > 2 else Path(f'pr-{data["pr"]["number"]}.html')
|
|
try:
|
|
html_out = build(data)
|
|
except BuildError as e:
|
|
print(f"build failed: {e}", file=sys.stderr)
|
|
return 1
|
|
out.write_text(html_out, encoding="utf-8")
|
|
print(f"wrote {out} ({len(html_out.splitlines())} lines)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv))
|