PR Overview
Fetch a GitHub PR, review it through multiple specialized agents (read-only), collect any unresolved comments, and produce a self-contained HTML overview at pr-overview.html in the repo root so the user can read it in a browser.
This skill never modifies code, never commits, never pushes, and never resolves comment threads. It only produces an overview. For a workflow that also applies fixes, use pr-review-html; for one that addresses reviewer comments, use pr-review-fixer.
Two caveats to "never touches the repository": when CI has no usable test artifacts and the PR is a same-repo PR in a repository you have cloned, Phase 1b executes the branch's install scripts and tests on this machine, in a throwaway git worktree under the job directory, with your environment and credentials; and fetching the PR head writes a ref into the clone's .git (FETCH_HEAD), which touches no working-tree file and no branch.
Phase 1: Fetch the PR
Resolve which PR to look at, in this order:
- An explicit number, URL, or branch name from the user
- Otherwise the PR for the current branch via
gh pr view --json number
Pull what you need:
gh pr view <pr> -R <owner>/<repo> --json number,title,author,baseRefName,headRefName,headRefOid,isCrossRepository,headRepository,body,url,state,commits,files,createdAt
Pin the snapshot. SHA=<headRefOid> from that JSON is the commit every later step describes — the diffs, the CI lookup, the fetch, the worktree, and the diagram all use it, so a branch that moves mid-review cannot give the page data from two trees. isCrossRepository: true means a fork PR. Every gh api and gh run call in this skill carries -R <owner>/<repo> so it works without a clone, and list endpoints use --paginate.
Working directory. Every generated input — diff fragments, downloaded artifacts, the diagram, the overview JSON itself — lives in $INPUTS, outside any working tree:
INPUTS="${CLAUDE_JOB_DIR:-$(mktemp -d)}/review-inputs"; mkdir -p "$INPUTS"
Both $CLAUDE_JOB_DIR and mktemp -d yield absolute paths; never use a relative one, as the Phase 1b subshell would resolve it into the worktree.
Read the trees without checking out. Do not run gh pr checkout. The user may be on a different branch deliberately, and switching branches risks losing work. Decide whether the current directory is a clone of the PR's repository (git remote get-url origin names <owner>/<repo>):
- With a clone:
git fetch origin refs/pull/<n>/headand verifytest "$(git rev-parse FETCH_HEAD)" = "$SHA"— the pull ref exists for every PR, forks included, and fetching executes nothing. Stop if the SHAs differ (the PR moved betweengh pr viewand the fetch; re-pin and fetch again). Thengit fetch origin <baseRefName>,MERGE_BASE=$(git merge-base origin/<baseRefName> "$SHA"), and take the full diff for the agents fromgit diff "$MERGE_BASE" "$SHA". Per-file fragments (Phase 6 step 1) come fromgit diff "$MERGE_BASE" "$SHA" -- <path>. Code context beyond the diff comes fromgit show "$SHA":<path>. - Without a clone:
gh api -R <owner>/<repo> --paginate repos/<owner>/<repo>/compare/<baseRefName>...$SHAgivesmerge_base_commit.shaasMERGE_BASEand onepatchper entry infiles[]. The API omits patches for binary files and lists at most 300 files; record any file without a patch as a missing fragment rather than fabricating one. Code context comes fromgh api -R <owner>/<repo> "repos/<owner>/<repo>/contents/<path>?ref=$SHA".
Keep the PR body, author, createdAt, and url from the gh pr view JSON — these flow into Phase 6's pr_description section so the reader can see the author's framing verbatim.
Show the user which PR you're about to summarise (number, title, author, head → base, commit count, pinned SHA, fork or not) before doing the heavier work — a cheap sanity check that catches the wrong PR number early.
Phase 1b: Collect test results
Test data comes from GitHub Actions artifacts for the pinned SHA first, and from a local run in a throwaway worktree only when CI has nothing and the trust conditions below hold. Record what happened in the tests block (Phase 6): provenance.ci_state and provenance.fallback_state are separate fields, so "fork PR with expired artifacts" is two facts.
CI artifacts
gh run list -R <owner>/<repo> --commit "$SHA" --json databaseId,status,conclusion,name,url
gh api -R <owner>/<repo> --paginate repos/<owner>/<repo>/actions/runs/<id>/artifacts # name, expired, size_in_bytes
gh api -R <owner>/<repo> --paginate repos/<owner>/<repo>/actions/runs/<id>/jobs # name, conclusion, html_url
gh run download <id> -R <owner>/<repo> -n <artifact> -D "$INPUTS/artifacts/<id>/<artifact>"
Record every job's name, outcome, and URL in tests.jobs. Download only artifacts with expired: false and size_in_bytes at most 100 MB; list larger ones by name and size in tests.skipped_artifacts. Identify files by content, never by name: JUnit is XML whose root element is testsuites or testsuite; Cobertura is XML rooted at coverage; lcov starts with TN: or SF:; coverprofile starts with mode: . Copy each recognised file into $INPUTS as <run_id>-<artifact>--<basename> so two artifacts or two runs cannot collide, and reference those names from tests.junit, tests.coverage, and tests.artifacts[].
CI state is derived after sniffing, first rule that matches:
- Any completed run yielded a JUnit file →
artifacts usable; runs stillin_progressorqueuedgo intotests.pending_runsand the page notes them. - Any run
in_progressorqueued→run in progress or queued. - No runs →
no run. - At least one artifact exists across completed runs and every one is expired →
artifacts expired. - Any completed run with
conclusion: failureand zero artifacts →run failed before upload. - Otherwise →
artifacts absent(covers artifacts that contain no JUnit).
Job attribution. Artifacts belong to a run, not a job, so attribution is a convention: tokenise job and artifact names into lowercase alphanumeric runs; a file is attributed to the job whose token set is a subset of the artifact's token set, choosing the job with the most tokens; a tie leaves it attributed to the artifact. test (ubuntu) → {test, ubuntu} matches test-results-ubuntu. Put the winner in tests.artifacts[].job, or omit it.
Worktree fallback
Permitted only when the CI state is no run, run failed before upload, artifacts expired, or artifacts absent. Otherwise fallback_state is not needed (artifacts usable) or blocked by run in progress. Then two trust conditions, checked in this order and recorded as the blocked state when they fail: the PR must be a same-repo PR (isCrossRepository: false; otherwise blocked by fork PR — fork code never runs here), and the current directory must be a clone of the PR's repository (otherwise blocked by no local clone).
Choose the command as pr-review-html Phase 5 does: read ~/.claude/scripts/ecosystems.json, pick the language row covering the most changed files and the runner whose detect rule matches, then take the first tier that applies reading text only — a Makefile target whose literal recipe lines contain one of the runner's junit_flags (never make -n), a documented project test command with such a flag, or the runner's recipe with {junit}, {coverage}, {inputs} replaced by absolute paths under $INPUTS, its env exported, and its config_files written first. A missing requires binary records no_data_reason: "required tool missing"; no row or runner records runner not detected. coverage_scope is repository for the ecosystem recipe and project-configured otherwise.
Run everything in one Bash call with timeout: 600000 (the tool's maximum, covering install and tests), with WT="$CLAUDE_JOB_DIR/wt-<n>-<sha7>" (or under the same mktemp -d parent as $INPUTS when the job directory is unset):
git worktree prune
git fetch origin refs/pull/<n>/head && test "$(git rev-parse FETCH_HEAD)" = "$SHA"
git worktree add --detach "$WT" "$SHA"
(cd "$WT" && <install> && <recipe with absolute $INPUTS paths>); status=$?
python3 ~/.claude/scripts/blast_radius.py --repo "$WT" --snapshot "$SHA" --base "$MERGE_BASE" --tools --out "$INPUTS"
git worktree remove --force "$WT"; git worktree prune
echo "recipe-status=$status"
git worktree prune at the start drops only entries whose directories are gone and skips locked ones; never remove a worktree that still exists on disk. The recipe's exit status becomes run_outcome (passed or failed) and fallback_state becomes ran. blast_radius.py --tools runs inside the same call because the worktree is the only checkout this skill has and it is gone afterwards; Phase 6 then skips its own blast_radius.py invocation. All outputs are under $INPUTS, so nothing is lost when the worktree goes.
If the call times out: run git worktree remove --force "$WT"; git worktree prune separately, keep whatever JUnit XML was written, set run_outcome: "timed_out", partial: true, fallback_state: "timed out", and no_data_reason: "local run timed out" when no JUnit was written. Nothing in the user's own checkout is touched, so there is nothing to restore.
Phase 2: Fetch unresolved comments
Use the same GraphQL query as pr-review-fixer to pull every code-level thread, PR-level review, and discussion comment:
gh api graphql -f query='
query($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
reviewThreads(first: 100) {
nodes {
id isResolved url
comments(first: 50) {
nodes { id body author { login } path line createdAt url }
}
}
}
reviews(first: 50) {
nodes { id body state author { login } createdAt url }
}
comments(first: 100) {
nodes { id body author { login } createdAt url }
}
}
}
}
' -f owner=OWNER -f repo=REPO -F pr=$PR_NUM
A comment counts as a "Claude review" if either the author is claude[bot] (the upstream anthropics/claude-code-action Action) or the body contains the sentinel <!-- claude-local-review --> (emitted by the local-review agent when pr-pilot runs it locally). Treat both sources uniformly in the rules below.
Filter:
- Code-level threads: drop any thread where
isResolved: true. For each remaining thread, surface the first comment as the top-level entry and any later comments asreplies. If multiple Claude review comments exist in a thread, keep only the latest as the top-level entry. - PR-level reviews: drop reviews with empty/whitespace bodies. Drop reviews where
state == "APPROVED"and the body has no actionable feedback. For Claude reviews, keep only the most recent. - Discussion comments: drop pure acknowledgements, CI bot noise, and
pr-review-fixer's "PR Review Overview" iteration reports (matched by the<!-- pr-review-overview -->sentinel or, for legacy comments,claude[bot]author + "PR Review Overview" in the body — they're already in the GitHub UI). For Claude reviews, keep only the latest.
Each surviving item becomes an entry in Phase 6's unresolved_comments array. Preserve the comment body verbatim — don't rewrite, summarise, or "clean up" reviewer markdown.
If there are no unresolved comments, the section is simply omitted from the output. Don't fabricate one.
Phase 3: Locate the spec (if any)
Phase 4 review and Phase 5 explanation are both richer when grounded in the design intent, so look for a matching feature spec.
- Use the PR head branch name and title as hints
- Search
specs/for a folder matching that feature - If found, read requirements, design, tasks, and decision log
- If not found, skip spec-aware checks and continue
Phase 4: Launch review agents in parallel (read-only)
Spawn all agents in a single message so they run concurrently. Pass each one the full diff — they need complete context to spot cross-file issues. Each agent reports findings only; nothing is fixed.
Agent 1 — Code reuse
For each change:
- Grep for existing utilities, helpers, and similar patterns. Common locations: utility directories, shared modules, files adjacent to the changes.
- Flag any new function that duplicates existing functionality, and point at the existing one.
- Flag inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, ad-hoc type guards, custom env checks.
Agent 2 — Code quality
Look for hacky patterns:
- Redundant state (duplicates existing state, cached values that could be derived, observers that could be direct calls)
- Parameter sprawl (new params bolted onto a function instead of restructuring it)
- Copy-paste with slight variation (near-duplicates that should share an abstraction)
- Leaky abstractions (exposing internals or breaking existing boundaries)
- Stringly-typed code where existing constants, enums, or branded types would fit
Agent 3 — Efficiency
Look for waste:
- Redundant computations, repeated reads, duplicate API calls, N+1 patterns
- Independent operations run sequentially that could run in parallel
- New blocking work added to startup or per-request hot paths
- Pre-checking file/resource existence before operating (TOCTOU anti-pattern)
- Unbounded data structures, missing cleanup, listener leaks
- Reading whole files when a slice would do, loading everything when filtering for one
Agent 4 — Spec & docs (only if a spec was found)
- Implementation matches requirements and design; divergences are documented in the decision log
- README, CLAUDE.md, or related docs need updates to reflect the changes
- Tests exist for new/modified behavior and test behavior, not implementation details
Aggregate the findings. Every finding's status is "raised" in the output — there are no "fixed" entries in this skill. Skip false positives and trivial style nits.
Phase 5: Implementation explanation and insight material
Invoke the explain-like skill (Skill tool with skill="explain-like") to produce all three expertise levels — Beginner, Intermediate, Expert. Keep the explanation in memory for the HTML output. Do not write it to specs/{feature_name}/implementation.md — this skill doesn't modify the repo.
Use the explanation as a validation pass:
- Anything in the spec that can't be cleanly explained → flag as potentially incomplete
- Anything in the explanation that diverges from the design → flag the divergence
- Add a "Completeness Assessment" with what's fully implemented, partially implemented, and missing
In the same pass, extract three pieces of structured content from the diff, commit messages, PR description/body, and (if present) the decision log. This feeds Phase 6 and must be produced even when no spec exists:
- Important changes — the 3–7 commits or hunks that matter most for a reviewer. For each: a one-line title (file or area + verb), why it matters (correctness, performance, API surface, user-visible behaviour, security), and the specific line range or symbol.
- Learnings — patterns, idioms, or APIs introduced in this PR that a reader could reuse elsewhere. One short "takeaway" per item, with a pointer to the example in the diff. Skip pure mechanical changes.
- Decision rationale — for each non-trivial choice, capture the reason. Source order: (a) decision log entries that match the PR, (b) PR description, (c) commit message bodies, (d) inline code comments added in the diff, (e) inferred from the diff. Mark inferred entries explicitly as
inferred. If a decision is non-trivial but no rationale can be found anywhere, list it under "Open questions for the author" rather than inventing one.
Phase 6: Generate the overview HTML
Render {repo-root}/pr-overview.html (overwrite if it exists) using the shared renderer script. Do not hand-write the HTML — assemble a JSON description and invoke the script. The same renderer is used by pre-push-review and pr-review-html; keep template/palette changes in the script, not duplicated across skills.
Script: ~/.claude/scripts/build_review_html.py.
Step 1: Write per-file diff fragments
For each changed file, write its diff from the pinned SHA to $INPUTS/<name>.txt: git diff "$MERGE_BASE" "$SHA" -- <path> with a clone, or the compare API's files[].patch without one (a file the API gave no patch for stays a missing fragment). Keep filenames simple (e.g. diff-services-foo.txt); the JSON references them by name.
Step 1b: Baseline, blast radius, and classification
Baseline — test results for the merge base, used for new/removed tests and the overall coverage delta:
gh run list -R <owner>/<repo> --branch <baseRefName> --status success --limit 30 --json databaseId,headSha,url
The first candidate whose headSha equals $MERGE_BASE or is its ancestor wins. With a clone: git merge-base --is-ancestor <headSha> "$MERGE_BASE", treating exit status 128 (commit not present locally) as "skip this candidate". Without a clone: gh api -R <owner>/<repo> repos/<owner>/<repo>/compare/<headSha>...$MERGE_BASE with status of identical or ahead. Never use a run later than the merge base — tests added on the base branch since would show as removed. Download and sniff the winner's artifacts exactly as in Phase 1b (same size cap, same content sniffing, same <run_id>-<artifact>--<basename> naming) and reference them as baseline_junit and baseline_coverage, with baseline_provenance naming the run. A winning run with no usable artifacts ends the search: baseline_provenance: null, and new/removed tests fall back to diff-tests.json below.
Blast radius — skip this when Phase 1b's worktree run already wrote $INPUTS/diagram.json. Otherwise run without --tools (there is no checkout to run a dependency tool in):
python3 ~/.claude/scripts/blast_radius.py --repo . --snapshot "$SHA" --base "$MERGE_BASE" --out "$INPUTS" # with a clone
python3 ~/.claude/scripts/blast_radius.py --remote <owner>/<repo> --snapshot "$SHA" --base "$MERGE_BASE" --out "$INPUTS" # without one
With a clone the script reads both trees from the fetched git objects, never from the working tree, so it works for fork PRs too. --remote reads them through the trees and blobs API, capped at 500 blob calls; past the cap the dependents column is marked partial. Both forms write $INPUTS/diagram.json and $INPUTS/diff-tests.json; reference them by file name and never transcribe them into the JSON.
Classification — the change is docs-only when every changed file is documentation (.md, .rst, .adoc, anything under a docs/ directory), a README, CHANGELOG, LICENSE, CONTRIBUTING, or CODEOWNERS file with any extension, an image, a lockfile, or an editor/VCS dotfile such as .gitignore. Anything else — CI workflows, build configuration, dependency manifests, .txt files elsewhere — makes it code.
Step 2: Assemble overview.json
Schema (every top-level key is optional except repo and files — empty sections are dropped from the body and TOC):
{
"repo": {"name": "myrepo", "path": "/abs/path",
"branch": "feature/x", "remote": "origin/main"},
"title": "PR overview: #123 — Add foo",
"subtitle": "<html> PR #123 by @author · merging feature/x → main · <a href=\"<pr-url>\">view on GitHub</a>",
"metrics": [{"label": "PR", "value": "#123"},
{"label": "author", "value": "@someone"},
{"label": "head → base", "value": "feature/x → main"},
{"label": "commits", "value": "5"},
{"label": "files", "value": "12 touched"},
{"label": "lines", "value": "+820 / -94"},
{"label": "unresolved", "value": "3 comments"}],
"verdict": {"label": "Ready to merge",
"tone": "success", // success | warning | error
"detail": "<html> one-paragraph justification"},
"at_a_glance": ["<html> what this PR does in plain English", "..."],
"pr_description": { // shown verbatim — the author's framing
"author": "@someone",
"url": "https://github.com/.../pull/123",
"created_at": "2026-05-16",
"body": "raw PR body from `gh pr view --json body`, passed UNMODIFIED"
},
"explanation": { // from explain-like (Phase 5)
"beginner": "<html> What Changed / Why It Matters / Key Concepts",
"intermediate": "<html> Architecture / Patterns / Trade-offs",
"expert": "<html> Deep dive / Architecture impact / Edge cases"
},
"commits": [{"sha": "abc1234", "subject": "feat: foo", "author": "Name", "date": "2026-05-16"}],
"important_changes": [ // from Phase 5, 3-7 items
{
"title": "services/foo: new retry policy",
"file": "services/foo.go",
"why": "Why this matters for the reviewer.",
"what": "services/foo.go:88-142",
"takeaway": "What a reader can learn from this — reusable insight.",
"rationale": "Why this approach was chosen.",
"rationale_inferred": false
// OR: "rationale_unknown": true
}
],
"decisions": [
{"title": "Pin tokio version.",
"body": "<html> body, may include <code>…</code>",
"inferred": false}
],
"findings": [ // from Phase 4 — all status: "raised"
{"severity": "major", "area": "services/foo concurrency",
"finding": "...", "resolution": "Suggested approach — left for the author to decide.",
"status": "raised"}
],
"unresolved_comments": [ // from Phase 2 — verbatim reviewer text
{
"author": "@reviewer",
"type": "code", // code | review | discussion
"path": "services/foo.go", // only for type=code
"line": 88, // only for type=code
"body": "raw markdown comment body, passed UNMODIFIED",
"url": "https://github.com/.../pull/123#discussion_r1234567",
"created_at": "2026-05-16",
"replies": [ // optional; later comments in the same thread
{"author": "@author", "body": "...", "created_at": "2026-05-16"}
]
}
],
"double_check": [{"title": "Migration ordering.", "body": "<html> body"}],
"files": [
{"path": "services/foo.go", "badge": "Modified", "stat": "+140 / -22",
"diff_file": "diff-services-foo.txt"}
],
"change_classification": "code", // from Step 1b: code | docs-only
"diagram_file": "diagram.json", // written by blast_radius.py, relative to $INPUTS
"tests": { // from Phase 1b and Step 1b; present for every code change,
"provenance": { // with no_data_reason set when nothing could be collected
"source": "ci", // ci | local (local = the worktree fallback ran)
"run_ids": [123], "run_urls": ["https://github.com/.../actions/runs/123"], // CI
"timestamp": "2026-09-04T10:22:00+10:00", // local
"snapshot": {"sha": "<SHA>", "dirty": false},
"ci_state": "artifacts usable", // no run | run in progress or queued | run failed before upload |
// artifacts expired | artifacts absent | artifacts usable
"fallback_state": "not needed" // not needed | ran | blocked by fork PR | blocked by no local clone |
}, // blocked by run in progress | timed out
"baseline_provenance": {"source": "ci", "run_id": 120, // or null
"run_url": "https://github.com/.../actions/runs/120", "sha": "<headSha>"},
"coverage_scope": "project-configured", // project-configured for CI and tiers 1-2; repository for the ecosystem recipe
"run_outcome": "passed", // passed | failed | timed_out | not_run (CI source: not_run)
"partial": false, // true when a fallback timeout cut the run short
"junit": ["123-test-results-ubuntu--junit.xml"], // file names under $INPUTS
"coverage": ["123-test-results-ubuntu--coverage.out"],
"baseline_junit": ["120-test-results-ubuntu--junit.xml"],
"baseline_coverage": ["120-test-results-ubuntu--coverage.out"],
"path_map": {"strip": null, "prepend": null}, // only when suffix matching cannot resolve coverage paths
"jobs": [{"run_id": 123, "name": "test (ubuntu)", "outcome": "success", "url": "…"}],
"artifacts": [{"name": "test-results-ubuntu", "run_id": 123,
"junit": ["123-test-results-ubuntu--junit.xml"],
"coverage": ["123-test-results-ubuntu--coverage.out"],
"job": "test (ubuntu)"}], // omit job when attribution failed
"pending_runs": [{"run_id": 124, "name": "integration", "status": "in_progress", "url": "…"}],
"skipped_artifacts": [{"name": "build-output", "size_in_bytes": 412000000}],
"run_touched_files": [], // always empty here: the fallback runs in a worktree
"diff_tests_file": "diff-tests.json", // written by blast_radius.py; used when there is no baseline
"no_data_reason": null // no tests found | runner not detected | required tool missing |
}, // local run failed | local run timed out | ci
"publish_metadata": {
"title": "PR #123 — Add foo (overview)",
"repoUrl": "https://github.com/owner/repo",
"pr": 123,
"severity": "suggestions",
"summary": "1-3 sentence headline finding shown in the feed reader."
}
}
no_data_reason: "ci" tells the renderer to word the no-data card from ci_state and fallback_state (adding that the workflow must upload a JUnit XML artifact when the state is no run, artifacts absent, or artifacts expired). With change_classification: "docs-only" the Tests card, Tests section, and diagram are all omitted, whatever else is present.
Rendering contract (implemented by the script — informational, you don't enforce it):
- Pass-through HTML fields:
subtitle,at_a_glanceitems,verdict.detail, everyexplanationpanel,decisions[].body,double_check[].body. Write actual HTML. - All other fields are HTML-escaped automatically. Write plain text.
pr_description.bodyandunresolved_comments[].bodyare HTML-escaped and rendered in apre-wrapmonospace block — markdown markers (##, lists, fenced code) survive on screen as the author wrote them. Do not rewrite, trim, or summarise. Verbatim is the whole point.- Each unresolved comment renders as a warning-bordered card with a type pill (code/review/discussion), author, file:line (if code-level), date, and a "view on GitHub" link. Replies collapse into a
<details>block. - Diffs are escaped and coloured by the script's own stylesheet — no external assets. Added lines that have coverage data and zero hits carry an uncovered mark; added lines in files with no coverage data carry none.
- The three-level explanation renders as CSS-only radio-button tabs in Beginner → Intermediate → Expert order.
- Important-change cards show a magenta-bordered Takeaway callout and a cyan-bordered Rationale callout.
- Findings counts derive from the
statusfield; in this skill every finding is"raised". testsrenders a Tests card in the overview grid (pass rate, new tests, diff coverage) and a Tests section: provenance with CI links, CI state and fallback state, availability of run / JUnit / coverage / baseline as independent states, totals with the flaky count, pending runs, one row per job (or per artifact when unattributed), failed tests with messages redacted for secrets and truncated to 500 characters, new and removed tests (by identity with a baseline, by declaration name fromdiff_tests_filewithout one), a per-file diff-coverage table, the overall coverage delta when both sides have it, skipped artifacts, and any warnings. With no readable results it renders a no-data card fromno_data_reason,ci_state, andfallback_state.diagram_filerenders the Blast radius section as inline SVG before the per-file diffs: dependents, changed files, dependencies, grouped by package or directory, changed nodes linked to their diff. Test files leave the side columns, packages with more than 3 expansion-only files collapse, side columns cap at 15 nodes, and a column the script could not derive shows the reason instead. An absent or invalid file warns and omits the section.- TOC, overview cards, and section anchors are generated automatically. Empty sections vanish.
publish_metadatais emitted as a<script type="application/json" id="review-meta">block in<head>.
Step 3: Invoke the script
python3 ~/.claude/scripts/build_review_html.py \
--data "$INPUTS/overview.json" \
--output {repo-root}/pr-overview.html \
--diff-dir "$INPUTS"
Always pass --diff-dir explicitly; every file the JSON references (fragments, JUnit, coverage, baseline, diagram.json, diff-tests.json) is resolved against it. The script prints the output path on success. Surface that path to the user so they can open it in a browser.
Error handling. Missing diff fragments show as (diff fragment 'name.txt' missing) placeholders. A test, coverage, or diagram input that is missing, malformed, not UTF-8, over 50 MB, or XML with a DOCTYPE prints a warning: line naming the file, is listed in the Tests section, and the rest of the page still renders with exit status 0. Only an unreadable overview.json exits non-zero.
Severity floor. When a tests block is present and the change is not docs-only, the script's last two stderr lines are summary coverage: matched=N unmatched=N and summary tests: passed=N failed=N errored=N skipped=N flaky=N (head JUnit only, every job aggregated). Grep stderr for the summary tests: prefix. If failed or errored is non-zero: set verdict.tone to warning unless it is already error, prepend the failure count to verdict.detail (e.g. "3 failing tests — "), raise publish_metadata.severity to needs-changes unless it is already blocking, and run the script again to the same output path. When the line is absent there is no test data and no floor applies. Flaky tests and coverage values never change the verdict or severity.
When to edit the renderer vs the SKILL.md
- Edit the renderer (the
~/.claude/scripts/review_html/package;build_review_html.pyis only the command line) when you need a new card, callout colour, layout tweak, or theme adjustment. The Prism Dark palette lives inreview_html/css.py; section markup insections.py, the Tests section intests_section.py, the diagram indiagram.py. Changes there are shared withpre-push-reviewandpr-review-html;make testin the agentic-coding repo covers them. - Edit this SKILL.md when you change the JSON contract, the output location, or phase semantics specific to PR overviews.
Populating publish_metadata
Always populate this field. Mapping rules:
title: human-readable, typically the PR title with(overview)suffix.repoUrl: the PR's repo URL (fromgh pr view --json urlorgit remote get-url origin).pr: the PR number as an integer. Do not also setbranch.severity: derive from the findings and the unresolved-comments count — no findings or unresolved comments →lgtm; nits or low-stakes unresolved threads only →suggestions; major findings or substantive unresolved threads →needs-changes; blocking/security/correctness issues →blocking. Failing or errored tests floor it atneeds-changes(Step 3).summary: 1–3 sentences leading with the headline takeaway (e.g. "3 unresolved threads, all on error handling in foo.go").
Phase 7: Publish
Check whether the pulsar binary is on PATH (command -v pulsar). If it is, invoke pulsar publish <path-to-html> — the binary validates the metadata block, normalises the repo URL, moves the file into $HOME/CodeReviews/YYYY-MM/, and deletes the source. Surface the archived path in Phase 8 instead of the source path. Treat a non-zero exit code as a hard error and surface the stderr message verbatim. If pulsar is not on PATH, skip this phase silently.
Phase 8: Summary
End with a short verdict for the user: Looks good, Worth a closer look (with the top 2–3 findings), or Blocking concerns (with the must-address list). Mention the unresolved-comment count and link to the HTML output (or the archived path returned by Phase 7 if publish ran).
This skill never pushes, commits, merges, or resolves threads — surface what's there and let the user decide what to do next. If Phase 1b ran the worktree fallback, say so, and if it was blocked, say why (fork PR, no local clone, run in progress).