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('