diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..a15d780 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "russ-patchstory", + "owner": { "name": "Russell Smith" }, + "description": "PatchStory — turn a PR or diff into an interactive, human-readable walkthrough, with your coding agent authoring the narrative.", + "plugins": [ + { + "name": "patchstory", + "source": "./integrations/claude-code/patchstory", + "description": "Turn a pull request or local change into an interactive PR walkthrough. The agent authors the story (chapters with intent, risk, reviewer questions, verification steps); patchstory renders it as a self-contained .html with secrets redacted and opens it.", + "homepage": "https://github.com/russ/patchstory", + "repository": "https://github.com/russ/patchstory" + } + ] +} diff --git a/README.md b/README.md index b953686..51feba9 100644 --- a/README.md +++ b/README.md @@ -144,12 +144,18 @@ Commands github GitHub PR (uses `gh` if available, else public .diff) render render an existing pr-walkthrough.json serve [dir|file] serve an output folder/file on your LAN + schema print the pr-walkthrough.json JSON Schema Options - -o, --out output dir, or .html file with --single-file (default ./walkthrough) + -o, --out output dir; .html file with --single-file; + .json file (or stdout) with --scaffold (default ./walkthrough) -g, --generator none | anthropic (default none) --repo git repo to operate in (default cwd) --model model id for the anthropic generator + --scaffold emit the editable pr-walkthrough.json (the IR) instead + of rendering — for an agent or human to enrich + --emit-diff with --scaffold: also write the resolved raw diff, so the + same bytes can be passed to `render --diff` --single-file emit one self-contained .html (easy to email/attach) --redact mask secrets in the diff before generating/rendering --serve serve the result on your LAN after generating @@ -186,6 +192,48 @@ Keyboard: `j`/`k` next/prev chapter · `/` search · `e`/`c` expand/collapse all --- +## Author the story with your own AI agent + +PatchStory keeps the **story** (a JSON document) separate from the **renderer**, so you +don't need the built-in `anthropic` adapter to get an AI-written walkthrough — you can let +*your own* coding agent (Claude Code, Cursor, aider, …) author it. The agent reads the diff +in the context of the whole repo, so its narrative is usually better than a one-shot API +call, and **no API key is involved**. + +The flow is three commands: + +```bash +# 1. Scaffold a schema-valid skeleton from any source, plus the exact diff bytes. +patchstory github --scaffold -o pr-walkthrough.json --emit-diff pr.diff + +# 2. Your agent rewrites pr-walkthrough.json into a real narrative — chapter intent, +# risk, reviewer questions, verification steps. Validate against the schema anytime: +patchstory schema > pr-walkthrough.schema.json + +# 3. Render the agent's story. --redact keeps secrets out of the embedded diff. +patchstory render pr-walkthrough.json --diff pr.diff --redact --single-file -o pr.html --open +``` + +`--scaffold` works with every source command (`diff`, `commits`, `file`, `github`) and runs +the `none` heuristic to hand the agent an accurate starting point — correct `stats`, file +groupings, and `diff_hunks` line numbers — which the agent then enriches. Because +`--emit-diff` writes the *same* bytes the scaffold was computed from, the hunk line refs stay +aligned when you `render --diff` them. (`--redact` masks the embedded diff at render time; the +scaffolded IR and emitted diff are left unredacted for the agent to read.) + +A ready-to-install **Claude Code** plugin that runs exactly this flow lives in +[`integrations/claude-code/`](integrations/claude-code/): + +```text +/plugin marketplace add russ/patchstory +/plugin install patchstory@russ-patchstory +``` + +It auto-detects the source, scaffolds, has Claude author the narrative, and renders. Other +agents can follow the same three commands. + +--- + ## The walkthrough JSON (`pr-walkthrough.json`) This is the canonical intermediate representation. The renderer consumes it; AI diff --git a/integrations/claude-code/README.md b/integrations/claude-code/README.md new file mode 100644 index 0000000..caed8e6 --- /dev/null +++ b/integrations/claude-code/README.md @@ -0,0 +1,58 @@ +# PatchStory plugin for Claude Code + +A [Claude Code](https://claude.com/claude-code) plugin (one skill) that drives `patchstory` to +produce a human-readable, interactive walkthrough of a PR or local change — with **Claude +itself authoring the narrative** (the JSON IR), not the built-in heuristic or `-g anthropic` +adapter. + +It leans on three CLI primitives so it stays thin: + +- `patchstory --scaffold --emit-diff` — get a schema-valid skeleton + the exact diff +- `patchstory schema` — the IR contract, for validation +- `patchstory render --diff … --redact` — render the agent's story with secrets masked + +## What it does + +1. Resolve the source (branch's open PR, else branch-vs-base; or an explicit PR#/URL/range). +2. Scaffold a starting `pr-walkthrough.json` + capture the diff. +3. Claude rewrites the skeleton into a real narrative — chapter intent, risk, reviewer + questions, verification steps. +4. Render one self-contained `.html` (secrets redacted) and open it in the browser. + +## Install + +Prereqs on every machine: `patchstory` >= 0.1.3 on PATH (`npm i -g patchstory`, or the script +falls back to `npx -y patchstory`), plus `node` >= 20, `git`, and `gh` for PR mode. Linux or +macOS (not Windows). + +### As a plugin (recommended) + +```text +/plugin marketplace add russ/patchstory +/plugin install patchstory@russ-patchstory +``` + +Then: `/patchstory`, `/patchstory 123`, or "make a walkthrough of this PR". The marketplace +manifest must be on the repo's default branch for `marketplace add` to find it. + +### Manually (symlink the skill) + +```bash +ln -s "$(pwd)/integrations/claude-code/patchstory/skills/patchstory" ~/.claude/skills/patchstory +``` + +…or copy that directory into `~/.claude/skills/patchstory`. + +## Layout + +``` +.claude-plugin/marketplace.json # marketplace catalog (repo root) +integrations/claude-code/patchstory/ # plugin root (marketplace `source`) + .claude-plugin/plugin.json # plugin manifest + skills/patchstory/ + SKILL.md # the skill + authoring rubric + scripts/collect.sh # resolves the source, runs `patchstory scaffold` +``` + +Other agents (Cursor, aider, …) can follow the same recipe directly — see +**“Author the story with your own AI agent”** in the [top-level README](../../README.md). diff --git a/integrations/claude-code/patchstory/.claude-plugin/plugin.json b/integrations/claude-code/patchstory/.claude-plugin/plugin.json new file mode 100644 index 0000000..a65a3ec --- /dev/null +++ b/integrations/claude-code/patchstory/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "patchstory", + "description": "Turn a pull request or local change into an interactive, human-readable PR walkthrough. The agent authors the JSON story; patchstory renders it as a self-contained .html (secrets redacted) and opens it in the browser.", + "version": "0.1.0", + "author": { "name": "Russell Smith" }, + "homepage": "https://github.com/russ/patchstory", + "repository": "https://github.com/russ/patchstory", + "license": "MIT", + "keywords": ["pull-request", "code-review", "diff", "walkthrough", "agent"] +} diff --git a/integrations/claude-code/patchstory/skills/patchstory/SKILL.md b/integrations/claude-code/patchstory/skills/patchstory/SKILL.md new file mode 100644 index 0000000..9a59841 --- /dev/null +++ b/integrations/claude-code/patchstory/skills/patchstory/SKILL.md @@ -0,0 +1,170 @@ +--- +name: patchstory +description: > + Turn a pull request or local change into a human-readable, interactive walkthrough + ("PR story") with the `patchstory` CLI. Auto-detects the source — the current branch's + open PR if there is one, otherwise the branch vs its default base — or takes an explicit + PR number / PR URL / git range. The agent authors the narrative itself (chapters with + intent, risk, reviewer questions, and verification steps), then patchstory renders it as + one self-contained .html with secrets redacted and opens it in the browser. + Triggers: "/patchstory", "patchstory this", "make a walkthrough of this PR", + "tell the story of this PR", "PR walkthrough", "explain this PR for a human", + "patchstory #123", "patchstory the current branch". +--- + +# patchstory — human-readable PR walkthroughs + +[`patchstory`](https://github.com/russ/patchstory) turns a diff into a self-contained +interactive HTML walkthrough: logical **chapters**, each with intent, the relevant diff +hunks, reviewer questions, a risk level, and verification steps. + +Its defining design choice: **the story (a JSON document) is separate from the renderer**, so +an agent can author the story directly. **That is this skill's job.** You read the diff and +write `pr-walkthrough.json`; patchstory renders it. You — reading the code in context — are a +better author than a one-shot API call, and no API key is involved. (patchstory's own `none` +heuristic and `-g anthropic` adapter exist, but this skill does not use them for the +narrative.) + +Local-first: no hosted service. By default it renders one self-contained `.html` and opens it +in the browser (serving on the LAN is an alternative — see Notes). + +## Prerequisites + +- `patchstory` on PATH (`npm i -g patchstory`) — else the scripts fall back to `npx -y patchstory`. +- `node` >= 20, `git`, and `gh` (for PR mode; the public `.diff` fallback only covers public repos). +- Linux or macOS (not Windows). + +## Workflow + +### 1. Resolve the source + scaffold + +This skill ships a helper at `scripts/collect.sh` next to this SKILL.md. Resolve its absolute +path first — it works whether installed as a plugin or symlinked manually: + +```bash +COLLECT="${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/skills/patchstory/scripts/collect.sh}" +[ -f "$COLLECT" ] || COLLECT="$HOME/.claude/skills/patchstory/scripts/collect.sh" +[ -f "$COLLECT" ] || COLLECT="$(find "$HOME/.claude" -name collect.sh -path '*patchstory*' 2>/dev/null | head -1)" +``` + +Then run it from inside the target git repo. Pass nothing to auto-detect, or pass a PR number, +PR URL, range (`main...feature`), or branch name: + +```bash +bash "$COLLECT" # auto-detect (branch PR, else branch vs base) +bash "$COLLECT" 123 # PR number +bash "$COLLECT" https://github.com/org/repo/pull/123 +bash "$COLLECT" main...my-branch +``` + +(`--repo ` targets another checkout; `--out ` overrides the work-dir root, +default `~/.cache/patchstory`.) + +It runs `patchstory scaffold` and prints `WORK`, `SKELETON`, and `RAWDIFF` paths plus the +exact render command. Under the hood: + +- `$WORK/skel.json` — a schema-valid `pr-walkthrough.json` skeleton from patchstory's `none` + heuristic: accurate `source`, `title`, `stats`, `commits`, and **`diff_hunks` line numbers**. +- `$WORK/pr.diff` — the exact diff bytes the skeleton was computed from (so hunk refs stay + aligned when you `render --diff` it). + +### 2. Read the inputs + +- `$WORK/skel.json` — your starting point. The `source`/`title`/`stats`/`commits` are correct; + the chapters are heuristic — replace their prose with a real narrative. +- `$WORK/pr.diff` — the actual diff. **Read this** to understand intent. +- Need the contract? `patchstory schema` prints the canonical JSON Schema. +- **Do not paste secret literals** (tokens, keys) into your JSON — `--redact` masks the + embedded diff at render time, not your prose. + +### 3. Author `$WORK/pr-walkthrough.json` + +Write a genuinely better narrative than the heuristic — this is the whole point. Keep +`skel.json`'s `source`, `stats`, and `commits`; rewrite everything else. Aim for a reviewer +who has never seen the change: + +- **`summary`** — 2–4 sentences: what changes and *why it matters to a human*, not a file list. +- **`themes`** — a few high-level threads (e.g. "Auth", "DB migration", "Tests"). +- **`chapters`** — order them as a **reading path**, not by directory. Group related files into + one chapter when they tell one sub-story. Each chapter: + - **`intent`** — *why* this exists / what problem it solves (the most valuable field). + - **`summary`** — what the diff in this chapter does. + - **`risk_level`** — `low|medium|high`. Raise for auth, payments, migrations, money math, + deletions, or anything externally observable. + - **`review_notes`** — sharp reviewer questions. + - **`verification_steps`** — concrete things to do/check to trust it. + - **`files`** + **`diff_hunks`** — reuse the skeleton's hunk line refs; re-summarize each. +- **`reviewer_path`** — chapter `id`s in suggested reading order. +- **`start_here`** — 1–3 `{file, reason}` entries a reviewer should open first. +- Set **`"generator": "claude"`** (or your agent's name) and keep **`"version": "0.1"`**. + +Scale effort to the diff: a tiny PR may be 1–2 chapters; a large one, 5–8. Don't pad. + +### 4. Render to one self-contained `.html` and open it + +Run the command the helper printed. `render` re-validates the JSON (surfacing schema errors), +masks secrets in the embedded diff with `--redact`, and `--open` launches the default browser +(`open` on macOS, `xdg-open` on Linux — detached, returns immediately): + +```bash +patchstory render "$WORK/pr-walkthrough.json" --diff "$WORK/pr.diff" \ + --redact --single-file --out "$WORK/walkthrough.html" --open +``` + +Report the `$WORK/walkthrough.html` path so it can be re-opened or attached. If `--open` can't +reach a browser (headless / no GUI), just report the path. + +## The walkthrough JSON schema + +Authoritative copy: `patchstory schema`. Required: `version`, `title`, `summary`, `source` +(+ `source.type` ∈ `github_pr|git_diff|commit_range|diff_file`), `stats` (`files_changed`, +`additions`, `deletions` — numbers), `chapters`. Each chapter needs a **unique** `id`, `title`, +`summary`, `risk_level` (`low|medium|high`), and `files`. `diff_hunks` items need `file`, +`start_line`, `end_line` (line numbers in the **new** file). Everything else is optional. + +```jsonc +{ + "version": "0.1", + "title": "Add multi-face media review workflow", + "summary": "Introduces a creator-approval step for media where more than one face is detected, so multi-person uploads can't auto-publish.", + "generator": "claude", + "source": { "type": "github_pr", "repo": "org/repo", "pr_number": 123, "base": "main", "head": "feature/multi-face-review" }, + "stats": { "files_changed": 12, "additions": 340, "deletions": 72 }, + "themes": ["Data model & migrations", "Detection service", "Tests"], + "reviewer_path": ["face-detection", "review-state", "tests"], + "start_here": [{ "file": "app/services/face_detection_service.rb", "reason": "Core new logic; everything else supports it." }], + "chapters": [ + { + "id": "face-detection", + "title": "Detect multiple faces in uploaded media", + "summary": "Adds metadata and detection logic for multi-face media.", + "intent": "Determine whether creator approval is needed before publishing.", + "risk_level": "medium", + "files": ["app/models/media.rb", "app/services/face_detection_service.rb"], + "diff_hunks": [ + { "file": "app/models/media.rb", "start_line": 42, "end_line": 88, "summary": "Adds face_count and review_state fields." } + ], + "review_notes": [ + "Confirm single-face uploads are not accidentally blocked.", + "What happens when face detection fails or times out?" + ], + "verification_steps": [ + "Upload media with one face.", + "Upload media with multiple faces." + ] + } + ] +} +``` + +## Notes & gotchas + +- **Redaction is handled by `patchstory render --redact`** — no manual masking step. It masks + token shapes / `KEY=value` / private keys in the embedded diff. The scaffolded skeleton and + `pr.diff` are left unredacted for you to read; just don't quote secrets in your prose. +- **Want to share on the LAN instead of opening locally?** Render to a folder (`--out + "$WORK/site"`, drop `--single-file`) and then `patchstory serve "$WORK/site"` in the + background — it binds `0.0.0.0` and prints a URL other devices can open. The `--port` is a + starting hint; if taken, serve picks the next free port and prints the real one. +- **Private PRs** need `gh` (authenticated). The public `.diff` fallback is public-repos-only. +- The work dir under `~/.cache/patchstory/` persists; old runs can be deleted freely. diff --git a/integrations/claude-code/patchstory/skills/patchstory/scripts/collect.sh b/integrations/claude-code/patchstory/skills/patchstory/scripts/collect.sh new file mode 100755 index 0000000..a844c64 --- /dev/null +++ b/integrations/claude-code/patchstory/skills/patchstory/scripts/collect.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# collect.sh [] [--repo ] [--out ] +# +# Thin wrapper that turns "what change do you mean?" into a patchstory scaffold. +# It resolves a source the way a human would expect, then runs a single +# `patchstory scaffold` to produce, in a fresh work dir: +# +# - skel.json : a schema-valid pr-walkthrough.json skeleton (the `none` +# heuristic) for the agent to enrich +# - pr.diff : the exact raw diff the skeleton was computed from, to pass +# back to `patchstory render --diff` +# +# Source resolution: +# - explicit PR number / PR URL -> github +# - explicit range "a..b" / "a...b" -> diff +# - explicit branch name -> diff (default-base...branch) +# - no arg + branch has an open PR (gh) -> github +# - no arg + no PR -> diff (default-base...HEAD) +# +# Portable across Linux and macOS (GNU + BSD userland, bash >= 3.2). Not for Windows. +set -euo pipefail + +ARG="" +REPO="" +OUT="${PATCHSTORY_WORK:-$HOME/.cache/patchstory}" + +while [ $# -gt 0 ]; do + case "$1" in + --repo) REPO="${2:?--repo needs a dir}"; shift 2 ;; + --out) OUT="${2:?--out needs a dir}"; shift 2 ;; + -h|--help) sed -n '2,20p' "$0" | sed -E 's/^# ?//'; exit 0 ;; + -*) echo "unknown flag: $1" >&2; exit 2 ;; + *) if [ -z "$ARG" ]; then ARG="$1"; shift; else echo "unexpected arg: $1" >&2; exit 2; fi ;; + esac +done + +PATCHSTORY="$(command -v patchstory || true)" +[ -n "$PATCHSTORY" ] || PATCHSTORY="npx -y patchstory" + +if [ -z "$REPO" ]; then + REPO="$(git rev-parse --show-toplevel 2>/dev/null || true)" +fi +if [ -n "$REPO" ]; then cd "$REPO"; fi + +have_gh() { command -v gh >/dev/null 2>&1; } +is_pr_url() { [[ "$1" =~ ^https?://github\.com/.+/pull/[0-9]+ ]]; } + +default_base() { + local ref + ref="$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null || true)" + if [ -n "$ref" ]; then echo "${ref#refs/remotes/}"; return; fi + local b + for b in origin/main origin/master main master; do + if git rev-parse --verify --quiet "$b" >/dev/null 2>&1; then echo "$b"; return; fi + done + echo "main" +} + +# PR number -> full URL (patchstory's `github` source wants a URL). +pr_url_for() { + have_gh || { echo "gh CLI required to resolve a PR number" >&2; exit 1; } + gh pr view "$1" --json url -q .url 2>/dev/null +} + +MODE="" PR_URL="" RANGE="" SLUG="" + +if [ -z "$ARG" ]; then + if have_gh && PR_URL="$(gh pr view --json url -q .url 2>/dev/null)" && [ -n "$PR_URL" ]; then + MODE="github" + else + MODE="diff"; RANGE="$(default_base)...HEAD" + fi +elif [[ "$ARG" =~ ^[0-9]+$ ]]; then + MODE="github"; PR_URL="$(pr_url_for "$ARG")" + [ -n "$PR_URL" ] || { echo "could not resolve PR #$ARG" >&2; exit 1; } +elif is_pr_url "$ARG"; then + MODE="github"; PR_URL="$ARG" +elif [[ "$ARG" == *".."* ]]; then + MODE="diff"; RANGE="$ARG" +else + MODE="diff"; RANGE="$(default_base)...${ARG}" +fi + +if [ "$MODE" = "github" ]; then + SLUG="pr-$(printf '%s' "$PR_URL" | grep -oE '[0-9]+' | tail -1)" +else + [ -n "$REPO" ] || { echo "not in a git repo (use --repo)" >&2; exit 1; } + SLUG="$(printf '%s' "${RANGE##*..}" | tr '/ ' '--' | LC_ALL=C sed -E 's/[^A-Za-z0-9._-]//g')" +fi + +STAMP="$(date +%Y%m%d-%H%M%S)" +WORK="$OUT/${SLUG:-change}-$STAMP" +mkdir -p "$WORK" + +echo ">> mode=$MODE work=$WORK" >&2 + +if [ "$MODE" = "github" ]; then + $PATCHSTORY github "$PR_URL" --scaffold -o "$WORK/skel.json" --emit-diff "$WORK/pr.diff" >&2 +else + $PATCHSTORY diff "$RANGE" --repo "$REPO" --scaffold -o "$WORK/skel.json" --emit-diff "$WORK/pr.diff" >&2 +fi + +node -e ' + const fs = require("fs"); + const d = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + console.log("title: " + d.title); + console.log("source: " + JSON.stringify(d.source)); + console.log("stats: " + JSON.stringify(d.stats)); + console.log("commits: " + (d.commits ? d.commits.length : 0)); + console.log("skeleton chapters:"); + for (const c of d.chapters || []) console.log(" - " + c.title + " [" + (c.files || []).length + " file(s)]"); +' "$WORK/skel.json" + +cat < Walkthrough of a GitHub pull request render Render an existing pr-walkthrough.json serve [dir|file] Serve an output folder/file on your LAN + schema Print the pr-walkthrough.json JSON Schema Options: - -o, --out Output directory, or .html file with --single-file + -o, --out Output directory; .html file with --single-file; + .json file (or stdout) with --scaffold (default: ./walkthrough) -g, --generator Story generator: none | anthropic (default: none) --repo Git repo to run in (default: cwd) --model Model for the anthropic generator + --scaffold Emit the editable pr-walkthrough.json (the IR) for an + agent/human to enrich, instead of rendering a site + --emit-diff With --scaffold: also write the resolved raw diff, so + the same bytes can be passed to \`render --diff\` --single-file Emit one self-contained .html (easy to email/attach) --redact Mask secrets in the diff before generating/rendering --serve Serve the result on your LAN after generating @@ -68,6 +76,12 @@ Examples: patchstory file ./my-pr.diff --redact patchstory serve ./walkthrough --open +Author the story with your own AI agent (the IR is separate from the renderer): + patchstory github --scaffold -o pr-walkthrough.json --emit-diff pr.diff + # ...an agent edits pr-walkthrough.json into a real narrative... + patchstory render pr-walkthrough.json --diff pr.diff --redact --single-file -o pr.html --open + patchstory schema > pr-walkthrough.schema.json # validate against this + The non-AI "none" generator always works (no API key needed). With -g anthropic + ANTHROPIC_API_KEY it asks Claude to author the story (falling back to the heuristic on error). Use --redact to keep secrets out of the @@ -116,47 +130,37 @@ async function main() { return; } + // `schema` is standalone: print the canonical JSON Schema for the IR. + if (command === "schema") { + process.stdout.write(JSON.stringify(WALKTHROUGH_JSON_SCHEMA, null, 2) + "\n"); + return; + } + const generator = flagStr(flags, "generator") ?? "none"; const model = flagStr(flags, "model"); const redact = !!flags.redact; const cwd = resolve(flagStr(flags, "repo") ?? process.cwd()); const singleFile = !!flags["single-file"]; + const scaffold = !!flags.scaffold; let bundle: WalkthroughBundle; let redactedCount = 0; + let rawDiffText: string | undefined; if (command === "render") { - bundle = await renderCommand(positionals, flags); - } else { - let resolved: ResolvedSource; - const arg = positionals[1]; - switch (command) { - case "diff": - if (!arg) fail("`diff` needs a range, e.g. patchstory diff main...feature"); - resolved = resolveGitDiff(arg, cwd); - break; - case "commits": - if (!arg) fail("`commits` needs a range, e.g. patchstory commits abc..def"); - resolved = resolveCommitRange(arg, cwd); - break; - case "file": - if (!arg) fail("`file` needs a path to a .diff file"); - if (!existsSync(arg)) fail(`diff file not found: ${arg}`); - resolved = resolveDiffFile(resolve(arg)); - break; - case "github": - if (!arg) fail("`github` needs a PR URL"); - resolved = await resolveGithubPr(arg); - break; - default: - fail(`unknown command "${command}". Run patchstory --help.`); + if (scaffold) { + fail("`--scaffold` builds the IR from a source; it can't be combined with `render`."); } - - if (!resolved!.rawDiff.trim()) { + const result = await renderCommand(positionals, flags, redact); + bundle = { walkthrough: result.walkthrough, diff: result.diff }; + redactedCount = result.redactedCount; + } else { + const resolved = await resolveSource(command, positionals[1], cwd); + if (!resolved.rawDiff.trim()) { fail("the diff is empty — nothing to walk through. Check your range/input."); } - - const result = await buildWalkthrough(resolved!, { + rawDiffText = resolved.rawDiff; + const result = await buildWalkthrough(resolved, { generator, generatorOptions: { model }, redact, @@ -165,6 +169,37 @@ async function main() { redactedCount = result.redactedCount; } + // --scaffold: emit the editable IR (and optionally the diff) and stop. + if (scaffold) { + const doc = bundle.walkthrough; + doc.generated_at = nowIso(); + const json = JSON.stringify(doc, null, 2) + "\n"; + const outPath = flagStr(flags, "out"); + if (outPath) { + const p = resolve(outPath); + writeFileSync(p, json); + process.stderr.write( + `\n✓ Walkthrough IR written to ${p}\n` + + ` ${doc.chapters.length} chapters · generator: ${doc.generator ?? "none"}\n` + + ` edit it into a real narrative, then: patchstory render ${outPath} --diff \n`, + ); + } else { + process.stdout.write(json); + } + const emitDiff = flagStr(flags, "emit-diff"); + if (emitDiff && rawDiffText != null) { + const p = resolve(emitDiff); + writeFileSync(p, rawDiffText); + process.stderr.write(`✓ Raw diff written to ${p} (feed it to \`render --diff\`)\n`); + } + if (redact) { + process.stderr.write( + "🛈 --redact applies when rendering the embedded diff; the emitted IR/diff are unredacted.\n", + ); + } + return; + } + const renderOpts = { generatedAt: nowIso(), toolVersion: VERSION }; const outFlag = flagStr(flags, "out") ?? "./walkthrough"; @@ -212,11 +247,37 @@ function reportDone(bundle: WalkthroughBundle, out: string, single: boolean) { ); } +/** Resolve a source command (diff/commits/file/github) into a raw diff + metadata. */ +async function resolveSource( + command: string, + arg: string | undefined, + cwd: string, +): Promise { + switch (command) { + case "diff": + if (!arg) fail("`diff` needs a range, e.g. patchstory diff main...feature"); + return resolveGitDiff(arg, cwd); + case "commits": + if (!arg) fail("`commits` needs a range, e.g. patchstory commits abc..def"); + return resolveCommitRange(arg, cwd); + case "file": + if (!arg) fail("`file` needs a path to a .diff file"); + if (!existsSync(arg)) fail(`diff file not found: ${arg}`); + return resolveDiffFile(resolve(arg)); + case "github": + if (!arg) fail("`github` needs a PR URL"); + return resolveGithubPr(arg); + default: + fail(`unknown command "${command}". Run patchstory --help.`); + } +} + /** `render` builds a bundle from an existing walkthrough JSON (+ optional diff). */ async function renderCommand( positionals: string[], flags: ReturnType["flags"], -): Promise { + redact: boolean, +): Promise { const jsonPath = positionals[1]; if (!jsonPath) fail("`render` needs a path to pr-walkthrough.json"); if (!existsSync(jsonPath)) fail(`file not found: ${jsonPath}`); @@ -224,10 +285,18 @@ async function renderCommand( const walkthrough = parseWalkthrough(readFileSync(resolve(jsonPath), "utf8")); let diff: ParsedDiff; + let redactedCount = 0; const diffPath = flagStr(flags, "diff"); if (diffPath) { if (!existsSync(diffPath)) fail(`diff file not found: ${diffPath}`); diff = parseDiff(readFileSync(resolve(diffPath), "utf8")); + // Unlike the source commands, `render` parses the diff here — so redaction + // has to happen here too, or `--redact` would silently do nothing. + if (redact) { + const r = redactParsedDiff(diff); + diff = r.diff; + redactedCount = r.count; + } if (!walkthrough.stats || !walkthrough.stats.files_changed) { walkthrough.stats = diffStats(diff); } @@ -246,7 +315,7 @@ async function renderCommand( }; } - return { walkthrough, diff }; + return { walkthrough, diff, redactedCount }; } main().catch((err) => { diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..fd0d337 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,94 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { validateWalkthrough } from "../packages/core/src/schema.ts"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const BIN = join(ROOT, "packages/cli/dist/patchstory.mjs"); +const SAMPLE = join(ROOT, "examples/sample.diff"); + +// These exercise the built bundle, so they need `npm run build` first (CI does). +const skip = existsSync(BIN) ? false : "run `npm run build` first"; + +function run(args: string[]): string { + return execFileSync(process.execPath, [BIN, ...args], { + encoding: "utf8", + cwd: ROOT, + maxBuffer: 64 * 1024 * 1024, + }); +} + +function tmp(): string { + return mkdtempSync(join(tmpdir(), "patchstory-cli-")); +} + +test("`schema` prints the canonical JSON Schema", { skip }, () => { + const schema = JSON.parse(run(["schema"])); + assert.match(schema.$id, /pr-walkthrough/); + assert.deepEqual(schema.required, [ + "version", "title", "summary", "source", "stats", "chapters", + ]); + assert.ok(schema.properties.chapters, "schema documents chapters"); +}); + +test("`--scaffold` emits a schema-valid IR to stdout", { skip }, () => { + const doc = JSON.parse(run(["file", SAMPLE, "--scaffold"])); + const v = validateWalkthrough(doc); + assert.equal(v.valid, true, v.errors.join("; ")); + assert.equal(doc.generator, "none"); + assert.ok(doc.generated_at, "stamps generated_at"); + assert.ok(doc.chapters.length > 0); +}); + +test("`--scaffold --emit-diff` writes the IR and the exact diff bytes", { skip }, () => { + const dir = tmp(); + const jsonPath = join(dir, "ir.json"); + const diffPath = join(dir, "used.diff"); + run(["file", SAMPLE, "--scaffold", "-o", jsonPath, "--emit-diff", diffPath]); + assert.ok(existsSync(jsonPath), "wrote the IR file"); + assert.equal( + readFileSync(diffPath, "utf8"), + readFileSync(SAMPLE, "utf8"), + "emitted diff is byte-identical to the source", + ); +}); + +test("`render --redact` masks secrets in the embedded diff", { skip }, () => { + const dir = tmp(); + const diffPath = join(dir, "secret.diff"); + writeFileSync( + diffPath, + [ + "diff --git a/.env b/.env", + "index 1..2 100644", + "--- a/.env", + "+++ b/.env", + "@@ -1,2 +1,3 @@", + " KEEP=this", + "+AWS_KEY=AKIAIOSFODNN7EXAMPLE", + "+password = hunter2supersecret", + "", + ].join("\n"), + ); + const irPath = join(dir, "ir.json"); + run(["file", diffPath, "--scaffold", "-o", irPath]); + + const plain = join(dir, "plain"); + run(["render", irPath, "--diff", diffPath, "--out", plain]); + assert.match( + readFileSync(join(plain, "data.js"), "utf8"), + /AKIAIOSFODNN7EXAMPLE/, + "without --redact the secret is embedded", + ); + + const redacted = join(dir, "redacted"); + run(["render", irPath, "--diff", diffPath, "--redact", "--out", redacted]); + const data = readFileSync(join(redacted, "data.js"), "utf8"); + assert.doesNotMatch(data, /AKIAIOSFODNN7EXAMPLE|hunter2supersecret/, "secrets are masked"); + assert.match(data, /redacted/, "mask token is present"); + assert.match(data, /KEEP=this/, "benign content is preserved"); +});