Skip to content

Review a pull request in kunai - #4

Open
HEGADE wants to merge 17 commits into
mainfrom
feat/pr-review
Open

Review a pull request in kunai#4
HEGADE wants to merge 17 commits into
mainfrom
feat/pr-review

Conversation

@HEGADE

@HEGADE HEGADE commented Jul 29, 2026

Copy link
Copy Markdown
Owner

You open a pull request in kunai, click Review, and an agent reviews it against a
real checkout of that PR's code. The findings come back as a draft you read and
prune, and a second click posts them as a bot, anchored to the files and lines
they are about.

Nothing polls, nothing listens, nothing runs unless a person clicks. The design
discussion is written up in docs/pr-review.md.

What is here

  • internal/ghapp authenticates as a webhook-less GitHub App. People assume App
    means webhooks; an App registered without one is purely an identity plus a
    credential, which is what lets kunai post as a bot while still exposing nothing
    to the internet.
  • internal/review is the pure part: what a finding is, reading the agent's
    answer, where a finding may be anchored, and what the posted review looks like.
    It knows nothing about GitHub's JSON or about sessions.
  • internal/server/prreview*.go orchestrates. A review is an ORDINARY SESSION,
    so it appears in the sidebar, speaks over the same socket, and can be argued
    with once the findings are in.
  • The dashboard card, the draft card, and the App credentials in Settings.

The parts worth reviewing carefully

Anchoring decides whether a review posts at all. GitHub rejects the whole
review, not the offending comment, when one line is not part of the diff. The
patch is parsed into the set of commentable positions and every finding is
checked before anything is sent; one that cannot anchor is demoted to the summary
with a permalink rather than dropped, because the most valuable finding is often
about a file the pull request does not change.

Fork pull requests get no Bash. A stranger's diff is untrusted input that the
agent is about to read in full, so it can read and report but not execute. The
trust decision reads the head repository rather than the author, and lives in one
function.

Posting is a second click, and checks three things first: whether somebody has
already reviewed this commit (GitHub is the only state two colleagues' installs
share), whether the head has moved since, and whether every comment still anchors.

What is verified, and what is not

Full Go suite, go vet, gofmt and the race detector are clean, with around
forty new tests covering JWT timing, token refresh, diff anchoring, the parser,
and the placement rules. Playwright covers the surfaces.

Not verified: this has never touched real GitHub. Every test runs against a
fake API. The first live review is the real test, particularly the line
anchoring.

Also known: reviews currently run on the machine's default Claude account. The
seam to point them elsewhere exists and is not wired.

HEGADE added 17 commits July 29, 2026 21:15
The design conversation, written down before any of it is built. Records the
decisions that delete work rather than add it: a webhook-less GitHub App so
reviews are attributed to kunai[bot] without kunai ever exposing a port, a
button as the only trigger so there is no polling and no always-on host, and
posting as a second click so nothing half-baked reaches a colleague's PR.

Also records the three things that decide whether the reviewer is used or
muted, none of which are plumbing: prompt discipline about what not to say, a
verification pass to kill confident nonsense, and treating a fork's diff as
hostile input.
Phase 1 of docs/pr-review.md: identity only, no reviewing yet. The package can
list a repository's open pull requests as the App, which is the whole of what
this phase claims.

The App has no webhook and never will. People assume App means webhooks, but an
App registered without one is purely an identity plus a credential, which is
what lets kunai post as a bot while still exposing nothing to the internet.

Authentication is two hops because GitHub requires it: a short-lived RS256 JWT
signs as the App and may only ask which installations exist, and an installation
token scoped to one org is what may actually touch a repository. The two are not
interchangeable, so the auth mode is named at every call site rather than
inferred, because confusing them produces a 403 that reads like a permissions
problem.

Three details are load-bearing and are what the tests are about. The JWT
backdates iat and claims nine minutes rather than ten, because GitHub judges both
against ITS clock and a machine running slightly fast otherwise mints a token
that is already invalid on arrival. Installation tokens refresh two minutes
early, because a token that lapses between the check and the request fails a
review that has already spent real quota. And a rejected call's real reason is in
GitHub's per-field errors, not the top-level message, which says only "Validation
Failed": that is where "line must be part of the diff" lives, and it is the one
line that explains what to fix.

The private key is never returned, never logged, and never quoted in an error;
failures describe the shape of what was found instead. Both PEM encodings are
accepted because a key round-tripped through openssl comes back as PKCS#8 and
refusing it would be inscrutable to someone who did nothing wrong.

FromFork reads the head REPOSITORY rather than the author, since that decides
whether the reviewing agent gets Bash: a maintainer may open a PR from their own
fork, a stranger cannot push to the base repo, and an unnamed head repo (a
deleted fork) is treated as untrusted.
Phases 2 and 3 of docs/pr-review.md: the review runs and can be posted. No UI
yet, so this is reachable over the API only.

A review is an ORDINARY SESSION, created through the same machinery as any
other. That is the whole structure: it appears in the sidebar, speaks over the
same socket, and can be interrupted and argued with once the findings are in.
Everything specific to reviewing is in what it is handed rather than in a
subsystem of its own.

What it is handed is a DETACHED worktree at refs/pull/N/head. Detached because
a review reads somebody else's commit and will never be merged, so a branch
would leave a permanent entry in git branch for something that lasts minutes.
That ref is also the only place a fork's commits are reachable without adding
a remote, so one fetch covers a colleague's branch and a stranger's fork alike.

The agent cannot run git on a fork's pull request, because it has no Bash
there, so kunai hands it the diff as text and it reads the surrounding code
from the checkout. Diff in, context read out: that split is why the worktree
earns its keep, and why Read, Grep and Glob are deliberately left alone.

Anchoring is the part that decides whether a review posts at all. GitHub
rejects the WHOLE review, not the offending comment, when one line is not part
of the diff, so the patch is parsed into the set of positions that are actually
commentable and every finding is checked before anything is sent. A finding
that cannot anchor is demoted to the summary with a permalink pinned to the
reviewed commit, never dropped: the most valuable finding is often about a file
the pull request does not change, and that one can never be inline.

Posting checks three things between the click and the submission, each because
of a way this goes wrong: somebody else may have reviewed this commit already
(GitHub is the only state two colleagues' installs share), the head may have
moved since the review (comments would land on lines nobody wrote), and every
comment must still anchor against the diff as it is now.

internal/review is pure and knows nothing about GitHub's JSON or about
sessions, so the rules that matter can be exercised against a diff fixture
rather than against the network.
Phase 4 of docs/pr-review.md: the surfaces. A Pull requests card on the
dashboard, a draft card in the chat, and the App credentials in Settings.

The draft card is the reason posting is a second click, so it does not
summarise what was found. It shows where each finding GOES: a badge per row
saying inline or summary, and the ones GitHub will not accept inline say so
with the reason, because that constraint is real and discovering it after
posting would be a surprise. The header is the promise, and it updates as you
drop findings: two findings, one inline, one in the summary. Nothing else
appears on your colleague's pull request.

The dashboard card lists only repositories kunai already has open, derived
from sessions and history rather than a list somebody maintains. That is the
same constraint the whole feature rests on rather than a limitation to work
around: reviewing here beats reviewing in CI because the agent reads the real
tree at the pull request's head, and that needs a clone to build a worktree
from.

Nothing new was invented visually. The palette, the mono data voice, cards for
context and chrome for actions, and the rule that an action rests visible
rather than hiding behind a hover are all the app's own, and t3code was taken
for mechanism rather than appearance.

Two things are set in Settings and they are deliberately separate. The App is
shared, so every install posts as one kunai[bot] and reviews are consistently
attributed. The handle is per device, because it names the PERSON who asked:
a machine two people use must not credit one's reviews to the other. The
private key is write-only from the client and is cleared from the field on
save.
Caught before this went anywhere: on a machine with no GitHub App configured,
the dashboard fetched pull requests for every repository, failed every fetch,
and rendered "kunai has no GitHub App on this machine yet" as an error card.
Everybody who does not review pull requests would have got that on their
dashboard, twice on a phone.

The card now asks whether the App is configured before anything else and
renders nothing at all until the answer is yes, which also stops it making one
failing request per repository on every dashboard load.

The smoke test passed through all of this, because it only asserted that the
page still rendered. It now asserts the card is ABSENT, which is the actual
claim being made.
Every pull request showed as +0 -0, including a 5,300-line one. GitHub's LIST
endpoint does not carry additions and deletions at all; they exist only on the
single-pull-request endpoint. The comment in pulls.go said exactly that and the
dashboard went on reading them off the list anyway.

The size now comes from the per-pull-request lookup, and the enrichment runs
concurrently rather than in sequence, which it should have from the start: each
row already needed a second call to answer "has this commit been reviewed", so
a repository with ten open pull requests made twenty round trips to github.com
before the card could paint. Bounded at six in flight, because spending a
repository's rate limit on a dashboard nobody has read yet is its own kind of
rude.

A failed detail lookup leaves the size unknown rather than dropping the row:
knowing a pull request exists is worth more than knowing how big it is.

ghapp.NewWithBaseURL is exported for this test, which is the seam a GitHub
Enterprise host would use too.
Two faults, one of which meant the feature never worked at all. Found by
reviewing PR #4 with the feature itself, which is the one good thing here.

collectDraft accumulated ev.Text on assistant events. An assistant message
carries its content in Blocks; Text is only ever set on delta, thinking and
user events. So the collected draft was the empty string, review.Parse found
no block, and EVERY review recorded a parse error. It read as the model
failing to follow the output format rather than as kunai never having seen the
answer, which is the worst shape a bug can take. Text now comes from the text
blocks, with thinking and tool calls excluded for the same reason the loop's
promise check excludes them.

And the brief was sent as an ordinary prompt, so the entire instruction set
and the whole diff were printed into the chat as something the user had
apparently said. The work started several screens below a wall of JSON schema.
It is sent silently now, the way an added project's context already was: the
draft card is the conversation's record of it, which is exactly what `silent`
means in this codebase.
The dashboard listed every pull request twice: once under "kunai" and once
under a heading called "4". The second was the review's own worktree,
.../worktrees/kunai/review/4, taken for a repository because nothing said
otherwise.

A review deliberately runs in a detached worktree that is not registered as
work in progress, which is right (it will never be merged, and a branch for a
review that lasts minutes would litter git branch), but it left the session
with no repository at all. It now reports the checkout it was started from, so
it groups under the codebase it is reviewing in the sidebar as well.

Tagged from the review record rather than resolved from disk, because this
runs for every session on every listing and an extra git call per row is not
worth paying to answer a question we already know the answer to.
Three changes, all from the same complaint: the review spent your money badly
and put its machinery where the work should be.

The diff is written into the worktree and the prompt names the path. Pasting a
large pull request inline spends the whole diff in tokens before the model has
decided anything is worth looking at, and most of a big diff is not: you saw
146k tokens and $1.47 for a run that had not finished. Given a path it reads
what matters, greps for callers, and skips the lockfile. Read reaches the file
on a fork's review too, where Bash does not, so this costs nothing in safety.
The prompt keeps the changed-file list with per-file sizes, because that is
small and is exactly how the model decides what to open first.

Reviews get their own account and model, set in Settings. A review is chunky
and arrives when somebody else opens a pull request, so spending the window you
are working in is the wrong default; pointed at a second account or a provider
it can never wall the session you are sitting in. Empty means the machine's
default, so nothing changes for a machine that never sets one.

And clicking Review no longer throws you into the chat. A review takes minutes
and you almost never want to watch one: being dropped into a session reading
files is what made this feel wrong. The row reports "Reviewing 2m" in place and
offers a way in. The draft card also moves to the TOP of the session, because
it is the point of that session: while the review runs it is the progress, and
when it ends it is the thing you act on. At the bottom it sat behind minutes of
tool calls.
Every review made a full worktree and nothing ever removed one, so they
accumulated one per pull request ever reviewed. Tens of megabytes each on a
repository of any size: not dangerous, and exactly the kind of thing you find
out about in a month.

The obvious implementation is the wrong one, which is why this is a sweep and
not a callback. A RESPAWN also ends the session (an effort change, an account
switch, auto-failover all close it and build a new one in the same checkout),
so removing on close would delete the directory out from under a session that
is coming straight back into it, and there is no moment at which the difference
can be observed: the old session is gone before the new one is registered.

So the question asked is "is anyone working here", which the worktree store
already answers for this exact reason, plus a grace period long enough that a
respawn measured in seconds cannot race a sweep measured in minutes. Asked of
the live session list rather than of the review's own id, because a worktree can
hold more than one session: you may have opened your own beside the review to
read the code, and pulling the directory out from under that would be worse than
keeping a few megabytes.

Swept on boot as well as on a timer, and the boot pass is the one that matters:
it catches the reviews that were running when kunai was killed, restarted or
updated, which is precisely when nothing else had the chance.
Two things made watching a long turn worse than reading about it afterwards.

A finished turn already collapsed its tool calls behind one summary. A RUNNING
turn rendered every block inline, which is the case where it matters most:
watching an agent read forty files meant forty cards pushing the work off the
screen, and the thing you wanted (what is it doing NOW) was at the bottom of a
column you had to chase. While it runs it is now one line naming the current
call and counting the finished ones, with the full stream behind a disclosure.
That disclosure's state is held by the chat rather than the component, so it
does not close itself every time a tool result arrives.

And tool output was a grey <pre> whatever came back, which wasted three things
already in the app: a syntax highlighter, a diff renderer, and the path the tool
was given. A Go file now reads as Go and a unified diff reads as red and green,
which matters more now that a review begins by reading one. A diff is sniffed
from the content rather than the path, because it arrives from Bash and from
git show where there is no path; the sniff needs a hunk header AND a changed
line, so a changelog full of bullet points is not mistaken for one. The Read
tool's `   12->` gutter is stripped first, or highlighting colours the line
numbers as code.
Three real reviews finished and produced nothing: no draft, no parse error, and
not one log line. Not a parse failure, which is what the empty card looked like.
The collector was never there at the end.

It worked by SUBSCRIBING to the session and waiting for the turn to end.
emitLocked drops a subscriber whose buffer fills and closes its channel, which
is right for a phone that cannot keep up and fatal for a collector: a review
streams for minutes, so the watcher was dropped part-way every time and then
saved nothing and logged nothing, because from where it stood the conversation
had simply ended. Silence indistinguishable from success, again.

Watching from outside was the wrong mechanism. The answer is taken from inside
the session now, where lastText is already maintained for the loop's completion
promise, through a hook that cannot be dropped for lag and fires exactly once
per turn. Deliberately not SetTurnEndHook, which auto-failover owns: a session
has one of those and two features needing the end of a turn should not have to
fight over it. Pinned by a test that overflows a subscriber, asserts it really
was dropped, and requires the hook to have fired anyway.

Separately, the brief is now wrapped in a tag. It is sent silently so it never
renders live, but the CLI writes every turn to the transcript and reopening a
session replays it, so a review looked at afterwards printed its entire
instruction set back as a user message. Seeding already skips a user turn that
opens with a tag; this is the same mechanism <loop-iteration> relies on, and it
costs one line.
The chat was the wrong surface and the tell was that every improvement to it
was an attempt to hide it: the brief sent silently, the findings pinned above
the conversation, the tool calls collapsed, the prompt wrapped so a reopened
session would not replay it. When every change suppresses a surface, the
surface is wrong. A chat is for open-ended conversation; a review is a fixed
set of judgements, each with evidence, that you accept or drop and then send.

So a review session now opens on a view of its own: one column of
self-contained cards, each carrying the claim, the diff lines it is about, a
suggested change where there is one, and the two decisions you can make. One
column rather than a list beside a detail pane because kunai is used from a
phone, and a split does not survive a narrow screen; this is one layout to get
right instead of two.

Each finding travels with its hunk, cut from the patch rather than read from
disk: the patch is what the finding's line numbers refer to, and a file read
from disk would drift the moment the pull request moved. Lines the finding is
actually about are marked, so context can be generous without the point being
lost in it.

Keyboard on desktop, because a review is a rhythm: j and k to move, d to drop,
cmd-enter to post. Untouched on a phone, where scrolling and tapping is the
whole interaction. Post is sticky in the header and says how many findings it
will send, so it is a promise rather than a button.

The conversation is one click away, and a finding can be carried into it: being
able to argue with the reviewer is the thing kunai has that a CI reviewer does
not. It is just not the room you start in.

Two bugs the new view's own test found. Dropping every finding and pressing
Post published every finding, because an empty selection was read as "no
pruning" -- nil and empty mean different things and JSON distinguishes them for
free. And with everything dropped the button went grey, when the summary alone
is still a review worth sending; it now says so.
Updating nightly did nothing, twice, and then reported "still on nightly-099509".
The release really did carry the new build; the updater downloaded the old one.

Assets were fetched from the release's name-based URLs, on the stated assumption
that CI overwrites them on every push so the URL is always the latest. GitHub
redirects those to a CDN that caches BY URL, so for a window after CI replaces an
asset the previous bytes are still served. A nightly published at 23:36 was still
handing out the build before it at 23:37.

The checksum did not catch it, and that is the part worth understanding rather
than patching: checksums.txt was fetched from a name-based URL as well, so the
stale binary and the stale checksum came from the same cached generation and
agreed with each other. A consistent stale pair passes every check that only
compares the pair.

Assets are now resolved through the release API, where every re-upload gets a new
asset id: there is no shared URL left to cache. Both the binary and its checksum
come from ONE read of the release, so they cannot be from different generations
even in principle. The old name-based helper is deleted rather than left beside
the new one, because it looks perfectly usable and is not.

One test was stubbing the removed variable and so had been reaching real GitHub;
it now uses the same fake release as the others.
The same stale-CDN bug as the self-updater, in the other place a binary arrives
from. install.sh fetched /releases/download/<tag>/<name>, which GitHub serves
through a cache keyed on that URL, so re-running it during the window after CI
replaced an asset reinstalled the build already on the machine. Handing somebody
a curl one-liner to escape a broken updater is not much use if the one-liner has
the same fault.

Assets are resolved through the release API and fetched by id, which changes on
every re-upload, and the binary and checksums.txt come from one read of the
release so they cannot disagree about which generation they belong to.

The release payload is parsed with awk rather than jq, which this script cannot
assume exists. Within an asset GitHub emits "url" before "name", so the last url
seen when a matching name appears belongs to that asset; the nested uploader's
url comes after the name and is overwritten before the next asset's name is
reached; and browser_download_url does not match because the character before
`url` there is an underscore, not a quote. Checked against the real payload
rather than reasoned about: it resolves both assets and returns nothing for a
name that is absent.
The session sat on "Needs you" while the view said "Nothing worth reporting".
Both halves were wrong in the same way.

A review runs with a permission mode that gets on with safe work and stops to
ask about a risky command, which is right for a session you are sitting in front
of. A review is the opposite by construction: the dashboard deliberately does
not open it, because you almost never want to watch one. So the first unusual
Bash command parked the whole review on a question that would never be answered.

Bash is therefore withheld from every review, including on your own team's code
where it was allowed so tests could run. That was a real gain in quality and it
is being given up on purpose: a reviewer that hangs silently is worth less than
one that cannot run tests, which is the same trade the loop already makes when
it borrows acceptEdits. Reading is untouched, and reading is the job; Read, Grep
and Glob never need permission, which is what makes an unwatched review possible
at all. One consequence is worth keeping: the toolset no longer depends on
trust, so there is no second list to keep in step and no way for a stranger's
diff to be handed more than your own branch gets.

And the view lied. awaiting_permission is neither running nor finished, so it
fell through to the empty case and reported a clean bill of health for a review
that had not started looking. It now says it is waiting and offers the way to
answer. Reviews can no longer reach that state, but a UI that claims "nothing
found" whenever it is confused will find another way to be wrong.
…back

A review stopped a minute in, having read the diff and some source, with no
error anywhere. The log named the culprit against a session that was never
shared: "session 4092bed3 outlived its share; restoring its tools".

The share reconciler gives back the toolset of any session whose share has
ended, because a link that simply runs out of time is swept by whatever next
reads the store and nothing was watching for that. It found those sessions by
looking for withheld tools, which was proof of a share only while sharing was
the one thing that withheld them. A review withholds Bash too, so that it
cannot park on a permission ask nobody is there to answer. So every review
looked like an expired share, and about a minute after one started the
reconciler respawned its session and killed the running turn.

A session now records what withheld its tools, and the reconciler lifts only
its own. The owner travels with the tools through spawnSpec, because a
restriction nobody claims is indistinguishable from an abandoned share and
would be lifted by the next tick; changing the tools re-states the owner, and
giving them back drops the claim.

The reconciler's judgement is a pure function so it can be tested without
spawning a CLI, the same reason discoveryCache.merge is one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant