fix: shorten PR walkthrough links and align shell branding (#2954)

This commit is contained in:
Carlos Florêncio
2026-08-23 13:39:07 +00:00
committed by GitHub
parent fff55f0579
commit b15bf00783
16 changed files with 389 additions and 71 deletions
@@ -380,14 +380,44 @@ def render_groups(boxes):
)
def edge_lanes(edges):
"""Assign stable slots for endpoints and repeated node pairs."""
outgoing, incoming, pairs = {}, {}, {}
for index, edge in enumerate(edges):
outgoing.setdefault(edge["from"], []).append(index)
incoming.setdefault(edge["to"], []).append(index)
pair = tuple(sorted((edge["from"], edge["to"])))
pairs.setdefault(pair, []).append(index)
lanes = []
for index, edge in enumerate(edges):
source_edges = outgoing[edge["from"]]
target_edges = incoming[edge["to"]]
pair_edges = pairs[tuple(sorted((edge["from"], edge["to"])))]
lanes.append({
"from_lane": source_edges.index(index),
"from_lanes": len(source_edges),
"to_lane": target_edges.index(index),
"to_lanes": len(target_edges),
"pair_lane": pair_edges.index(index),
"pair_lanes": len(pair_edges),
})
return lanes
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")}"')
for e, lane in zip(edges, edge_lanes(edges)):
lane_attrs = " ".join(
f'data-{name.replace("_", "-")}="{value}"'
for name, value in lane.items()
)
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>')
f'data-label="{esc_attr(e.get("label", ""))}" '
f'{lane_attrs}></div>')
return out
@@ -11,14 +11,16 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="https://kandev.ai/favicon.ico" sizes="32x32" />
<link rel="icon" href="https://kandev.ai/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="https://kandev.ai/apple-touch-icon.png" sizes="180x180" />
<title>{{PR_TITLE}} - Walkthrough</title>
<!-- External resources (all pinned, loaded from CDN).
Tailwind loads here as a classic script. The four ESM libraries below
are declared in this import map so every external URL is visible at the
top. The module script near the end imports them by their bare name
(mermaid, marked, dompurify, shiki). To change a version, edit only the
URLs here. -->
<!-- External package resources use exact-version CDN URLs. Website brand
assets intentionally use live kandev.ai URLs so new pages match the
current site branding. Tailwind loads here as a classic script. The
four ESM libraries below are declared in this import map so every
package URL is visible at the top. -->
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<script type="importmap">
{
@@ -35,15 +37,14 @@
tailwind.config = {
darkMode: 'class',
theme: { extend: { maxWidth: { content: '72rem' }, colors: {
brand: { DEFAULT: '#4f46e5', soft: '#818cf8', deep: '#3730a3' },
brand: { DEFAULT: '#6366f1', soft: '#818cf8', deep: '#4f46e5' },
} } },
};
</script>
<style>
/* Visual language shared with kandev.ai landing: Figtree for UI copy,
Geist Mono for code, indigo actions, near-black product surfaces, and
compact shadcn-style radii. The font URLs are pinned to the landing
repository commit used for this design. */
/* Visual language shared with the documentation shell: Figtree for UI
copy, Geist Mono for code, indigo actions, dark-gray surfaces, and
compact shadcn-style radii. */
@font-face {
font-family: 'Figtree'; font-style: normal; font-weight: 300 900; font-display: swap;
src: url('https://cdn.jsdelivr.net/gh/kdlbs/landing@71c3f609df07d6ff67fbe7c9213482d0c0c22c70/public/fonts/figtree/figtree-300-normal.woff2') format('woff2');
@@ -54,24 +55,25 @@
}
:root {
color-scheme: light;
--wt-bg: #ffffff;
--wt-fg: #0d0d10;
--wt-muted: #71717a;
--wt-border: rgba(13, 13, 16, .12);
--wt-surface: #f7f7f8;
--wt-bg: #f5f5f5;
--wt-fg: #0a0a0a;
--wt-muted: #666666;
--wt-border: rgba(204, 204, 204, .5);
--wt-surface: #f1f1f1;
--wt-primary: #4f46e5;
--wt-primary-soft: #818cf8;
--wt-primary-deep: #3730a3;
--wt-dark-surface: #111114;
--wt-primary-deep: #4f46e5;
--wt-dark-surface: #1e1e1e;
--wt-radius: .5rem;
}
html.dark {
color-scheme: dark;
--wt-bg: #0d0d10;
--wt-fg: #fafafa;
--wt-muted: rgba(255, 255, 255, .58);
--wt-border: rgba(255, 255, 255, .10);
--wt-surface: #111114;
--wt-bg: #121212;
--wt-fg: #ebebeb;
--wt-muted: #b3b3b3;
--wt-border: rgba(102, 102, 102, .2);
--wt-surface: #191919;
--wt-primary: #6366f1;
}
html { scroll-behavior: smooth; }
body {
@@ -80,11 +82,11 @@
letter-spacing: -.01em;
}
.wt-topbar {
background: rgba(255, 255, 255, .88); border-color: var(--wt-border);
box-shadow: 0 1px 0 rgba(13, 13, 16, .02);
background: rgba(245, 245, 245, .95); border-color: var(--wt-border);
box-shadow: 0 1px 0 rgba(0, 0, 0, .02);
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
}
html.dark .wt-topbar { background: rgba(13, 13, 16, .88); }
html.dark .wt-topbar { background: rgba(18, 18, 18, .95); }
.wt-main { line-height: 1.7; }
.wt-main h2, .wt-main h3 { letter-spacing: -.035em; }
.wt-main h2 { font-weight: 650; }
@@ -98,10 +100,11 @@
.wt-brand:hover { color: var(--wt-primary); }
.wt-brand-mark {
display: inline-flex; align-items: center; justify-content: center; width: 1.75rem; height: 1.75rem;
border-radius: .375rem; color: white; background: linear-gradient(135deg, #4f46e5, #818cf8);
box-shadow: 0 0 0 1px rgba(13, 13, 16, .08), 0 8px 18px rgba(79, 70, 229, .22);
font-size: .95rem; font-weight: 800; letter-spacing: -.08em;
overflow: hidden; border-radius: .375rem; background: var(--wt-surface);
box-shadow: 0 0 0 1px rgba(0, 0, 0, .08), 0 8px 18px rgba(99, 102, 241, .22);
}
html.dark .wt-brand-mark { box-shadow: 0 0 0 1px rgba(255, 255, 255, .1), 0 8px 18px rgba(99, 102, 241, .22); }
.wt-brand-mark img { display: block; width: 100%; height: 100%; object-fit: cover; }
.wt-brand-name { font-size: .875rem; font-weight: 650; }
.wt-brand-divider { width: 1px; height: 1rem; background: var(--wt-border); }
.wt-brand-pr { font-size: .8125rem; font-weight: 600; white-space: nowrap; }
@@ -118,6 +121,7 @@
border: 1px solid var(--wt-border); border-radius: .5rem; background: var(--wt-bg);
box-shadow: 0 12px 28px rgba(13, 13, 16, .14);
}
html.dark .wt-mobile-menu { background: var(--wt-dark-surface); }
.wt-mobile-menu a {
display: block; min-height: 2.5rem; padding: .65rem .75rem; border-radius: .375rem; color: var(--wt-muted);
font-size: .8125rem; font-weight: 550; text-decoration: none;
@@ -131,7 +135,14 @@
/* Shiki dual-theme via defaultColor:false. Light by default, dark on html.dark. */
.shiki, .shiki span { color: var(--shiki-light); background-color: var(--shiki-light-bg); }
html.dark .shiki, html.dark .shiki span { color: var(--shiki-dark) !important; background-color: var(--shiki-dark-bg) !important; }
.shiki { padding: 14px 16px; border-radius: var(--wt-radius); overflow-x: auto; font-family: 'Geist Mono', ui-monospace, monospace; font-size: .82rem; line-height: 1.55; border: 1px solid var(--wt-border); }
.shiki { padding: 14px 16px; border-radius: var(--wt-radius); overflow-x: auto; font-family: 'Geist Mono', ui-monospace, monospace; font-size: .82rem; line-height: 1.55; border: 1px solid var(--wt-border); scrollbar-width: thin; scrollbar-color: transparent transparent; }
.shiki::-webkit-scrollbar { height: 6px; }
.shiki::-webkit-scrollbar-track { background: transparent; }
.shiki::-webkit-scrollbar-thumb { background: transparent; border-radius: 999px; }
.shiki:hover, .shiki:focus-within { scrollbar-color: rgba(100,116,139,.55) transparent; }
.shiki:hover::-webkit-scrollbar-thumb, .shiki:focus-within::-webkit-scrollbar-thumb { background: rgba(100,116,139,.55); }
html.dark .shiki:hover, html.dark .shiki:focus-within { scrollbar-color: rgba(148,163,184,.5) transparent; }
html.dark .shiki:hover::-webkit-scrollbar-thumb, html.dark .shiki:focus-within::-webkit-scrollbar-thumb { background: rgba(148,163,184,.5); }
/* GitHub-style diff lines. applyLineDiff() tags changed lines with
.diff.add / .diff.remove from the data-added / data-removed indices
build.py emits. Each .line is a full-width block so the tint spans the
@@ -391,7 +402,9 @@
<div class="mx-auto max-w-content px-4 sm:px-6 h-14 flex items-center justify-between gap-3">
<a href="{{PR_URL}}" target="_blank" rel="noopener"
class="wt-brand shrink-0" title="Open the pull request on GitHub">
<span class="wt-brand-mark" aria-hidden="true">k</span>
<span class="wt-brand-mark" aria-hidden="true">
<img src="https://kandev.ai/brand/kandev-github-org.png" alt="" />
</span>
<span class="wt-brand-name hidden sm:inline">Kandev</span>
<span class="wt-brand-divider hidden sm:inline" aria-hidden="true"></span>
<span class="wt-brand-pr">PR #{{PR_NUMBER}}</span>
@@ -787,6 +800,25 @@
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const apply = () => { stage.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`; };
// Spread edges that share a node across that node's edge. The old
// midpoint route made every branch leave and enter at the same pixel,
// which stacked paths and labels on dense graphs.
function laneOffset(index, count, span, maxGap = 48) {
const total = Math.max(1, Number(count) || 1);
if (total === 1) return 0;
const slot = clamp(Number(index) || 0, 0, total - 1);
const gap = Math.min(maxGap, (span * .62) / (total - 1));
return (slot - (total - 1) / 2) * gap;
}
function cubicPoint(p0, p1, p2, p3, t) {
const u = 1 - t;
return {
x: u ** 3 * p0.x + 3 * u ** 2 * t * p1.x + 3 * u * t ** 2 * p2.x + t ** 3 * p3.x,
y: u ** 3 * p0.y + 3 * u ** 2 * t * p1.y + 3 * u * t ** 2 * p2.y + t ** 3 * p3.y,
};
}
// Draw one arrow per .cedge, from the FROM node edge to the TO node edge.
function drawEdges() {
svg.innerHTML = '';
@@ -802,30 +834,69 @@
const bcx = bx + bw / 2, bcy = by + bh / 2;
// Anchor on the near vertical or horizontal edge of each node.
const dx = bcx - acx, dy = bcy - acy;
const horizontal = Math.abs(dx) >= Math.abs(dy);
const fromLane = laneOffset(
e.dataset.fromLane, e.dataset.fromLanes, horizontal ? ah : aw,
);
const toLane = laneOffset(
e.dataset.toLane, e.dataset.toLanes, horizontal ? bh : bw,
);
// Reciprocal or parallel edges have the same endpoint geometry even
// when their source/target slots differ. Give the whole node pair a
// smaller parallel offset so those routes do not collapse into one.
const pairLane = laneOffset(
e.dataset.pairLane, e.dataset.pairLanes,
(horizontal ? Math.min(ah, bh) : Math.min(aw, bw)) * .6,
);
let x1, y1, x2, y2;
if (Math.abs(dx) >= Math.abs(dy)) {
x1 = dx >= 0 ? ax + aw : ax; y1 = acy;
x2 = dx >= 0 ? bx : bx + bw; y2 = bcy;
if (horizontal) {
x1 = dx >= 0 ? ax + aw : ax; y1 = acy + fromLane + pairLane;
x2 = dx >= 0 ? bx : bx + bw; y2 = bcy + toLane + pairLane;
} else {
x1 = acx; y1 = dy >= 0 ? ay + ah : ay;
x2 = bcx; y2 = dy >= 0 ? by : by + bh;
x1 = acx + fromLane + pairLane; y1 = dy >= 0 ? ay + ah : ay;
x2 = bcx + toLane + pairLane; y2 = dy >= 0 ? by : by + bh;
}
const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
const c1x = Math.abs(dx) >= Math.abs(dy) ? mx : x1;
const c1y = Math.abs(dx) >= Math.abs(dy) ? y1 : my;
const c2x = Math.abs(dx) >= Math.abs(dy) ? mx : x2;
const c2y = Math.abs(dx) >= Math.abs(dy) ? y2 : my;
const direction = horizontal ? (dx >= 0 ? 1 : -1) : (dy >= 0 ? 1 : -1);
const curve = Math.max(52, (horizontal ? Math.abs(x2 - x1) : Math.abs(y2 - y1)) * .42);
const p0 = { x: x1, y: y1 };
const p1 = horizontal
? { x: x1 + direction * curve, y: y1 }
: { x: x1, y: y1 + direction * curve };
const p2 = horizontal
? { x: x2 - direction * curve, y: y2 }
: { x: x2, y: y2 - direction * curve };
const p3 = { x: x2, y: y2 };
const path = document.createElementNS(SVGNS, 'path');
path.setAttribute('d', `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`);
path.setAttribute('d', `M ${p0.x} ${p0.y} C ${p1.x} ${p1.y}, ${p2.x} ${p2.y}, ${p3.x} ${p3.y}`);
path.setAttribute('class', 'cedge-path');
path.setAttribute('marker-end', 'url(#arrow)');
svg.appendChild(path);
if (e.dataset.label) {
const labelPoint = cubicPoint(p0, p1, p2, p3, .5);
const labelSpan = horizontal ? Math.min(ah, bh) : Math.min(aw, bw);
const labelFromLane = laneOffset(
e.dataset.fromLane, e.dataset.fromLanes, labelSpan, 192,
);
const labelToLane = laneOffset(
e.dataset.toLane, e.dataset.toLanes, labelSpan, 192,
);
// Prefer the endpoint with the stronger fan-out. This keeps labels
// apart when two different routes happen to share a midpoint.
const endpointLabelLane = Math.abs(labelToLane) >= Math.abs(labelFromLane)
? labelToLane : labelFromLane;
const pairLabelLane = laneOffset(
e.dataset.pairLane, e.dataset.pairLanes,
labelSpan * .6, 96,
);
const labelLane = Number(e.dataset.pairLanes) > 1
? pairLabelLane : endpointLabelLane;
if (horizontal) labelPoint.y += labelLane;
else labelPoint.x += labelLane;
const lab = document.createElement('div');
lab.className = 'cedge-label';
lab.textContent = e.dataset.label;
lab.style.left = mx + 'px';
lab.style.top = my + 'px';
lab.style.left = labelPoint.x + 'px';
lab.style.top = labelPoint.y + 'px';
stage.appendChild(lab);
}
}
@@ -1027,7 +1098,7 @@
// Lay out after Shiki settles node heights, then fit.
shikiReady.finally(() => { requestAnimationFrame(() => { sizeGroups(); drawEdges(); fit(); }); });
window.addEventListener('resize', () => { sizeGroups(); drawEdges(); });
window.addEventListener('resize', () => { sizeGroups(); drawEdges(); fit(); });
})();
</script>
</body>
@@ -333,6 +333,54 @@ class TestRenderEdges(unittest.TestCase):
self.assertIn('data-from="n1"', html)
self.assertIn('data-label="calls"', html)
def test_shared_endpoints_receive_distinct_route_lanes(self):
html = build.render_edges(
[
{"from": "n1", "to": "n2", "label": "first"},
{"from": "n1", "to": "n3", "label": "second"},
{"from": "n2", "to": "n4", "label": "third"},
{"from": "n3", "to": "n4", "label": "fourth"},
],
{"n1", "n2", "n3", "n4"},
)
self.assertIn(
'data-from-lane="0" data-from-lanes="2" '
'data-to-lane="0" data-to-lanes="1"',
html,
)
self.assertIn(
'data-from-lane="1" data-from-lanes="2" '
'data-to-lane="0" data-to-lanes="1"',
html,
)
self.assertIn(
'data-from-lane="0" data-from-lanes="1" '
'data-to-lane="0" data-to-lanes="2"',
html,
)
self.assertIn(
'data-from-lane="0" data-from-lanes="1" '
'data-to-lane="1" data-to-lanes="2"',
html,
)
def test_reciprocal_edges_receive_distinct_pair_lanes(self):
html = build.render_edges(
[
{"from": "n1", "to": "n2", "label": "forward"},
{"from": "n2", "to": "n1", "label": "backward"},
],
{"n1", "n2"},
)
self.assertIn(
'data-pair-lane="0" data-pair-lanes="2"',
html,
)
self.assertIn(
'data-pair-lane="1" data-pair-lanes="2"',
html,
)
def test_unknown_from_node_fails(self):
with self.assertRaises(build.BuildError):
build.render_edges([{"from": "nX", "to": "n1"}], {"n1"})
@@ -417,14 +465,28 @@ class TestBuild(unittest.TestCase):
'<h3 class="font-semibold text-brand-soft mb-1">'
'UniqueChangeTitle</h3>', out)
def test_shell_matches_kandev_landing_visual_language(self):
def test_shell_matches_docs_brand_and_dark_visual_language(self):
out = build.build(minimal_data())
self.assertIn("--wt-primary: #4f46e5", out)
self.assertIn("--wt-bg: #0d0d10", out)
self.assertIn("--wt-primary: #6366f1", out)
self.assertIn("--wt-bg: #121212", out)
self.assertIn("--wt-surface: #191919", out)
self.assertIn("--wt-dark-surface: #1e1e1e", out)
self.assertIn("--wt-fg: #ebebeb", out)
self.assertIn("font-family: 'Figtree'", out)
self.assertIn("font-family: 'Geist Mono'", out)
self.assertIn('class="wt-topbar sticky', out)
self.assertIn('class="wt-brand-mark"', out)
self.assertIn('href="https://kandev.ai/favicon.ico"', out)
self.assertIn('href="https://kandev.ai/icon.svg"', out)
self.assertIn(
'rel="apple-touch-icon" href="https://kandev.ai/apple-touch-icon.png"',
out,
)
self.assertIn('src="https://kandev.ai/brand/kandev-github-org.png"', out)
self.assertIn(
"brand: { DEFAULT: '#6366f1', soft: '#818cf8', deep: '#4f46e5' },",
out,
)
def test_runtime_cdn_dependencies_use_exact_versions(self):
out = build.build(minimal_data())
@@ -445,6 +507,28 @@ class TestBuild(unittest.TestCase):
self.assertIn('id="mobile-nav-data"', out)
self.assertIn("@media (max-width: 640px)", out)
def test_shell_makes_code_scrollbars_subtle_until_hover(self):
out = build.build(minimal_data())
self.assertIn("scrollbar-width: thin", out)
self.assertIn("scrollbar-color: transparent transparent", out)
self.assertIn(".shiki::-webkit-scrollbar-thumb", out)
self.assertIn(".shiki:hover::-webkit-scrollbar-thumb", out)
self.assertIn(".shiki:focus-within::-webkit-scrollbar-thumb", out)
def test_shell_uses_lanes_to_route_canvas_edges(self):
out = build.build(minimal_data())
self.assertIn("function laneOffset", out)
self.assertIn("e.dataset.fromLane", out)
self.assertIn("e.dataset.toLane", out)
self.assertIn("e.dataset.pairLane", out)
self.assertIn("const labelLane", out)
self.assertIn("const labelFromLane", out)
self.assertIn("const labelToLane", out)
self.assertIn("const endpointLabelLane", out)
self.assertIn("const pairLabelLane", out)
self.assertIn("labelPoint.x += labelLane", out)
self.assertIn("sizeGroups(); drawEdges(); fit();", out)
if __name__ == "__main__":
unittest.main()
@@ -132,6 +132,10 @@ class PRWalkthroughWorkflowContractTest(unittest.TestCase):
)
self.assertIn("--agent github-pr-walkthrough", self.generation)
self.assertIn("--file .pr-walkthrough/guidelines.md", self.generation)
self.assertIn(
"Never put shell template sentinels such as PR_TITLE or PR_URL in a code, patch, or rendered-Markdown block.",
self.generation,
)
self.assertIn("--file .pr-walkthrough/walkthrough.patch", self.generation)
self.assertIn(".pr-walkthrough/head-context/manifest.json", self.generation)
self.assertNotIn("render_pr_walkthrough", self.generation)
@@ -233,7 +237,8 @@ class PRWalkthroughWorkflowContractTest(unittest.TestCase):
def test_publication_uploads_only_html_with_expected_metadata(self) -> None:
for value in (
'OBJECT_KEY="pr/${PR_NUMBER}/${HEAD_SHA}.html"',
'SHORT_HEAD_SHA="${HEAD_SHA:0:12}"',
'OBJECT_KEY="pr/${PR_NUMBER}/${SHORT_HEAD_SHA}.html"',
"aws s3 cp",
'--content-type "text/html; charset=utf-8"',
'--cache-control "public, max-age=300"',
+3 -1
View File
@@ -171,6 +171,7 @@ jobs:
- Read the attached guidelines and walkthrough patch first.
- The checked-out repository is the trusted base commit. Inspect it only when needed to understand callers and surrounding architecture.
- Use real file paths and real code excerpts from the PR head or attached diff. Do not fabricate code.
- Never put shell template sentinels such as PR_TITLE or PR_URL in a code, patch, or rendered-Markdown block. If a source diff hunk contains a shell template sentinel, trim that hunk or use a shorter excerpt that omits the sentinel.
- Follow the pr-walkthrough skill from the trusted repository checkout.
- Write the complete JSON object to .pr-walkthrough/draft.json, then run the renderer.
- If the renderer rejects the draft, correct that file and run the renderer again.
@@ -272,7 +273,8 @@ jobs:
HTML_PATH=".r2-artifact/docs/pr-walkthrough/pr-${PR_NUMBER}.html"
test -s "$HTML_PATH"
OBJECT_KEY="pr/${PR_NUMBER}/${HEAD_SHA}.html"
SHORT_HEAD_SHA="${HEAD_SHA:0:12}"
OBJECT_KEY="pr/${PR_NUMBER}/${SHORT_HEAD_SHA}.html"
aws s3 cp "$HTML_PATH" "s3://${CLOUDFLARE_R2_BUCKET}/${OBJECT_KEY}" \
--endpoint-url "$CLOUDFLARE_R2_ENDPOINT" \
@@ -18,8 +18,10 @@ object on every push if the model cost is acceptable.
Publish walkthrough HTML files to a dedicated Cloudflare R2 bucket named
`kandev-pr-walkthroughs`, exposed through the custom domain
`walkthrough.kandev.ai`. Store objects under
`pr/<pull-request-number>/<head-sha>.html` and publish only the HTML, not the
JSON generation artifact.
`pr/<pull-request-number>/<short-head-sha>.html`, where `short-head-sha` is the
first 12 lowercase hexadecimal characters of the exact head SHA. Publish only
the HTML, not the JSON generation artifact. The URL naming rule is recorded in
[ADR-2026-08-23-pr-walkthrough-short-urls](2026-08-23-pr-walkthrough-short-urls.md).
The GitHub Actions publication job uses a bucket-scoped R2 S3-compatible
Object Read & Write credential. The generation job receives no R2 credential;
@@ -0,0 +1,46 @@
# ADR-2026-08-23-pr-walkthrough-short-urls: Use 12-character SHA prefixes for PR walkthrough URLs
**Status:** accepted
**Date:** 2026-08-23
**Area:** infra, workflow
## Context
The public PR walkthrough URL currently includes the full 40-character commit
SHA. The full value is useful for workflow identity, but it makes a reviewer
link unnecessarily long. A shorter value must still identify a PR snapshot
with a low practical collision risk.
## Decision
Keep the full lowercase commit SHA in workflow inputs and validation. Use its
first 12 lowercase hexadecimal characters for the R2 object key, public URL,
and PR-description callout. The workflow and the PR-body helper derive this
prefix from the trusted 40-character event SHA.
The fixed shell loads the current website logo and favicon from the website's
stable public asset paths. This live branding is intentional. Package URLs in
the shell remain pinned, but new pages follow the current website identity.
## Consequences
- Walkthrough links are 28 characters shorter while retaining a 48-bit prefix.
- The public URL is stable for a given PR head and remains independent of the
generated HTML bytes.
- Different commits with the same 12-character prefix can share an object
key. The prefix is long enough for the repository's active PR snapshots and
is safer than a conventional 7-character display prefix.
- Existing full-SHA objects are not renamed. New generations use the short
path, and reruns update the PR callout to that path.
- Existing pages can show a later website brand asset because the shell loads
that asset at page-load time.
## Alternatives Considered
- **Keep the full SHA:** It avoids prefix collisions, but produces unnecessarily
long public links.
- **Use a 7-character prefix:** It is familiar in Git interfaces, but has a
smaller collision space for a public object namespace.
- **Use a generated alias:** It can make links shorter, but it requires a
separate alias store and lifecycle rules that are not needed for a commit
snapshot.
+1
View File
@@ -196,3 +196,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-22-agent-owned-pr-walkthrough-rendering | [Keep PR walkthrough rendering agent owned and provider neutral](2026-08-22-agent-owned-pr-walkthrough-rendering.md) | superseded by 2026-08-22-pr-walkthrough-filesystem-runner | workflow, infra, security | 2026-08-22 |
| 2026-08-22-pr-walkthrough-filesystem-runner | [Use a Filesystem Contract for PR Walkthrough Runners](2026-08-22-pr-walkthrough-filesystem-runner.md) | accepted | workflow, infra, security | 2026-08-22 |
| 2026-08-22-pr-walkthrough-description-link | [Own a top-level PR walkthrough callout](2026-08-22-pr-walkthrough-description-link.md) | accepted | workflow, infra, security, GitHub | 2026-08-22 |
| 2026-08-23-pr-walkthrough-short-urls | [Use 12-character SHA prefixes for PR walkthrough URLs](2026-08-23-pr-walkthrough-short-urls.md) | accepted | workflow, infra | 2026-08-23 |
@@ -145,6 +145,10 @@ Wave 2:
- [x] [task-03-prompt-driven-workflow](task-03-prompt-driven-workflow.md)
Wave 3:
- [x] [task-04-public-url-and-shell-presentation](task-04-public-url-and-shell-presentation.md)
Execute tasks sequentially in the primary conversation unless the user
explicitly authorizes subagents.
@@ -159,6 +163,7 @@ explicitly authorizes subagents.
## Out of Scope
- Changes to R2 object naming, publication, retention, or PR linking.
- Generic R2 retention or merge-promotion changes beyond the 12-character URL
contract recorded in ADR-2026-08-23-pr-walkthrough-short-urls.
- Generic GitHub automation for Claude or Codex.
- Walkthrough generation for fork pull requests.
@@ -0,0 +1,43 @@
---
id: "04-public-url-and-shell-presentation"
title: "Public URL and shell presentation"
status: done
wave: 3
depends_on: ["03-prompt-driven-workflow"]
plan: "plan.md"
spec: "../../specs/pr-walkthrough/spec.md"
---
# Task 04: Public URL and Shell Presentation
Shorten hosted walkthrough links and align the generated page with the
website and documentation shell.
- **Acceptance:** The publication job and PR-body helper use the first 12
lowercase hexadecimal characters of the trusted full head SHA.
- **Acceptance:** The generated shell uses the website brand image, favicon,
and documentation dark-gray palette.
- **Acceptance:** Focused helper, workflow-contract, renderer, and responsive
page checks pass.
- **Verification:**
```text
python3 scripts/pr-walkthrough-pr-body.test.py
python3 .github/scripts/pr-walkthrough-workflow-contract_test.py
cd .agents/skills/pr-walkthrough/references && python3 -m unittest test_build
git diff --check
```
## Results
- The implementation derives a 12-character public SHA prefix from the full
event SHA and retains full-SHA validation for trusted workflow identity.
- The shell uses the website brand image and favicon links and the docs dark
tokens for its shell surfaces, text, and borders.
- `python3 scripts/pr-walkthrough-pr-body.test.py` passed (8 tests).
- `python3 .github/scripts/pr-walkthrough-workflow-contract_test.py` passed
(20 tests).
- `python3 -m unittest test_build` passed (59 tests).
- Context, renderer, harness, action-pinning, and actionlint checks passed.
- Google Chrome captured the generated page at 390x844 and 1440x1000. Both
views showed the brand mark and no topbar overflow.
+6 -3
View File
@@ -1,7 +1,8 @@
---
spec: docs/specs/pr-walkthrough/spec.md
created: 2026-08-22
status: implementing
status: superseded
superseded_by: ../pr-walkthrough-portable-runner-fix/plan.md
---
# Implementation Plan: Pull Request Walkthrough Generation
@@ -101,7 +102,9 @@ The publication contract is:
- Secret-key secret: `CLOUDFLARE_R2_SECRET_ACCESS_KEY`.
- Existing account variable: `CLOUDFLARE_ACCOUNT_ID` remains available for
endpoint construction and is not a secret.
- Object key: `pr/<pull-request-number>/<head-sha>.html`.
- Object key: `pr/<pull-request-number>/<short-head-sha>.html`, where
`short-head-sha` is the first 12 lowercase hexadecimal characters of the
exact head SHA.
- Object metadata: `Content-Type: text/html; charset=utf-8` and a short cache
lifetime that does not outlive lifecycle deletion or same-head reruns.
@@ -235,7 +238,7 @@ Completed 2026-08-22:
Wave 1:
- [x] [task-01-walkthrough-skill-renderer](task-01-walkthrough-skill-renderer.md)
- [ ] [task-02-agent-rendering-contract](task-02-agent-rendering-contract.md)
- [x] [task-02-agent-rendering-contract](task-02-agent-rendering-contract.md) (superseded by the portable filesystem contract)
Wave 2:
@@ -1,7 +1,8 @@
---
id: "02-agent-rendering-contract"
title: "Provider-neutral agent rendering contract"
status: in_progress
status: superseded
superseded_by: ../pr-walkthrough-portable-runner-fix/task-02-fixed-renderer-contract.md
wave: 1
depends_on: ["01-walkthrough-skill-renderer"]
plan: "plan.md"
@@ -26,11 +26,13 @@ custom domain as the externally visible URL.
S3-compatible API using a bucket-scoped Object Read & Write token. It does
not use the existing Pages `CLOUDFLARE_API_TOKEN`.
- **Acceptance:** The object key is
`pr/<pull-request-number>/<head-sha>.html`, with HTML content type and a
short cache policy suitable for label-triggered reruns.
`pr/<pull-request-number>/<short-head-sha>.html`, where `short-head-sha` is
the first 12 lowercase hexadecimal characters of the exact head SHA. It has
HTML content type and a short cache policy suitable for label-triggered
reruns.
- **Acceptance:** The job verifies the object with an R2 metadata request and
verifies the public URL
`https://walkthrough.kandev.ai/pr/<pull-request-number>/<head-sha>.html`
`https://walkthrough.kandev.ai/pr/<pull-request-number>/<short-head-sha>.html`
with an HTTPS GET before reporting publication success.
- **Acceptance:** The job writes the public URL to the workflow job summary
and exports it to the dependent trusted link job, but receives no GitHub
+25 -7
View File
@@ -21,7 +21,8 @@ lifecycle policy.
**Decisions:**
[ADR-2026-08-22-pr-walkthrough-r2-hosting](../../decisions/2026-08-22-pr-walkthrough-r2-hosting.md),
[ADR-2026-08-22-pr-walkthrough-filesystem-runner](../../decisions/2026-08-22-pr-walkthrough-filesystem-runner.md),
[ADR-2026-08-22-pr-walkthrough-description-link](../../decisions/2026-08-22-pr-walkthrough-description-link.md)
[ADR-2026-08-22-pr-walkthrough-description-link](../../decisions/2026-08-22-pr-walkthrough-description-link.md),
[ADR-2026-08-23-pr-walkthrough-short-urls](../../decisions/2026-08-23-pr-walkthrough-short-urls.md)
**Implementation plan:**
[Portable PR walkthrough runner fix](../../plans/pr-walkthrough-portable-runner-fix/plan.md)
@@ -43,7 +44,13 @@ lifecycle policy.
- The generated page contains a vertical reviewer story, a dark and light
theme, architecture and data diagrams when supplied, highlighted code, diff
tinting, GitHub file links, an interactive code canvas, and a linear fallback
list.
list. Canvas edges use separate endpoint and node-pair lanes, and their
labels follow the routed edge without stacking. Horizontal code scrollbars
stay subtle until a reviewer hovers over or focuses a code block. Patch and
explicitly marked diff blocks use diff colors. Plain code blocks remain
neutral because they provide context rather than a change. Its top bar uses
the website brand mark and favicon, and its dark theme uses the
documentation shell's dark-gray palette.
- The configured workflow agent generates and renders the walkthrough for a
non-draft same-repository pull request when it is opened, reopened, marked
ready for review, or updated. OpenCode is the initial runner, but the skill
@@ -64,11 +71,13 @@ lifecycle policy.
- The workflow preserves the generated JSON and HTML as CI artifacts and
uploads only the HTML to the `kandev-pr-walkthroughs` R2 bucket.
- Each published object uses the key
`pr/<pull-request-number>/<head-sha>.html` and is served at
`https://walkthrough.kandev.ai/pr/<pull-request-number>/<head-sha>.html`.
`pr/<pull-request-number>/<short-head-sha>.html`, where `short-head-sha` is
the first 12 lowercase hexadecimal characters of the exact head SHA. It is
served at
`https://walkthrough.kandev.ai/pr/<pull-request-number>/<short-head-sha>.html`.
- The workflow regenerates on `synchronize` for same-repository pull request
updates. Each generated object remains keyed by pull request number and head
SHA.
updates. Each generated object remains keyed by pull request number and the
12-character prefix of the exact head SHA.
- After public validation succeeds, a separate minimum-permission job prepends
a prominent marker-owned walkthrough callout to the pull request
description. A rerun replaces only that callout and preserves the rest of
@@ -167,7 +176,7 @@ not from merge time.
- **GIVEN** a non-draft same-repository pull request is opened, reopened, or
marked ready for review, **WHEN** the walkthrough job runs, **THEN** it
creates non-empty JSON and HTML artifacts and publishes the HTML under the
current head SHA in R2.
12-character prefix of the current head SHA in R2.
- **GIVEN** a fetched PR head, **WHEN** the workflow computes the triple-dot
diff, **THEN** the merge base exists and context preparation succeeds.
- **GIVEN** a changed file contains bounded UTF-8 text, **WHEN** context is
@@ -203,6 +212,15 @@ not from merge time.
label contains unsafe markup, **WHEN** the page is rendered, **THEN** the
source is escaped or sanitized and no executable PR-controlled markup is
inserted by the renderer.
- **GIVEN** two canvas edges share an endpoint or node pair, **WHEN** the
canvas draws them, **THEN** their paths and labels use separate lanes and do
not occupy the same route.
- **GIVEN** a code block overflows horizontally, **WHEN** it is not hovered or
focused, **THEN** its scrollbar thumb is transparent; **WHEN** it is hovered
or focused, **THEN** a subtle thumb appears.
- **GIVEN** a code block uses a patch or explicit diff marker, **WHEN** the
page renders it, **THEN** added and removed lines receive diff colors, and a
plain code block remains neutral context.
- **GIVEN** a draft pull request or an unauthorized fork event, **WHEN** the
workflow is triggered, **THEN** no walkthrough agent job runs.
- **GIVEN** a successfully generated HTML file, **WHEN** the publishing job
+5 -1
View File
@@ -12,6 +12,7 @@ from typing import Any
START_MARKER = "<!-- kandev-pr-walkthrough-start -->"
END_MARKER = "<!-- kandev-pr-walkthrough-end -->"
SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$")
PUBLIC_SHA_LENGTH = 12
UNCHANGED = 3
@@ -49,7 +50,10 @@ def validate_url(url: str, pr_number: int, head_sha: str) -> None:
raise ValueError("pull request number must be positive")
if not SHA_PATTERN.fullmatch(head_sha):
raise ValueError("head SHA must be 40 lowercase hexadecimal characters")
expected = f"https://walkthrough.kandev.ai/pr/{pr_number}/{head_sha}.html"
expected = (
"https://walkthrough.kandev.ai/"
f"pr/{pr_number}/{head_sha[:PUBLIC_SHA_LENGTH]}.html"
)
if url != expected:
raise ValueError(f"expected walkthrough URL {expected}")
+3 -2
View File
@@ -11,8 +11,9 @@ import unittest
REPO_ROOT = Path(__file__).resolve().parent.parent
SCRIPT = REPO_ROOT / "scripts" / "pr-walkthrough-pr-body"
PR_NUMBER = 2906
HEAD_SHA = "a" * 40
URL = f"https://walkthrough.kandev.ai/pr/{PR_NUMBER}/{HEAD_SHA}.html"
HEAD_SHA = "0123456789abcdef0123456789abcdef01234567"
SHORT_HEAD_SHA = HEAD_SHA[:12]
URL = f"https://walkthrough.kandev.ai/pr/{PR_NUMBER}/{SHORT_HEAD_SHA}.html"
START = "<!-- kandev-pr-walkthrough-start -->"
END = "<!-- kandev-pr-walkthrough-end -->"