From 8e5ea5ec28a0e749f5592582a0c7161dd36e4290 Mon Sep 17 00:00:00 2001 From: Brian Batt Date: Wed, 26 Aug 2026 20:23:55 -0500 Subject: [PATCH] Add github-pr-mockup skill for local GitHub-style PR previews. Lets agents render title, description, and full diffs as HTML before a PR reaches GitHub, which is especially useful for open-source contributions. Co-authored-by: Cursor --- AGENTS.md | 1 + README.md | 32 +- github-pr-mockup/README.md | 58 ++ github-pr-mockup/SKILL.md | 123 ++++ github-pr-mockup/references/examples.md | 31 + github-pr-mockup/references/script.md | 51 ++ github-pr-mockup/scripts/build_pr_mockup.py | 770 ++++++++++++++++++++ 7 files changed, 1062 insertions(+), 4 deletions(-) create mode 100644 github-pr-mockup/README.md create mode 100644 github-pr-mockup/SKILL.md create mode 100644 github-pr-mockup/references/examples.md create mode 100644 github-pr-mockup/references/script.md create mode 100644 github-pr-mockup/scripts/build_pr_mockup.py diff --git a/AGENTS.md b/AGENTS.md index 2e4ded1..cbadfa9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ This repo publishes reusable Agent Skills (`SKILL.md`), not an application. - `pr-quality/` — PR preparation and review skill - `qa-unit-testing/` — TypeScript unit / property / mutation testing skill +- `github-pr-mockup/` — Local GitHub-style PR HTML preview before push Each skill directory is self-contained. Prefer editing inside one skill at a time. diff --git a/README.md b/README.md index e1a3fd1..923381d 100644 --- a/README.md +++ b/README.md @@ -130,10 +130,11 @@ These skills are experiments in doing exactly that. # Skills -| Skill | What it teaches the agent | -| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [`pr-quality`](./pr-quality) | Prepare and review pull requests using exact diff accounting, testing evidence, blast radius analysis, implementation review, and questions designed to challenge whether the proposed solution is actually the right one. | -| [`qa-unit-testing`](./qa-unit-testing) | Build stronger TypeScript unit tests by combining example based tests, fast-check property testing, and Stryker mutation analysis to find gaps ordinary coverage metrics miss. | +| Skill | What it teaches the agent | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`pr-quality`](./pr-quality) | Prepare and review pull requests using exact diff accounting, testing evidence, blast radius analysis, implementation review, and questions designed to challenge whether the proposed solution is actually the right one. | +| [`qa-unit-testing`](./qa-unit-testing) | Build stronger TypeScript unit tests by combining example based tests, fast-check property testing, and Stryker mutation analysis to find gaps ordinary coverage metrics miss. | +| [`github-pr-mockup`](./github-pr-mockup) | Build a local GitHub-style PR HTML mockup (title, description, full diff) so you can review a contribution before it ever reaches GitHub — especially useful for open-source forks. | More skills will be added as Brian continues converting useful engineering practices into repeatable agent workflows. @@ -176,6 +177,19 @@ The goal is not maximum test count or maximum coverage. The goal is tests that catch real defects. +## `github-pr-mockup` + +Open-source contributions often need a private review pass before a PR is public. + +This skill builds a local HTML page that looks like GitHub's pull request UI: + +* PR title and Open state +* Rendered description (Conversation tab) +* Full unified diff with file sidebar (Files changed tab) +* Working-tree mode (including untracked) or committed range mode + +Use it after drafting a body with `pr-quality`, and before `gh pr create`. + --- # Installation @@ -223,6 +237,7 @@ or: ```bash gh skill install elearningplugins/brians-agent-skills pr-quality gh skill install elearningplugins/brians-agent-skills qa-unit-testing +gh skill install elearningplugins/brians-agent-skills github-pr-mockup ``` ## Manual installation @@ -293,3 +308,12 @@ This repository is where Brian is turning the engineering practices he wants an --- Created by **Brian Batt**. + +## Preview a PR before GitHub + +```text +/github-pr-mockup + +Draft a PR description for this branch, then build a GitHub-style HTML mockup +so I can review the full diff locally before opening anything. +``` diff --git a/github-pr-mockup/README.md b/github-pr-mockup/README.md new file mode 100644 index 0000000..9d0c503 --- /dev/null +++ b/github-pr-mockup/README.md @@ -0,0 +1,58 @@ +# GitHub PR Mockup Agent Skill + +Local GitHub-style pull request preview: title, rendered description, and full unified diff in an HTML page — before anything reaches GitHub. + +Especially useful for open-source contributions where you want a private review pass first. + +## Contents + +```text +github-pr-mockup/ +├── SKILL.md +├── README.md +├── references/ +│ ├── examples.md +│ └── script.md +└── scripts/ + └── build_pr_mockup.py +``` + +## Install + +Copy this directory into an Agent Skills location, e.g.: + +```text +~/.cursor/skills/github-pr-mockup/ +~/.claude/skills/github-pr-mockup/ +/.cursor/skills/github-pr-mockup/ +``` + +Or install the whole repo: + +```bash +npx skills add elearningplugins/brians-agent-skills +``` + +## Quick use + +From any git repository with local changes: + +```bash +python3 /path/to/github-pr-mockup/scripts/build_pr_mockup.py \ + --title "Area: Describe the change" \ + --body-file /tmp/pr-body.md \ + --out /tmp/pr-mockup.html \ + --open +``` + +## Suggested prompts + +```text +/github-pr-mockup +``` + +```text +Build a GitHub-style PR mockup for my current branch so I can review it before opening a PR. +``` + +Pair with `pr-quality` when you need evidence-backed PR bodies, then render with this skill. diff --git a/github-pr-mockup/SKILL.md b/github-pr-mockup/SKILL.md new file mode 100644 index 0000000..380967f --- /dev/null +++ b/github-pr-mockup/SKILL.md @@ -0,0 +1,123 @@ +--- +name: github-pr-mockup +description: >- + Builds a local GitHub-style pull request HTML mockup (title, rendered description, + Conversation/Files changed tabs, full unified diff) from the working tree or a + commit range before anything is pushed. Use when the user wants a PR preview, + GitHub mockup, pre-PR review page, local diff review UI, or to review an + open-source contribution without opening a real PR yet. +--- + +# GitHub PR Mockup + +Produce a **local HTML page that looks like a GitHub pull request** so the user can review title, description, and the full diff **before** the change reaches GitHub. + +Especially useful for open-source forks: CLA, signed commits, maintainer norms, and first impressions matter — catch description and diff issues privately. + +This skill renders a preview. It does **not** open a PR, push, or comment on GitHub unless the user separately asks. + +## When to use + +- User asks for a GitHub-like PR mockup / preview / review HTML +- Pre-flight review of an OSS contribution still on a local branch +- Validate PR title + body + full file list before `gh pr create` +- Pair with `pr-quality` after the body is drafted + +## Workflow + +### 1. Gather context + +In the target git repo: + +1. Detect base branch (`origin/main` / `origin/master` / local fallback). +2. Prefer **working-tree** mode when changes are uncommitted or include untracked files (typical pre-PR state). +3. Use **range** mode (`base...HEAD`) when commits already exist and the working tree is clean. +4. Read the repo’s PR template (`.github/PULL_REQUEST_TEMPLATE.md` etc.) and title conventions (`Area: Summary`, Conventional Commits, etc.). +5. Draft the PR **title** and **Markdown body** honestly. Prefer the `pr-quality` skill for evidence-backed bodies when preparing a real contribution. Never invent issue numbers or test results. + +### 2. Write the body to a temp file + +```bash +cat > /tmp/pr-body.md <<'EOF' +**What is this feature?** + +… + +**Which issue(s) does this PR fix?**: + +Fixes #12345 +EOF +``` + +### 3. Generate the mockup + +Run this skill’s script (resolve the path to this skill’s `scripts/` directory): + +```bash +python3 /path/to/github-pr-mockup/scripts/build_pr_mockup.py \ + --repo /path/to/target-repo \ + --mode working-tree \ + --title "Area: Short accurate title" \ + --body-file /tmp/pr-body.md \ + --out /tmp/pr-mockup.html \ + --issue-url "https://github.com/org/repo/issues/12345" \ + --evidence "+N / −M across K files · only commands you actually ran" \ + --open +``` + +Range mode after commits exist: + +```bash +python3 /path/to/github-pr-mockup/scripts/build_pr_mockup.py \ + --repo /path/to/target-repo \ + --mode range \ + --base origin/main \ + --title "Area: Short accurate title" \ + --body-file /tmp/pr-body.md \ + --out /tmp/pr-mockup.html \ + --open +``` + +### 4. Deliver to the user + +1. Open the HTML (script `--open`, or `open` / `xdg-open`). +2. Tell them the output path. +3. Point them at **Conversation** (description) and **Files changed** (full diff). +4. Do **not** create the real GitHub PR unless they ask. + +## Output location rules + +- Prefer a path **outside** the target repo (parent directory, `/tmp`, or Documents) so the mockup does not dirty `git status`. +- If writing inside the repo is unavoidable, gitignore or delete it after review — never commit the mockup unless the user explicitly wants that. + +## Script behavior (do not reimplement) + +`scripts/build_pr_mockup.py` already: + +- collects unified diff + numstat (working tree includes untracked via temporary `git add -N`, then resets); +- renders GitHub-dark UI with Conversation / Commits / Files changed tabs; +- converts a Markdown subset (headings, lists, task lists, links, inline code, hr) for the description; +- detects `owner/repo` from `origin` when `--slug` is omitted. + +Do not regenerate a one-off HTML builder in chat when this script can run. + +## Quality bar + +- Full diff of every file in scope — not a summary-only page. +- Description must match what would be pasted into GitHub (template sections filled). +- Banner must make clear this is a **local mockup**, not a real PR. +- Evidence footer: only commands/results actually run. + +## Pairing + +| Skill | Role | +| --- | --- | +| `pr-quality` | Decide readiness; exact LOC; evidence; solution review; draft the Markdown body | +| `github-pr-mockup` | Render that body + full diff as a GitHub-like page for human review | + +Typical OSS sequence: implement → verify → `pr-quality` body → **this mockup** → user reviews → signed commit / CLA / `gh pr create` when they ask. + +## Additional resources + +- Script flags: [references/script.md](references/script.md) +- Example prompts: [references/examples.md](references/examples.md) diff --git a/github-pr-mockup/references/examples.md b/github-pr-mockup/references/examples.md new file mode 100644 index 0000000..0c8284d --- /dev/null +++ b/github-pr-mockup/references/examples.md @@ -0,0 +1,31 @@ +# Example prompts + +## Open-source pre-flight + +```text +Use the github-pr-mockup skill. Draft a PR description for this branch against +upstream main using the repo template, then generate a GitHub-style HTML mockup +I can review locally before I open anything. +``` + +## Working tree (uncommitted) + +```text +Build a GitHub PR mockup for my current uncommitted changes. Title: +"Build: Soft-gate typecheck for e2e-playwright". Body should follow Grafana's +PR template and mention Fixes #129355. Open the HTML when done. +``` + +## After commits, before push + +```text +Generate a PR mockup from origin/main...HEAD with the description in /tmp/pr-body.md. +Write the HTML next to the clone, not inside it. +``` + +## Pair with pr-quality + +```text +Run pr-quality to draft the PR body with exact LOC and test evidence, then +render it with github-pr-mockup so I can review the full diff like GitHub. +``` diff --git a/github-pr-mockup/references/script.md b/github-pr-mockup/references/script.md new file mode 100644 index 0000000..9f083e6 --- /dev/null +++ b/github-pr-mockup/references/script.md @@ -0,0 +1,51 @@ +# Script reference: `build_pr_mockup.py` + +```bash +python3 scripts/build_pr_mockup.py --help +``` + +## Required + +| Flag | Meaning | +| --- | --- | +| `--title` | PR title string | +| `--out` | Output `.html` path | + +## Description + +Provide exactly one of: + +| Flag | Meaning | +| --- | --- | +| `--body-file PATH` | Markdown PR description | +| `--body "..."` | Inline Markdown (fine for short bodies) | + +## Diff source + +| Flag | Default | Meaning | +| --- | --- | --- | +| `--mode working-tree` | yes | Diff `HEAD` including unstaged + untracked | +| `--mode range` | | Diff `base...HEAD` (committed only) | +| `--base REF` | auto for range | e.g. `origin/main` | +| `--repo PATH` | cwd | Target repository root | + +Working-tree mode uses temporary `git add -N` for untracked files, then `git reset` so the index is not left dirty. + +## Display metadata + +| Flag | Meaning | +| --- | --- | +| `--author` | Display name (default `you`) | +| `--slug owner/repo` | Override remote detection | +| `--base-branch` / `--head-branch` | Branch pills in the header | +| `--pr-number` | Fake PR number (default `XXXXX`) | +| `--issue-url` | Linked in the mockup banner | +| `--commit-subject` | Commits tab line | +| `--evidence` | Plain-text footer note | +| `--open` | Open the HTML in the default browser | + +## Exit codes + +- `0` — wrote HTML +- `1` — no changes for the chosen mode +- `2` — not a git repo / could not detect base diff --git a/github-pr-mockup/scripts/build_pr_mockup.py b/github-pr-mockup/scripts/build_pr_mockup.py new file mode 100644 index 0000000..f084306 --- /dev/null +++ b/github-pr-mockup/scripts/build_pr_mockup.py @@ -0,0 +1,770 @@ +#!/usr/bin/env python3 +"""Build a GitHub-style PR review HTML mockup from a local git diff. + +Intended for reviewing title, description, and full file diffs before a PR +ever reaches GitHub — especially useful on open-source forks. +""" + +from __future__ import annotations + +import argparse +import html +import re +import subprocess +import sys +import webbrowser +from dataclasses import dataclass, field +from datetime import date +from pathlib import Path + + +def git(repo: Path, *args: str, check: bool = True) -> str: + proc = subprocess.run( + ["git", "-C", str(repo), *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if check and proc.returncode: + print(proc.stderr.strip() or proc.stdout.strip(), file=sys.stderr) + raise SystemExit(proc.returncode) + return proc.stdout + + +def detect_base(repo: Path, explicit: str | None) -> str: + if explicit: + return explicit + for candidate in ("origin/main", "origin/master", "main", "master"): + proc = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--verify", candidate], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if proc.returncode == 0: + return candidate + print("Could not detect base branch; pass --base", file=sys.stderr) + raise SystemExit(2) + + +def detect_remote_slug(repo: Path) -> str: + url = git(repo, "remote", "get-url", "origin", check=False).strip() + if not url: + return "owner/repo" + # git@github.com:owner/repo.git | https://github.com/owner/repo.git + m = re.search(r"github\.com[:/](?P[^/]+/[^/]+?)(?:\.git)?$", url) + if m: + return m.group("slug") + return "owner/repo" + + +@dataclass +class FileStat: + path: str + added: int + deleted: int + + +@dataclass +class DiffFile: + path: str + lines: list[str] = field(default_factory=list) + is_new: bool = False + is_deleted: bool = False + + +def parse_unified_diff(patch: str) -> list[DiffFile]: + files: list[DiffFile] = [] + current: DiffFile | None = None + for line in patch.splitlines(): + if line.startswith("diff --git "): + if current: + files.append(current) + m = re.search(r" b/(.+)$", line) + path = m.group(1) if m else line + current = DiffFile(path=path) + elif current is not None: + if line.startswith("new file mode"): + current.is_new = True + elif line.startswith("deleted file mode"): + current.is_deleted = True + current.lines.append(line) + if current: + files.append(current) + return files + + +def parse_numstat(text: str) -> list[FileStat]: + rows: list[FileStat] = [] + for line in text.splitlines(): + if not line.strip(): + continue + parts = line.split("\t", 2) + if len(parts) != 3: + continue + added, deleted, path = parts + if added == "-" or deleted == "-": + continue + rows.append(FileStat(path=path, added=int(added), deleted=int(deleted))) + return rows + + +def collect_working_tree_diff(repo: Path) -> tuple[str, list[FileStat]]: + """Include tracked modifications and untracked files (via intent-to-add).""" + status = git(repo, "status", "--porcelain", "-uall") + untracked: list[str] = [] + for line in status.splitlines(): + if not line: + continue + # ?? path | A path (rare) | M path etc. Untracked is "?? " + if line.startswith("?? "): + path = line[3:] + if path.endswith("/"): + # directory — expand via git ls-files --others + continue + untracked.append(path) + + # Expand untracked directories + others = git(repo, "ls-files", "--others", "--exclude-standard") + for path in others.splitlines(): + if path and path not in untracked: + untracked.append(path) + + added_n: list[str] = [] + try: + for path in untracked: + # Skip obviously huge/binary paths if needed — git will still list them + git(repo, "add", "-N", "--", path) + added_n.append(path) + patch = git(repo, "diff", "HEAD") + numstat = parse_numstat(git(repo, "diff", "--numstat", "HEAD")) + return patch, numstat + finally: + if added_n: + # Restore untracked presentation without leaving the index dirty + git(repo, "reset", "HEAD", "--", *added_n, check=False) + + +def collect_range_diff(repo: Path, base: str) -> tuple[str, list[FileStat]]: + patch = git(repo, "diff", f"{base}...HEAD") + numstat = parse_numstat(git(repo, "diff", "--numstat", f"{base}...HEAD")) + return patch, numstat + + +def light_markdown_to_html(md: str) -> str: + """Small Markdown subset for PR bodies: headings, lists, code, links, tasks, hr.""" + lines = md.replace("\r\n", "\n").split("\n") + out: list[str] = [] + in_ul = False + in_ol = False + in_code = False + code_lang = "" + code_buf: list[str] = [] + + def close_lists() -> None: + nonlocal in_ul, in_ol + if in_ul: + out.append("") + in_ul = False + if in_ol: + out.append("") + in_ol = False + + def inline(text: str) -> str: + text = html.escape(text) + text = re.sub(r"`([^`]+)`", r"\1", text) + text = re.sub( + r"\[([^\]]+)\]\((https?://[^)]+|#[^)]+)\)", + r'\1', + text, + ) + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + text = re.sub(r"(?\1", text) + return text + + i = 0 + while i < len(lines): + line = lines[i] + if line.startswith("```"): + if in_code: + out.append( + f'
{html.escape(chr(10).join(code_buf))}
' + ) + code_buf = [] + in_code = False + else: + close_lists() + in_code = True + code_lang = line[3:].strip() + i += 1 + continue + if in_code: + code_buf.append(line) + i += 1 + continue + + if line.strip() == "---": + close_lists() + out.append("
") + i += 1 + continue + + heading = re.match(r"^(#{1,3})\s+(.*)$", line) + if heading: + close_lists() + level = len(heading.group(1)) + out.append(f"{inline(heading.group(2))}") + i += 1 + continue + + task = re.match(r"^[-*]\s+\[([ xX])\]\s+(.*)$", line) + if task: + if not in_ul: + close_lists() + out.append('
    ') + in_ul = True + checked = " checked" if task.group(1).lower() == "x" else "" + out.append( + f'
  • ' + f"{inline(task.group(2))}
  • " + ) + i += 1 + continue + + bullet = re.match(r"^[-*]\s+(.*)$", line) + if bullet: + if not in_ul: + close_lists() + out.append("
      ") + in_ul = True + out.append(f"
    • {inline(bullet.group(1))}
    • ") + i += 1 + continue + + numbered = re.match(r"^\d+\.\s+(.*)$", line) + if numbered: + if not in_ol: + close_lists() + out.append("
        ") + in_ol = True + out.append(f"
      1. {inline(numbered.group(1))}
      2. ") + i += 1 + continue + + if not line.strip(): + close_lists() + i += 1 + continue + + close_lists() + # Treat **Label** alone-ish paragraphs from PR templates as strong lead-ins + out.append(f"

        {inline(line)}

        ") + i += 1 + + close_lists() + if in_code: + out.append( + f'
        {html.escape(chr(10).join(code_buf))}
        ' + ) + _ = code_lang # reserved for future highlighting + return "\n".join(out) + + +def fid_for(path: str) -> str: + return "file-" + re.sub(r"[^a-zA-Z0-9_-]", "-", path) + + +def render_diff_rows(fd: DiffFile) -> str: + rows: list[str] = [] + old_ln: int | None = None + new_ln: int | None = None + in_hunk = False + for line in fd.lines: + if line.startswith("@@"): + in_hunk = True + m = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$", line) + if m: + old_ln = int(m.group(1)) + new_ln = int(m.group(2)) + rows.append( + '' + f'{html.escape(line)}' + ) + continue + if not in_hunk: + continue + if line.startswith("+") and not line.startswith("+++"): + content = html.escape(line[1:]) + rows.append( + f'' + f'{new_ln}' + f'+{content}' + ) + if new_ln is not None: + new_ln += 1 + elif line.startswith("-") and not line.startswith("---"): + content = html.escape(line[1:]) + rows.append( + f'{old_ln}' + f'' + f'-{content}' + ) + if old_ln is not None: + old_ln += 1 + elif line.startswith("\\"): + rows.append( + f'' + f'{html.escape(line)}' + ) + else: + content = html.escape(line[1:] if line.startswith(" ") else line) + rows.append( + f'{old_ln if old_ln is not None else ""}' + f'{new_ln if new_ln is not None else ""}' + f' {content}' + ) + if old_ln is not None: + old_ln += 1 + if new_ln is not None: + new_ln += 1 + return "".join(rows) + + +def render_file_block(fd: DiffFile, stats: dict[str, FileStat]) -> str: + st = stats.get(fd.path) + add = st.added if st else 0 + dele = st.deleted if st else 0 + badge = "" + if fd.is_new: + badge = 'added' + elif fd.is_deleted: + badge = 'deleted' + fid = fid_for(fd.path) + green = min(5, max(1 if add else 0, add and 1 + add // max(1, (add + dele) // 5))) + red = min(5, max(0, dele and 1 + dele // max(1, (add + dele) // 5))) + bars = ('' * green) + ( + '' * red + ) + return f''' +
        +
        +
        + ▾ + {html.escape(fd.path)} + {badge} +
        +
        + + +{add} + −{dele} + {bars} + +
        +
        +
        + {render_diff_rows(fd)}
        +
        +
        + ''' + + +CSS = r""" +:root { + --bg: #0d1117; --canvas: #010409; --border: #30363d; --border-muted: #21262d; + --fg: #e6edf3; --fg-muted: #8b949e; --link: #2f81f7; --accent: #238636; + --btn: #21262d; --btn-border: #30363d; --file-header: #161b22; + --green-bg: rgba(46,160,67,0.15); --red-bg: rgba(248,81,73,0.15); + --hunk-bg: rgba(56,139,253,0.1); --hunk-fg: #8b949e; --num: #8b949e; +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; + font-size: 14px; line-height: 1.5; color: var(--fg); background: var(--canvas); +} +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } +code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; } +.topbar { + background: #010409; border-bottom: 1px solid var(--border); + padding: 12px 24px; display: flex; align-items: center; gap: 12px; +} +.topbar .mark { width: 32px; height: 32px; fill: var(--fg); } +.topbar .repo { font-weight: 600; } +.topbar .repo span { color: var(--fg-muted); font-weight: 400; } +.banner { + background: #1f6feb33; border-bottom: 1px solid #1f6feb66; + padding: 8px 24px; font-size: 13px; +} +.wrap { max-width: 1280px; margin: 0 auto; padding: 16px 24px 64px; } +.pr-title-row { display: flex; gap: 12px; align-items: flex-start; justify-content: space-between; } +.pr-title { font-size: 32px; font-weight: 400; margin: 0 0 8px; line-height: 1.25; } +.pr-title .num { color: var(--fg-muted); font-weight: 300; } +.pr-meta { + display: flex; flex-wrap: wrap; align-items: center; gap: 8px; + margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--border); + color: var(--fg-muted); +} +.state { + display: inline-flex; align-items: center; gap: 4px; background: var(--accent); + color: #fff; border-radius: 2em; padding: 0 10px; height: 24px; font-size: 12px; font-weight: 600; +} +.branch { + background: rgba(31,111,235,0.2); color: #2f81f7; border-radius: 2em; + padding: 0 8px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.labels { display: flex; gap: 6px; flex-wrap: wrap; margin: 0 0 12px; } +.label { + border-radius: 2em; padding: 0 10px; font-size: 12px; font-weight: 500; + line-height: 22px; border: 1px solid #9e6a03; background: #3d2c00; color: #d29922; +} +.tabs { + display: flex; border-bottom: 1px solid var(--border); margin-bottom: 16px; overflow-x: auto; +} +.tab { + appearance: none; background: transparent; border: 0; color: var(--fg-muted); + padding: 8px 16px; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; + font-size: 14px; display: inline-flex; align-items: center; gap: 6px; +} +.tab:hover { color: var(--fg); } +.tab.active { color: var(--fg); border-bottom-color: #f78166; font-weight: 600; } +.tab .counter { + background: var(--btn); border: 1px solid var(--border); border-radius: 2em; + padding: 0 6px; font-size: 12px; min-width: 20px; text-align: center; +} +.panel { display: none; } +.panel.active { display: block; } +.timeline { display: grid; grid-template-columns: 40px 1fr; gap: 8px 12px; } +.avatar { + width: 40px; height: 40px; border-radius: 50%; + background: linear-gradient(135deg, #238636, #1f6feb); + display: flex; align-items: center; justify-content: center; + color: #fff; font-weight: 700; font-size: 14px; +} +.comment { + border: 1px solid var(--border); border-radius: 6px; background: var(--bg); overflow: hidden; +} +.comment-header { + background: var(--file-header); border-bottom: 1px solid var(--border); + padding: 8px 16px; color: var(--fg-muted); font-size: 13px; +} +.comment-header strong { color: var(--fg); } +.comment-body { padding: 16px; } +.comment-body p { margin: 0 0 12px; } +.comment-body ul, .comment-body ol { margin: 0 0 12px; padding-left: 24px; } +.comment-body h1, .comment-body h2, .comment-body h3 { margin: 16px 0 8px; } +.comment-body code { + background: rgba(110,118,129,0.2); padding: 0.2em 0.4em; border-radius: 6px; font-size: 85%; +} +.comment-body hr { border: 0; border-top: 1px solid var(--border); margin: 16px 0; } +.md-code { + background: #161b22; border: 1px solid var(--border); border-radius: 6px; + padding: 12px; overflow: auto; margin: 0 0 12px; +} +.task-list { list-style: none; padding-left: 0; } +.task-list-item { display: flex; gap: 8px; align-items: flex-start; margin: 6px 0; } +.task-list-item input { margin-top: 4px; } +.note { + margin-top: 16px; border: 1px solid var(--border); border-radius: 6px; + padding: 12px 16px; color: var(--fg-muted); font-size: 12px; background: var(--bg); +} +.files-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; } +@media (max-width: 900px) { .files-layout { grid-template-columns: 1fr; } } +.toc { + border: 1px solid var(--border); border-radius: 6px; background: var(--bg); + position: sticky; top: 12px; max-height: calc(100vh - 24px); overflow: auto; +} +.toc h3 { + margin: 0; padding: 12px 16px; font-size: 12px; text-transform: uppercase; + letter-spacing: 0.04em; color: var(--fg-muted); border-bottom: 1px solid var(--border); +} +.toc ul { list-style: none; margin: 0; padding: 8px 0; } +.toc li a { + display: flex; justify-content: space-between; gap: 8px; padding: 6px 12px; + color: var(--fg); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.toc li a:hover { background: var(--border-muted); text-decoration: none; } +.fl-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.fl-stat { flex-shrink: 0; } +.c-add { color: #3fb950; } +.c-del { color: #f85149; } +.diffstat-summary { margin-bottom: 16px; color: var(--fg-muted); } +.diffstat-summary strong { color: var(--fg); } +.file { + border: 1px solid var(--border); border-radius: 6px; margin-bottom: 16px; + background: var(--bg); overflow: hidden; +} +.file-header { + display: flex; justify-content: space-between; align-items: center; + padding: 8px 16px; background: var(--file-header); border-bottom: 1px solid var(--border); + position: sticky; top: 0; z-index: 2; +} +.file-info { display: flex; align-items: center; gap: 8px; min-width: 0; } +.file-path { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; color: var(--fg); font-weight: 600; +} +.disclosure { color: var(--fg-muted); } +.file-badge { + font-size: 12px; border-radius: 2em; padding: 0 8px; + border: 1px solid var(--border); color: var(--fg-muted); +} +.file-badge.new { color: #3fb950; border-color: rgba(63,185,80,0.4); } +.file-badge.deleted { color: #f85149; border-color: rgba(248,81,73,0.4); } +.diffstat { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; } +.diffstat-add { color: #3fb950; } +.diffstat-del { color: #f85149; } +.diffstat-bar { display: inline-flex; gap: 1px; } +.diffstat-block { width: 8px; height: 8px; border-radius: 1px; background: var(--border); } +.diffstat-block.add { background: #3fb950; } +.diffstat-block.del { background: #f85149; } +.data { overflow-x: auto; } +.diff-table { + width: 100%; border-collapse: collapse; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; table-layout: fixed; +} +.blob-num { + width: 1%; min-width: 50px; padding: 0 8px; text-align: right; color: var(--num); + user-select: none; vertical-align: top; border-right: 1px solid var(--border-muted); + white-space: nowrap; +} +.blob-code { padding: 0 12px 0 8px; white-space: pre; vertical-align: top; color: var(--fg); width: 100%; } +.blob-code .x { user-select: none; margin-right: 4px; color: transparent; } +tr.add .blob-code .x, tr.del .blob-code .x { color: inherit; } +tr.add td { background: var(--green-bg); } +tr.del td { background: var(--red-bg); } +.blob-num-addition { background: rgba(46,160,67,0.2); } +.blob-num-deletion { background: rgba(248,81,73,0.2); } +tr.hunk td { background: var(--hunk-bg); } +.hunk-code { color: var(--hunk-fg); } +.btn { + background: var(--btn); color: var(--fg); border: 1px solid var(--btn-border); + border-radius: 6px; padding: 5px 12px; font-size: 14px; font-weight: 500; +} +.btn-primary { background: var(--accent); border-color: rgba(240,246,252,0.1); color: #fff; } +.btns { display: flex; gap: 8px; } +""" + + +def build_html( + *, + title: str, + body_html: str, + slug: str, + base_branch: str, + head_branch: str, + author: str, + pr_number: str, + issue_url: str | None, + mode: str, + files: list[DiffFile], + stats: list[FileStat], + commit_subject: str | None, + evidence_note: str, +) -> str: + owner, _, repo_name = slug.partition("/") + if not repo_name: + owner, repo_name = "owner", slug + stats_map = {s.path: s for s in stats} + total_add = sum(s.added for s in stats) + total_del = sum(s.deleted for s in stats) + today = date.today().strftime("%b %-d, %Y") + initials = "".join(p[0] for p in author.replace("_", " ").split()[:2]).upper() or "YO" + + toc = [] + for s in stats: + toc.append( + f'
      3. {html.escape(s.path)}' + f'+{s.added} ' + f'−{s.deleted}
      4. ' + ) + + diff_blocks = "\n".join(render_file_block(fd, stats_map) for fd in files) + issue_bit = ( + f' Related issue: {html.escape(issue_url)}.' + if issue_url + else "" + ) + commit_line = html.escape(commit_subject or title) + + return f""" + + + + +{html.escape(title)} · Pull Request mockup · {html.escape(slug)} + + + +
        + +
        {html.escape(owner)} / {html.escape(repo_name)}
        +
        + +
        +
        +

        {html.escape(title)} #{html.escape(pr_number)}

        +
        + + +
        +
        +
        + Open + {html.escape(author)} wants to merge into + {html.escape(base_branch)} from + {html.escape(head_branch)} + · drafted {today} +
        +
        review mockup
        +
        + + + +
        + +
        +
        +
        {html.escape(initials)}
        +
        +
        {html.escape(author)} commented {today} · PR description preview
        +
        {body_html}
        +
        +
        +
        {html.escape(evidence_note)}
        +
        + +
        +
        +
        Commits
        +
        +

        ● {commit_line}

        +
        +
        +
        + +
        +
        + Showing {len(stats)} changed files with + {total_add} additions and + {total_del} deletions. +
        +
        + +
        {diff_blocks}
        +
        +
        +
        + + + +""" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, default=Path.cwd(), help="Git repository root") + parser.add_argument( + "--mode", + choices=("working-tree", "range"), + default="working-tree", + help="working-tree: uncommitted+untracked vs HEAD; range: base...HEAD commits", + ) + parser.add_argument("--base", default=None, help="Base ref for --mode range (default: origin/main)") + parser.add_argument("--title", required=True, help="PR title") + parser.add_argument("--body-file", type=Path, help="Markdown file for PR description") + parser.add_argument("--body", default="", help="PR description Markdown (alternative to --body-file)") + parser.add_argument("--out", type=Path, required=True, help="Output HTML path") + parser.add_argument("--author", default="you") + parser.add_argument("--slug", default=None, help="owner/repo (default: from origin)") + parser.add_argument("--base-branch", default=None, help="Display name for base branch") + parser.add_argument("--head-branch", default=None, help="Display name for head branch") + parser.add_argument("--pr-number", default="XXXXX") + parser.add_argument("--issue-url", default=None, help="Optional linked issue URL for banner") + parser.add_argument("--commit-subject", default=None) + parser.add_argument("--evidence", default="", help="Footer evidence note (plain text)") + parser.add_argument("--open", action="store_true", help="Open the HTML in the default browser") + args = parser.parse_args() + + repo = args.repo.resolve() + if not (repo / ".git").exists() and not (repo / ".git").is_file(): + # worktrees use .git file + print(f"Not a git repo: {repo}", file=sys.stderr) + raise SystemExit(2) + + base = detect_base(repo, args.base) if args.mode == "range" else (args.base or "HEAD") + if args.mode == "working-tree": + patch, stats = collect_working_tree_diff(repo) + mode_label = "working tree (including untracked) vs HEAD" + else: + patch, stats = collect_range_diff(repo, base) + mode_label = f"{base}...HEAD" + + if not patch.strip() and not stats: + print("No changes found for this mode.", file=sys.stderr) + raise SystemExit(1) + + files = parse_unified_diff(patch) + # Ensure stats cover every parsed file (order by numstat, then extras) + seen = {s.path for s in stats} + for fd in files: + if fd.path not in seen: + # Count from hunks roughly + add = sum(1 for ln in fd.lines if ln.startswith("+") and not ln.startswith("+++")) + dele = sum(1 for ln in fd.lines if ln.startswith("-") and not ln.startswith("---")) + stats.append(FileStat(fd.path, add, dele)) + + body_md = args.body + if args.body_file: + body_md = args.body_file.read_text() + if not body_md.strip(): + body_md = ( + "**What is this feature?**\n\n" + "_Add PR description Markdown via --body-file or --body._\n" + ) + + slug = args.slug or detect_remote_slug(repo) + head = args.head_branch or git(repo, "branch", "--show-current").strip() or "HEAD" + base_branch = args.base_branch or ( + args.base.split("/")[-1] if args.base else ("main" if args.mode == "working-tree" else base.split("/")[-1]) + ) + + evidence = args.evidence or ( + f"+{sum(s.added for s in stats)} / −{sum(s.deleted for s in stats)} across {len(stats)} files · {mode_label}" + ) + + doc = build_html( + title=args.title, + body_html=light_markdown_to_html(body_md), + slug=slug, + base_branch=base_branch, + head_branch=head, + author=args.author, + pr_number=args.pr_number, + issue_url=args.issue_url, + mode=mode_label, + files=files, + stats=stats, + commit_subject=args.commit_subject, + evidence_note=evidence, + ) + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(doc) + print(f"Wrote {args.out} ({len(stats)} files, +{sum(s.added for s in stats)}/−{sum(s.deleted for s in stats)})") + if args.open: + webbrowser.open(args.out.resolve().as_uri()) + + +if __name__ == "__main__": + main()