Skip to content

← The log

Reviewing pull requests with Claude Code on your own machine

The sixteen lines that do it, and the seven things they get wrong — five you can fix, and two that are somebody else's software changing under you.

GuideKonstantin Tarkus

You have Claude Code. You have gh. Somebody just asked you to review a pull request. The obvious thought is that these three facts should compose, and they do — a working version fits on one screen.

This is that version, and then an honest account of what it gets wrong. Some of the flaws are one line to fix. Two of them are not, and knowing which is which is the useful part.

The sixteen lines

gh already knows how to find the pull requests waiting on you:

gh search prs --review-requested=@me --state=open --json repository,number

Wrap that in a loop, check out the code, hand it to Claude, and post what comes back. Do not run the result. It is written to be wrong in instructive ways, and two of them execute code from the branch on your machine the first time round the loop — so read it, then read the list under it.

#!/bin/sh
# DELIBERATELY UNSAFE. See "What it gets wrong" before running anything like it.
set -eu

gh search prs --review-requested=@me --state=open --limit 20 \
  --json repository,number --jq '.[] | "\(.repository.nameWithOwner) \(.number)"' |
while read -r repo number; do
  dir=$(mktemp -d)
  gh repo clone "$repo" "$dir" -- --quiet
  (
    cd "$dir"
    gh pr checkout "$number"
    claude -p "/review-pr $repo#$number" < /dev/null
  ) || echo "failed: $repo#$number" >&2
  rm -rf "$dir"
done

Give Claude a skill called review-pr that reads the diff and calls gh pr review, and this genuinely works. The first time you run it, it reviews a pull request and the review shows up under your name.

Then you leave it running unattended for a week.

What it gets wrong

1. It has no memory, so it borrows GitHub’s

Nothing here records what it acted on. That works, and it works by accident: GitHub drops you from a pull request’s requested reviewers the moment you submit a review, so the search stops returning it and the next run skips it.

Which puts your script’s memory on somebody else’s server, in a field that answers a different question than the one you are asking. Any run that fails to post — the silent one in problem 6 especially — leaves you still requested, so the next run tries again, and the one after that, with nothing to bound it.

And that field cannot be asked about the past. A re-request after you reviewed is a distinct act: the author pushed a fix and wants another look. You want to honour it, and to tell it apart from the first one. requested_reviewers cannot help, because it forgot the first one at the moment you answered it.

So the record has to be yours, and keyed to the request rather than the pull request — the review_requested event, which has its own id and is never cleared. That is a schema decision worth getting right on the first try, because the failure mode is inflicted on other people.

2. It reviews a moving target and cannot say what it read

gh pr checkout gives you whatever the head is at the moment it runs. If the author pushes while Claude is thinking, the review you post describes code that is already gone, and nothing in it says which revision it was about.

You cannot recover the revision the request was made against. On every review_requested event measured so far, commit_id is null: the request names a pull request, not a commit. What you can do is choose one, hold it, and say so:

sha=$(gh pr view "$number" --repo "$repo" --json headRefOid --jq .headRefOid)
git fetch origin "$sha" && git checkout --detach "$sha"

That fetch works even when the pull request comes from a fork, because GitHub advertises the head as refs/pull/<n>/head in the base repository, which leaves the commit reachable and therefore fetchable by its raw SHA. Do not assume it of a self-hosted forge: fetching an object nobody advertised is a server-side permission, not a git guarantee.

Then put the SHA in the prompt. A review that names its revision can be checked later; one that does not is a claim about an unknown version of the code. If the head moves again before you post, that is a decision to make deliberately — review the new one, or say which one you read.

What this does not do is make the checkout safe. It pins which code you review, not what that code does to you on the way in — which is problem 4, and this snippet walks into it exactly as gh pr checkout did.

3. The branch configures your agent

claude -p reads CLAUDE.md, .claude/settings.json, project skills and .mcp.json from the directory it starts in — and it does not stop to ask whether that directory is trusted. You just cloned a directory a contributor controls and started an agent inside it.

A SessionStart hook committed to the branch runs on your machine.

The flag is --setting-sources user, and what it does and does not cover was measured rather than taken on trust. It is one word to add, and it is the difference between a review and an arbitrary code execution.

It also takes something away, which is the trap. That flag drops every project source, and a skill is a project source. If review-pr lives in a repository’s .claude/skills/, the fix above is precisely what stops it loading — and what happens then is problem 6. Install the skill at user scope, which is the scope the flag leaves standing.

4. Acquiring the repository runs code

Before Claude starts at all.

git will execute a program on the way to a working tree in at least four places, and three of them are the reviewer’s own configuration meeting code they have not read: post-checkout hooks, a hook configured through hook.<name>.command, core.fsmonitor, and a smudge filter that a committed .gitattributes can point at. If you have ever run git lfs install, you have a global filter a branch can aim.

The earliest of them is not at checkout at all. reference-transaction fires on every clone and every fetch, before there is a tree to check out — so overrides applied to git checkout arrive two commands late, and the gh repo clone above has already run whatever you have configured.

The measurements are here. This is the one that costs a Saturday. The fix is not a flag but a set of per-invocation overrides, and several are not spellable the obvious way: a config subsection name may legally contain an =, and -c splits on the first one, so -c filter.a=b.smudge= against a real [filter "a=b"] sets filter.a and the smudge runs anyway.

5. It reviews forks

gh search prs --review-requested=@me returns pull requests from forks along with everything else, and the script above treats them identically. A fork’s branch is code from someone with no write access to the repository, which is exactly the population the two problems above are about.

gh pr view --json isCrossRepository tells you, and the distinct failure here is not that forks get reviewed — it is that the script has no idea which ones are, so it cannot apply a different policy to them even if you decide on one.

Be careful what you read that field as. It says the branch lives in a different repository from the base, and nothing more. It is not proof the author lacks write access, and a same-repository branch is not proof they have it. What it does mark is the case where the branch reached you without anyone having to be trusted first, which is the distinction worth acting on.

6. A zero exit does not mean a review happened

If your skill is named review-pr and you type /review-pr, and the skill is not installed where Claude is looking, Claude prints Unknown command: /review-pr and exits 0.

The script above reads that as success, moves on, and — once you have added the record from problem 1 — marks the request as handled. You have now consumed a review request that produced nothing, and nothing in the output says so.

Check the skill exists, at the scope you are actually going to run it in, before you claim the run. If you took the fix in problem 3, that scope is user — and a skill sitting in the repository is one Claude will not see.

7. Two copies will happily overlap

The loop is serial, which is fine until you run it from cron and the previous run has not finished. Now two agents are working at once — each a full turn against your Claude allowance, on a laptop, alongside whatever you are actually doing.

One at a time, with a lock, is the boring correct answer.

What is cheap and what is not

Problems 1, 2, 5, 6 and 7 are work you can scope. A lock file and a --json field are minutes. The durable record is longer, not because a table is hard but because the key has to be right the first time. All of them are decisions you can make once and be done with.

Problems 3 and 4 are different. They are not features of your script; they are properties of tools you did not write, they change under you between releases, and neither one announces itself when it breaks. The only way to know --setting-sources user still does what it did is to run an arena against a new Claude Code and look. The only way to know your checkout still executes nothing is to rebuild the origin carrying each vector and try it.

That is why a sixteen-line script becomes a program: not the loop, but the two boundaries that have to be re-measured rather than assumed.

Or take the packaged one

Engwire is the above with those decisions made — the request as the unit of identity, the revision pinned, forks skipped outright, the skill checked before the run is claimed, one review at a time, and both boundaries measured with the recipes published so you can re-run them.

MIT, and the review still arrives as you, because it is still your gh posting it. The command is at the foot of this page. If you would rather keep your own script, take the seven problems — they are the same seven either way.

Engwire reviews the pull requests that ask for you, on your own machine.

MIT · macOS and Linux · no account to make

curl -fsSL https://engwire.com/install.sh | sh

What Engwire does →