This was 100% written by Claude without even a looksy at the code. This is a POC.
A local tool for reading GitHub pull requests and asking Claude about them.
It renders a PR the way GitHub does — the whole diff, or one commit at a time —
and lets you ask questions anchored to a specific line. Claude answers with the
PR's repository checked out in front of it, so it can grep, read call sites, and
run git.
Questions and answers are kept on this machine, in a folder the tool owns, so a
server restart doesn't lose them. They are never posted to GitHub. The tool has
no code path that writes to GitHub at all — gh is only ever called as
gh api <path>.
Four things, all of which you probably already have:
- uv — runs the app. It resolves the Python
dependencies into an ephemeral environment, and will download a suitable
Python itself if you don't have one (the project needs 3.11 or newer). There
is nothing to
pip install. git— used to clone the repository under review, keep a worktree at the PR head, and produce the diffs the page is built from.- GitHub CLI (
gh), logged in — the only way the tool talks to GitHub. Install it, then rungh auth loginonce. The account you log in as needs read access to the repositories whose PRs you open, private ones included.ghis only ever invoked asgh api <path>, so it never writes anything back. - A Claude login — the Claude Code CLI ships bundled with the
claude-agent-sdkdependency, so there is nothing extra to install, but it needs credentials. If you already use Claude Code, you're done; otherwise runclaudeonce and log in, or setANTHROPIC_API_KEYin the environment you start the server from. Answers are billed to that account like any other Claude Code usage.
Everything above is enough for the whole tool except the Walkthrough button,
which invokes the dev-utils:explain-diff-html skill and so needs the
dev-utils plugin installed in
Claude Code. The rest of the app does not care whether it is there; the button
says so and stops if it isn't. PR_REVIEWER_PLUGIN_DIR points at the plugin
directory if it lives somewhere the app doesn't think to look.
uvx --from /path/to/pr_reviewer pr-reviewer # default port 5173
uvx --from /path/to/pr_reviewer pr-reviewer --port 5199Or from inside the project directory: uv run pr-reviewer. Then open
http://127.0.0.1:5173 and paste a PR URL.
The folder is self-contained — copy it anywhere, or push it to a repo and
uvx --from git+https://… pr-reviewer.
- Commits steps through the PR commit by commit. Each commit gets a short orientation note from Claude, generated in the background; whichever commit you open jumps the queue.
- All files shows the whole PR, merge-base to head.
- Hover any line and click + to ask about it. Shift-click a second line to ask about a range. Follow-ups continue the same conversation.
- Ask about PR opens a drawer for questions about the pull request as a whole. It stays open and keeps its answers as you step between commits.
−collapses a question thread,×deletes it. Deleting clears it from your view, from the server and frommemory.json; it does not remove it from Claude's session, which has already seen it.↕at a hunk boundary reveals more of the file, 40 lines at a time.⌖on a Python line opens the trace view in a new tab: the whole file at the PR head, centred on that line's function, with its callers as code on the left and everything it calls on the right. Everything off the trace is dimmed. Click any function name to re-centre on it and walk the chain; the toggle in the topbar switches a click to a popup of the function's call sites instead, each openable at that line in a new tab. The call index is built in the background when a PR is opened — pure static analysis (ast+ jedi), no Claude, no tokens — and shows only calls that resolve to exactly one definition in the repository, never a guess.- Walkthrough has Claude write a guided explanation of the whole PR — background, the core intuition, a tour of the changes and a short quiz — as a self-contained HTML page that opens in a new tab. It takes a few minutes and costs tokens, so it only ever runs when you click. Push new commits and the button offers to write a fresh one; the page written for the old head stays readable in the meantime, since it still explains the commits it was written for.
- Split toggles side-by-side. Questions lists every thread in one place.
- Tests lists the tests the PR introduces — every test function whose
defline the diff adds — as a table of class-qualified name and docstring, each linking to its file in the whole-PR diff. A test that already existed and only had its body edited is not one the PR introduces, so it is not listed. Pureast, no Claude, and read from the diff on each visit, so a Refresh that brings in new commits brings in their tests. - Refresh fetches the PR again, so commits pushed since you opened it show up. Your questions stay; notes you already paid for are not regenerated. A question anchored to a commit that was rebased away moves to the whole-PR diff rather than disappearing.
- Keyboard:
n/pfor next/previous commit,j/kto move between files.
| Module | Job |
|---|---|
app.py |
Flask routes, diff fragments, the SSE event stream |
github.py |
gh wrapper — PR URL parsing, PR metadata, commit list |
repo.py |
clone cache, PR refs, worktree, diffs, file reads at a revision |
diff.py |
git diff output → files → hunks → lines (pure parsing) |
render.py |
highlighting, gaps, file tree — the model a template can loop over |
trace.py |
the call-trace index — ast scan, jedi resolution, graph, cache |
testcases.py |
the tests a PR introduces — added lines crossed with an ast scan |
session.py |
the persistent Claude session and the question queue |
summaries.py |
background per-commit context generation |
walkthrough.py |
the one write-capable run: the skill, its gate, its output |
store.py |
review state — threads, turns, summaries, the event bus |
memory.py |
memory.json — that state, saved and restored |
paths.py |
where the persistence folder is |
disk.py |
what that folder is holding, its size, and removing parts of it |
The repository is cloned once per project into the persistence folder (see
below) with --filter=blob:none, so even a very large repo opens in seconds;
blobs are fetched as they are needed. A detached worktree at the PR head is what
Claude works in.
The PR under review is untrusted input — its diff, description and commit messages all land in Claude's context, so the session is built on the assumption that a PR may try to steer it.
Claude gets Read, Grep, Glob and gated Bash only — no Write or
Edit. Every tool call is checked by a PreToolUse hook, which is the one
place a check reliably holds: naming a tool in allowed_tools auto-approves it
before a can_use_tool callback runs, and the CLI auto-approves read-only
tools like Read on its own regardless. The hook enforces two things:
- Paths stay in the checkout.
Read,Grep,Globand every path in a Bash command must resolve inside the PR worktree. Symlinks are resolved first, so a link the PR adds is judged by where it really points, and..and~are refused outright. Your~/.aws/credentials,~/.netrcandgh's own token file are out of reach. - Bash only reads. Commands pass an allowlist of executables and git
subcommands, and a denylist of the arguments that turn one of those into
something else —
find -exec,sort -o,--output=,git --exec-path. Shell expansion ($VAR,$(…)), redirection and backgrounding are refused, so environment variables are not a way to read a credential either.
Every process Claude starts also runs with git and gh credentials stripped
and GITHUB_TOKEN/GH_TOKEN blanked, so a session cannot write to GitHub. It
runs with setting_sources=[], so the reviewed repository's own CLAUDE.md
and your global settings don't reach it.
Two limits worth stating plainly. Everything Claude reads is sent to the Anthropic API as part of the conversation, so the guarantee is about what it can read, not about keeping what it reads local — treat the checkout itself as the boundary. And a command allowed by the gate can still write inside the worktree; the checkout is disposable and recreated per PR head, but "read-only" means "cannot reach past the checkout", not "cannot touch a byte".
A walkthrough has to write a file, so that run gets its own session — never the
one answering your questions — with a policy that differs in exactly two ways.
It may invoke one skill, dev-utils:explain-diff-html, loaded by path so
setting_sources can stay empty and nothing else from your config or the
reviewed repository follows it in. And Write and Edit are allowed for one
path: the output file the app named before the run started, checked by equality
rather than by directory, with a symlink at either end refused. Everything else
is the read-only gate above, including Bash.
The page itself runs JavaScript that Claude wrote after a run spent reading an
untrusted PR, so it is served under sandbox allow-scripts with
default-src 'none'. That leaves the quiz working in an opaque origin with no
way to reach these routes and no way to send anything anywhere.
One folder, ignored by git, inside the checkout you run from:
.pr-reviewer/
memory.json # the PRs you've opened, your questions, the answers
repos/owner__repo/ # a blobless clone, shared by every PR in that repo
worktrees/owner__repo__7/ # a detached worktree at that PR's head
traces/owner__repo__7.json # that PR's call-trace index, keyed by head commit
memory.json is rewritten whenever a question is asked, an answer finishes, or a
commit note lands, and read when the server starts — so restarting brings back
the PRs you were reading, with their threads and the notes you already paid for.
Follow-ups continue the same Claude conversation where the CLI still has the
transcript, and re-establish themselves in a new one where it doesn't. A question
that was mid-answer when the server stopped comes back marked interrupted.
The home page lists what the folder is holding, largest first, with the PRs
loaded from each clone. Clear drops one PR — its worktree, its questions,
its answers — and takes the clone with it unless another loaded PR is still
using it. Clear repo takes the clone, its worktrees and every PR in it, and
reaches clones no review points at any more. Clear everything empties
repos/ and worktrees/. Clearing is not the same as closing: the questions
and answers go, and re-opening the PR starts a new review. The clone comes back
on its own next time you open a PR from that repository.
The folder is self-contained and disposable: nothing in it points outside itself,
so it can be moved and the reviews move with it, and deleting it loses the
history rather than the tool. It is written for your user only (memory.json is
0600 — it quotes diffs and answers from repositories that may be private). Set
PR_REVIEWER_HOME to put it somewhere else.
Walkthroughs are the one thing written outside it, because a walkthrough is usually read once and then forgotten:
/tmp/pr-reviewer-1000/2026-08-13-walkthrough-acme-demo-7-a1b2c3d4.html
The uid is in the directory name and a symlink there is refused, since the temp
directory is shared and what lands in it is code from a repository that may be
private; the directory itself is 0700. The head commit is in the filename, so
pushing new commits asks for a new walkthrough rather than reopening one written
for code that has moved. Clearing a PR deletes every walkthrough it wrote, which
is what keeps the folder above the only thing you have to delete. TMPDIR moves
them.
Which default you get depends on how you started the server. uv run pr-reviewer
run from inside the project directory runs the checkout in place, so
.pr-reviewer/ lands there. uvx --from /path/to/pr_reviewer and
uvx --from git+https://… both build the package into uv's own cache and run it
from there instead of the checkout — with no checkout to sit in, they fall back
to ~/.local/share/pr-reviewer.
One thing to know about it sitting inside the project: the worktrees hold code
from the pull requests you review, which is not your code and is not trusted.
Nothing here runs it — pytest doesn't collect from a dot-directory, and the
build only packages src/ — but that is a property of the tools' defaults, not
something this repo enforces. Don't aim anything at the folder that would run
what it finds, and don't turn an agent loose on "the whole repo" while it's
there. PR_REVIEWER_HOME moves it out of the tree if you'd rather.
uv run pytestThey run against a real git repository built in a temp directory and a fake
gh; nothing hits the network and no Claude session is started.