Code review inside Claude Code: the branch diff in a pane, draft comments from you or Claude on any line, sent as a pending GitHub pull-request review. Needs…

diff-review shows a diff in a pane inside Claude Code. You and Claude add draft comments on any line, and you send the comments as a pending review on the pull request, which you then submit on GitHub.
The main use: review a colleague's pull request from your terminal without checking it out.
/diff-review 2148
What you get:
Nothing is approved or submitted automatically. The review stays pending until you submit it on GitHub.
git on your path.gh on your path and logged in.In a Claude Code session:
/plugin marketplace add stefanoshea/diff-review
/plugin install diff-review@diff-review
/reload-plugins
A mod runs with your permissions. What the mod runs, reads and sends lists every program, request, prompt and command, and claude plugin validate on a clone lists every event it hooks and every call it makes.
To run it from a local clone instead:
claude --plugin-dir /path/to/diff-review
Every call below is in hooks/. claude plugin validate . on a clone lists the same hooks and calls.
The mod starts two programs, git and gh, through $.process.run, which takes an argument list and uses no shell. Each command is fixed words plus the branch, commit, pull request number, repository or file paths of the review. The mod starts no other program.
git, always with --no-optional-locks, reads the repository you run Claude Code in:
rev-parse --show-toplevel, rev-parse --abbrev-ref HEAD, rev-parse HEAD, rev-parse origin/<branch>: find the repository, the branch and the head commit. In branch mode, rev-parse HEAD also runs every 5 seconds while the pane is open, to notice new commits.symbolic-ref --quiet refs/remotes/origin/HEAD, rev-parse --verify --quiet origin/main (or origin/master): find the default base branch.merge-base <base> HEAD: find where the branch started.diff --no-color -U3 --find-renames <merge-base> [<pull request head>]: the diff in the pane.diff --name-only HEAD -- <commented paths>: before a branch-mode send, check that the commented files have no uncommitted changes.remote -v: find the remote that matches the pull request's repository.fetch --quiet <remote> +pull/<n>/head:refs/remotes/diff-review/pr-<n> and fetch --quiet <remote> <base branch>: in PR mode, download the pull request's commits. The only thing this writes is the refs/remotes/diff-review/pr-<n> ref. Your branch and working tree stay untouched.gh talks to GitHub as the account gh is logged in with:
gh pr view [<n>] [--repo <owner>/<repo>] --json ... and gh repo view --json nameWithOwner: read the pull request's number, branches, head commit and URL.gh api repos/<owner>/<repo>/pulls/<n> -H "Accept: application/vnd.github.v3.diff": read the diff of a pull request in another repository than the one you are in.gh api -X POST repos/<owner>/<repo>/pulls/<n>/reviews --input -: only when you send, see below./diff-review send or press send review: one pending review on that pull request. It holds the head commit SHA and, for each open comment, the file path, the line or line range, the side and the comment text. The review stays pending until you submit it on GitHub.gh and git fetch calls above send only the repository, the pull request number and the refs they ask for.get_diff and list_comments give Claude the diff and the open comments when Claude calls them, and the prompts below go to Claude as user turns. This is the conversation you already have in Claude Code; the mod adds no other service.$.http) and sends no telemetry. Data leaves the mod in two ways only: the git and gh arguments listed above ($.process.run), and the prompts listed below ($.prompt.submit). Nothing the mod reads from the session goes into either.What it reads: the git repository above; its own stored comments, risk analysis and feedback memory in Claude Code's plugin store ($.store); and the names of the tools Claude calls. When Edit, Write, NotebookEdit or Bash finishes, it refreshes the diff. It does not read the input or the output of those tools.
It also reads two facts about the session, and sends neither out:
$.session.id): stored in the plugin store with the open-pane marker, and compared at session start so that a resumed session reopens its pane. It is never put in a prompt, a git or gh argument, or the review. The mod reads it because session.start does not say whether a session is new or resumed.turn.start, turn.complete): only the turn id, the agent id and the reason the turn ended, so that claude review starts its next step when Claude's turn ends. The mod never reads the text of the conversation.The mod submits a prompt only when you ask for one:
[risk], /diff-review risk, or the first step of claude review: instructions to read the diff with get_diff and call set_risk once, the pull request number or URL, and the feedback memory block. That block holds your notes, the path and text of Claude drafts you deleted or rewrote in this repository, and how many Claude drafts you sent.claude review: instructions to turn the review's findings into add_comment calls, plus the same feedback memory block.[ask] in a comment box: the branch or pull request, the file path and line range, the selected diff lines with a few lines around them, and your question.The full text is in hooks/tools.ts (riskPromptOf, reviewPlanOf) and hooks/view/ask-text.ts (askTextOf).
claude review (/diff-review claude, or the claude review button) runs one slash command: the review skill set in /config under "Review skill", default /code-review low. In PR mode the pull request number or URL is appended. It runs after Claude's risk-analysis turn ends. The mod runs no other slash command.
command.run on { command: "diff-review" }: answers /diff-review, the mod's own command. It passes the event on unchanged with next(e), then adds its own text as the command's output. It never sees or changes any other command.tool.call on mcp__diff-review__add_comment, list_comments, get_diff and set_risk: answers the four tools the mod registers. A refusal there, such as "the pane is not open", is that tool's result, not a permission decision.tool.call on Edit, Write, NotebookEdit and Bash: passes the call on unchanged with next(e), then schedules a diff refresh.The mod never approves or denies a tool call. It never changes a permission mode, a setting or Remote Control, never spawns an agent, and never writes files through Claude Code.
Inside a clone of the repository that holds the pull request:
/diff-review 2148
/diff-review claude
Triage the drafts in the pane, add your own, then:
/diff-review send
Open the pull request on GitHub and submit the pending review.
| Command | Effect |
|---|---|
/diff-review pr <number, URL or owner/repo#number>, or just /diff-review 2148 | Review a pull request without checking it out. Your branch and working tree stay untouched. In a fork clone the PR is fetched from the remote that matches the PR's repository. |
/diff-review | Open the pane for the current branch against its base. If the pane is open, report its status. |
/diff-review branch | Leave PR mode and go back to the current local branch. |
/diff-review risk | Risk analysis only. Claude reads the diff and fills the risk card. |
/diff-review claude | Full review: risk analysis, then the configured review skill, then one draft comment per finding. |
/diff-review send | Create a pending review on GitHub from the open comments. |
/diff-review note <text> | Store a standing preference for reviews in this repository, for example note skip docstring nits. |
/diff-review memory | Print what the plugin has learned for this repository. memory clear deletes it. |
/diff-review refresh | Refresh the diff and comments. |
/diff-review clear | Delete the stored comments for the current branch or PR. |
/diff-review base <ref> | Branch mode only: diff against this ref instead of the detected base. |
/diff-review close | Close the pane. |
The pane reopens by itself when you resume the session, until you close it. A new session starts with the pane closed.
A URL or an owner/repo#number names the repository, so a PR outside the clone you are sitting in works too: gh is asked for that repository, the diff comes from the GitHub API, and nothing is fetched into the local clone. Comments, risk and feedback memory are stored under owner/repo, not the working directory, and the pane header names the repository. Give a bare number and it still means a PR of the current repository.
Claude is told the PR lives elsewhere, so it reads the diff through the plugin's tools instead of local files. Sending still posts to the PR's own repository.
Header, always visible: title, branch → base @sha · n open, and buttons refresh, send, claude review, risk, files, sent, expand all, ↑ comment, ↓ comment, and base in branch mode.
Body:
needs judgment: …, and decisions for you bullets. [hide] hides it; the risk button shows it again or runs a new analysis. It says (stale: diff changed) when the PR head moved since the analysis.+added -removed, ●n for open comments, and a risk badge ! high, ! medium, or low. Low-risk files start collapsed. Press a header to collapse or expand it.● marks lines with comments.edit and delete.Keys and buttons:
ctrl+↑ / ctrl+↓ (or opt+↑ / opt+↓) step through the comments in diff order, centring each one and expanding its file if needed. The footer reads comment 3 of 7, and (wrapped) past the end.[save] stores the draft. A range inside one hunk is kept as a range: the box shows lines a-b and GitHub gets a multi-line comment. A range across hunks is stored on its first line. [ask] sends the selected lines and your text to Claude as a question. [cancel] discards.[files] shows a jump list. Press a file to expand it alone. [expand all] expands everything, low-risk files included. [sent] shows or hides sent comments.ctrl+x x closes the pane./diff-review claude runs three steps in order. Each step starts when Claude's turn for the step before it ends. If a turn ends by interruption, refusal or error, the chain stops and the footer says claude review stopped: <step> ended (<reason>). Closing the pane also stops it.
get_diff and calls set_risk once: overall level, summary, review dimensions that need a human, up to five decisions for you, and a level per file./code-review low, with the PR number appended in PR mode. Set another one in /config under Review skill (plugin option reviewSkill), or in settings.json: ``json { "pluginConfigs": { "diff-review": { "options": { "reviewSkill": "/my-team-review" } } } } ``add_comment call on the exact line. You triage them in the pane.Tools the plugin gives Claude:
mcp__diff-review__get_diff — the diff with R<n> (new file, side RIGHT) and L<n> (old file, side LEFT) line numbers. Optional path for one file in full.mcp__diff-review__set_risk — record the risk analysis: level, summary, dimensions, decisions, files[{path, level, reason}].mcp__diff-review__add_comment — one draft on path, line, side, with body. Refused when the pane is closed or the line is not on the diff.mcp__diff-review__list_comments — the open drafts, grouped by file.You can also just ask, with the pane open: "Look at the diff and add draft comments for anything risky."
The plugin keeps one memory per repository and adds it to the risk prompt and the review prompt:
/diff-review note <text> records a preference in your words.The prompt block lists your notes, the last 15 rejected drafts, the last 10 corrections, and the accepted count, capped in size. /diff-review memory shows it. /diff-review memory clear deletes it. The memory never approves anything by itself.
/diff-review send creates a pending review on the pull request through the GitHub API from the open comments. The review stays pending: you submit it on GitHub, where you can still edit or drop comments.
Send is blocked when:
origin (branch mode),gh is not available or not logged in,In PR mode the review is posted against the PR head that the pane shows. Refresh first if the header says the diff changed.
All state lives in the plugin store, per repository toplevel:
| Key | Holds |
|---|---|
review:<toplevel>:<branch> | comments in branch mode |
review:<toplevel>:pr-<n> | comments in PR mode |
risk:<toplevel>:<branch or pr-n> | the risk analysis with the head sha |
memory:<toplevel> | feedback memory |
base:<toplevel>:<branch> | base override |
open:<toplevel> | reopen marker with the PR number |
/diff-review clear deletes the current comment key only.
gh pr view needs a default repository in clones with several GitHub remotes: gh repo set-default.git diff output over 4 MiB is cut. The footer names the last file kept, that file shows no hunks, and the files after it are missing. Claude's get_diff says the diff is incomplete.claude plugin test . — run the test suite.claude plugin validate . — validate the plugin manifest and hooks.MIT. See LICENSE.
hooks/register.tsx 660 lines1/* @jsx h */
2import type { EngineInterface, Register } from 'claude-code'
3import { CommentStore, type StoreOps } from './comments/store.ts'
4import { fileOf, inOneHunk } from './diff/address.ts'
5import type { FileDiff, Side } from './diff/types.ts'
6import { createPendingReview } from './send/github.ts'
7import { payloadOf } from './send/payload.ts'
8import { fetchDiff, type DiffFetch } from './git/fetch-diff.ts'
9import { runnerOf, type Runner } from './git/run.ts'
10import { uncommittedOf } from './git/uncommitted.ts'
11import { MemoryStore } from './memory/store.ts'
12import { prNumberOf, prRefOf, resolvePrTarget, resolveTarget, type Target, type TargetFailure } from './git/target.ts'
13import { Names } from './names.ts'
14import { DEFAULT_REVIEW_SKILL, reviewPlanOf, riskPromptOf, serveAddComment, serveGetDiff, serveListComments, serveSetRisk } from './tools.ts'
15import { askTextOf, type AskSelection } from './view/ask-text.ts'
16import { newModel, type Editing, type Model, type Risk } from './view/model.ts'
17import { paneTree, type Actions } from './view/pane.tsx'
18import { centeredTopOf, commentOrderOf, indexOfKey, LayoutCache, snapOffset, type CommentStop, type Layout } from './view/rows.ts'
19
20const REFRESH_DEBOUNCE_MS = 800
21const REDRAW_MS = 16
22const HEAD_POLL_MS = 5000
23const EDIT_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'Bash'] as const
24
25type Timer = { cancel: () => void }
26type Timers = { refresh?: Timer; redraw?: Timer; poll?: Timer }
27
28type ChainStep = { label: string; run: () => Promise<unknown> }
29type Chain = { steps: ChainStep[]; label: string; isArmed: boolean; turnId: string | null }
30
31type State = { chain: Chain | null; model: Model; store: CommentStore | null; memory: MemoryStore | null; isOpen: boolean; timers: Timers; lastHead: string; generation: number; prNumber: number | null; prRepo: string | null; cursorPath: string | null; cursorCommentId: string | null; scrollTop: number; bodyCount: number; contentRows: number; columns: number; reviewSkill: string; layout: LayoutCache; sessionId: string | null }
32
33const newState = (): State => ({ chain: null, model: newModel(), store: null, memory: null, isOpen: false, timers: {}, lastHead: '', generation: 0, prNumber: null, prRepo: null, cursorPath: null, cursorCommentId: null, scrollTop: 0, bodyCount: 20, contentRows: 0, columns: 80, reviewSkill: DEFAULT_REVIEW_SKILL, layout: new LayoutCache(), sessionId: null })
34
35const layoutOf = (state: State, columns: number): Layout =>
36 state.layout.get(state.model.files, () => state.store?.all() ?? [], state.model, columns, state.store?.revision ?? 0)
37
38const baseKey = (toplevel: string, branch: string) => `base:${toplevel}:${branch}`
39const openKey = (toplevel: string) => `open:${toplevel}`
40type OpenMarker = { prNumber: number | null; prRepo?: string | null; sessionId?: string }
41const openMarkerOf = (state: State): OpenMarker => ({ prNumber: state.prNumber, prRepo: state.prRepo, sessionId: state.sessionId ?? undefined })
42const riskKeyOf = (commentKey: string) => `risk:${commentKey.slice(Names.STORE_PREFIX.length)}`
43
44const storeOpsOf = ($: EngineInterface): StoreOps => ({
45 get: k => $.store.get(k),
46 set: (k, v) => $.store.set(k, v),
47 delete: k => $.store.delete(k),
48 now: () => $.clock.now(),
49})
50
51const onFail = (state: State, $: EngineInterface) => (error: unknown) => {
52 state.model.footer = String(error)
53 redraw(state, $)
54}
55
56const cancelTimers = (state: State) => {
57 for (const t of Object.values(state.timers)) t?.cancel()
58 state.timers = {}
59}
60
61const redraw = (state: State, $: EngineInterface) => {
62 if (state.timers.redraw) return
63 state.timers.redraw = $.clock.after(REDRAW_MS, () => {
64 state.timers.redraw = undefined
65 void $.ui.invalidate('ui.render')
66 })
67}
68
69const refresh = async (state: State, $: EngineInterface) => {
70 const gen = ++state.generation
71 const run: Runner = runnerOf((argv, init) => $.process.run(argv, init))
72 if (state.model.phase !== 'ready') state.model.phase = 'loading'
73
74 let target: Target | TargetFailure
75 if (state.prNumber !== null) {
76 target = await resolvePrTarget(run, state.prNumber, state.prRepo)
77 if (gen !== state.generation) return
78 } else {
79 const probe = await resolveTarget(run)
80 if (gen !== state.generation) return
81 if ('error' in probe) {
82 state.model = { ...state.model, phase: 'error', error: probe.error }
83 redraw(state, $)
84 return
85 }
86 const override = (await $.store.get(baseKey(probe.toplevel, probe.branch))) as string | undefined
87 if (gen !== state.generation) return
88 target = override ? await resolveTarget(run, override) : probe
89 if (gen !== state.generation) return
90 }
91 if ('error' in target) {
92 state.model = { ...state.model, phase: 'error', error: target.error }
93 redraw(state, $)
94 return
95 }
96 const key = state.prNumber !== null ? CommentStore.prKeyOf(target.repoKey, state.prNumber) : CommentStore.keyOf(target.repoKey, target.branch)
97 if (!state.store || state.store.key !== key) {
98 const freshStore = new CommentStore(storeOpsOf($), key)
99 await freshStore.load()
100 if (gen !== state.generation) return
101 state.store = freshStore
102 const risk = (await $.store.get(riskKeyOf(key))) as Risk | undefined
103 if (gen !== state.generation) return
104 state.model.risk = risk ?? null
105 state.model.showRisk = true
106 }
107 const memoryKey = MemoryStore.keyOf(target.repoKey)
108 if (!state.memory || state.memory.key !== memoryKey) {
109 const freshMemory = new MemoryStore(storeOpsOf($), memoryKey)
110 await freshMemory.load()
111 if (gen !== state.generation) return
112 state.memory = freshMemory
113 }
114 const diff = await fetchDiff(run, target)
115 if (gen !== state.generation) return
116 const lastRefreshAt = await $.clock.now()
117 if (gen !== state.generation) return
118 state.lastHead = target.headSha
119 state.model = { ...state.model, phase: 'ready', error: null, target, files: diff.files, isDiffTruncated: diff.isTruncated, lastRefreshAt }
120 state.model.footer = footerOf(state.store, target, diff)
121 if (state.isOpen) await $.store.set(openKey(target.toplevel), openMarkerOf(state))
122 redraw(state, $)
123}
124
125const footerOf = (store: CommentStore, target: Target, diff: DiffFetch): string => {
126 const source = target.diffSource
127 if (source.kind === 'github' && diff.error) return `no diff from the GitHub API for ${source.nameWithOwner}#${source.number}: ${diff.error}`
128 if (source.kind === 'github' && diff.files.length === 0) return `no diff from the GitHub API for ${source.nameWithOwner}#${source.number} (too large, or no access)`
129 if (diff.error) return `git diff failed: ${diff.error}`
130 const last = diff.files.at(-1)
131 if (diff.isTruncated) return last ? `diff cut at 4 MiB: files after ${last.path} are missing` : 'diff cut at 4 MiB'
132 const orphans = store.orphans(diff.files)
133 if (orphans.length) return `${orphans.length} comment(s) no longer on the diff`
134 return target.sendBlocked ? `send: ${target.sendBlocked}` : `send ready · ${store.open().length} open`
135}
136
137const scheduleRefresh = (state: State, $: EngineInterface) => {
138 if (!state.isOpen || state.prNumber !== null) return
139 state.timers.refresh?.cancel()
140 state.timers.refresh = $.clock.after(REFRESH_DEBOUNCE_MS, () => { void refresh(state, $) })
141}
142
143const openPane = async (state: State, $: EngineInterface) => {
144 state.isOpen = true
145 const opened = await $.ui.open({ id: Names.PANE_ID, title: 'review', focus: true })
146 if (!opened.isPlaced) $.ui.toast(`review pane waits: ${opened.reason}`)
147 if (state.model.target) await $.store.set(openKey(state.model.target.toplevel), openMarkerOf(state))
148 state.timers.poll?.cancel()
149 state.timers.poll = $.clock.every(HEAD_POLL_MS, async () => {
150 if (state.prNumber !== null) return
151 const run: Runner = runnerOf((argv, init) => $.process.run(argv, init))
152 const r = await run(['git', '--no-optional-locks', 'rev-parse', 'HEAD'])
153 if (r.ok && r.stdout.trim() !== state.lastHead) scheduleRefresh(state, $)
154 })
155 await refresh(state, $)
156}
157
158const clearOpenState = async (state: State, $: EngineInterface) => {
159 state.isOpen = false
160 state.chain = null
161 cancelTimers(state)
162 if (state.model.target) await $.store.delete(openKey(state.model.target.toplevel))
163}
164
165const closePane = async (state: State, $: EngineInterface) => {
166 await clearOpenState(state, $)
167 await $.ui.close({ id: Names.PANE_ID })
168}
169
170const entryPointOf = (order: readonly CommentStop[], path: string | null, delta: 1 | -1): number => {
171 if (path === null) return delta === 1 ? -1 : 0
172 const paths = order.map(s => s.path)
173 if (delta === 1) {
174 const first = paths.indexOf(path)
175 return first < 0 ? -1 : first - 1
176 }
177 const last = paths.lastIndexOf(path)
178 return last < 0 ? 0 : last + 1
179}
180
181const stepComment = (state: State, $: EngineInterface, delta: 1 | -1) => {
182 const order = commentOrderOf(state.model.files, state.store?.all() ?? [], state.model, layoutOf(state, state.columns))
183 if (order.length === 0) {
184 state.model.footer = state.model.showSent ? 'no comments' : 'no open comments'
185 redraw(state, $)
186 return
187 }
188 const at = state.cursorCommentId === null ? -1 : order.findIndex(s => s.id === state.cursorCommentId)
189 const from = at >= 0 ? at : entryPointOf(order, state.cursorPath, delta)
190 const to = (from + delta + order.length) % order.length
191 const stop = order[to]
192 if (stop === undefined) return
193 const wrapped = at >= 0 && (delta === 1 ? to < at : to > at)
194 state.cursorCommentId = stop.id
195 state.cursorPath = stop.path
196 if (state.model.collapsed.includes(stop.path)) state.model.collapsed = state.model.collapsed.filter(p => p !== stop.path)
197 state.model.footer = `comment ${to + 1} of ${order.length}${wrapped ? ' (wrapped)' : ''}`
198 const layout = layoutOf(state, state.columns)
199 state.contentRows = layout.total
200 redraw(state, $)
201 centerKey(state, $, `e:${stop.id}`)
202 if (!stop.sent) void $.ui.focus({ requestId: Names.PANE_ID, key: `e:${stop.id}` }).catch(() => {})
203}
204
205const refuseSend = (state: State, $: EngineInterface, why: string): string => {
206 state.model.footer = `cannot send: ${why}`
207 redraw(state, $)
208 return `review: cannot send: ${why}`
209}
210
211const sendReview = async (state: State, $: EngineInterface): Promise<string> => {
212 const target = state.model.target
213 if (!state.store || !target) return 'review: open the pane first'
214 if (target.sendBlocked) return refuseSend(state, $, target.sendBlocked)
215 const payload = payloadOf(state.store.all(), state.model.files, target.headSha)
216 if ('error' in payload) return refuseSend(state, $, payload.error)
217 const run: Runner = runnerOf((argv, init) => $.process.run(argv, init))
218 if (target.mode === 'branch') {
219 const dirty = await uncommittedOf(run, target.toplevel, [...new Set(payload.comments.map(c => c.path))])
220 if (dirty.error) return refuseSend(state, $, `git diff --name-only failed: ${dirty.error}`)
221 if (dirty.paths.length) return refuseSend(state, $, `uncommitted changes in ${dirty.paths.join(', ')}; commit and push first`)
222 }
223 const result = await createPendingReview(run, target, payload)
224 if (!result.ok) {
225 state.model.footer = `send failed: ${result.error}`
226 redraw(state, $)
227 return `review: send failed: ${result.error}`
228 }
229 await state.store.markSent(payload.ids)
230 for (const c of state.store.all()) {
231 if (c.author !== 'claude' || !payload.ids.includes(c.id)) continue
232 await state.memory?.record({ kind: 'accepted', path: c.path, body: c.body }).catch(onFail(state, $))
233 }
234 state.model.footer = `pending review created, submit on GitHub: ${result.url}`
235 redraw(state, $)
236 return `review: pending review created with ${payload.ids.length} comment(s). Submit it on GitHub: ${result.url}`
237}
238
239const prTargetOf = (state: State): string | null => {
240 const t = state.model.target
241 if (t?.mode !== 'pr' || !t.pr) return null
242 return t.diffSource.kind === 'github' ? t.pr.url : String(t.pr.number)
243}
244
245const startRiskAnalysis = (state: State, $: EngineInterface) => {
246 state.model.footer = 'asked Claude: risk analysis'
247 redraw(state, $)
248 const memory = state.memory?.promptText() ?? ''
249 $.clock.after(REDRAW_MS, () => {
250 void $.prompt.submit({ text: riskPromptOf(prTargetOf(state), memory) }).catch(onFail(state, $))
251 })
252}
253
254const runChainStep = (state: State, $: EngineInterface) => {
255 const chain = state.chain
256 const step = chain?.steps.shift()
257 if (!chain || !step) { state.chain = null; return }
258 chain.label = step.label
259 chain.isArmed = true
260 chain.turnId = null
261 void step.run().catch(error => {
262 if (state.chain === chain) state.chain = null
263 onFail(state, $)(error)
264 })
265}
266
267const startClaudeReview = (state: State, $: EngineInterface) => {
268 const target = prTargetOf(state)
269 state.model.footer = `asked Claude: risk analysis, then ${state.reviewSkill}`
270 redraw(state, $)
271 const memory = state.memory?.promptText() ?? ''
272 const plan = reviewPlanOf(state.reviewSkill, target, memory)
273 state.chain = {
274 steps: [
275 { label: 'risk analysis', run: () => $.prompt.submit({ text: riskPromptOf(target, memory) }) },
276 { label: state.reviewSkill, run: () => $.command.run({ command: plan.command, args: plan.args }) },
277 { label: 'draft comments', run: () => $.prompt.submit({ text: plan.followUp }) },
278 ],
279 label: '',
280 isArmed: false,
281 turnId: null,
282 }
283 $.clock.after(REDRAW_MS, () => runChainStep(state, $))
284}
285
286const applyRisk = async (state: State, $: EngineInterface, risk: Risk) => {
287 state.model.risk = risk
288 state.model.showRisk = true
289 const low = new Set(risk.files.filter(f => f.level === 'low').map(f => f.path))
290 state.model.collapsed = [...new Set([...state.model.collapsed, ...state.model.files.map(f => f.path).filter(p => low.has(p))])]
291 state.scrollTop = 0
292 redraw(state, $)
293 if (state.store) await $.store.set(riskKeyOf(state.store.key), risk)
294}
295
296const scrollBy = (state: State, $: EngineInterface, by: number) => {
297 const layout = layoutOf(state, state.columns)
298 const to = snapOffset(layout.tops, layout.total, state.bodyCount, state.scrollTop, by)
299 if (to === state.scrollTop) return
300 state.scrollTop = to
301 redraw(state, $)
302}
303
304const revealKey = (state: State, $: EngineInterface, key: string) => {
305 const layout = layoutOf(state, state.columns)
306 const index = indexOfKey(layout, key)
307 if (index < 0) return
308 const top = layout.tops[index] ?? 0
309 const margin = 2
310 if (top < state.scrollTop + margin) scrollBy(state, $, top - margin - state.scrollTop)
311 else if (top + margin >= state.scrollTop + state.bodyCount) scrollBy(state, $, top + margin + 1 - state.bodyCount - state.scrollTop)
312}
313
314const centerKey = (state: State, $: EngineInterface, key: string) => {
315 const layout = layoutOf(state, state.columns)
316 const index = indexOfKey(layout, key)
317 if (index < 0) return
318 const top = layout.tops[index] ?? 0
319 const height = layout.heights[index] ?? 1
320 const wanted = centeredTopOf(top, height, state.bodyCount)
321 scrollBy(state, $, wanted - state.scrollTop)
322}
323
324const VERBS = new Set(['open', 'pr', 'branch', 'close', 'refresh', 'send', 'clear', 'base', 'claude', 'risk', 'note', 'memory'])
325
326const parseArgs = (raw: string): { verb: string; rest: string[] } => {
327 const words = raw.split(/\s+/).filter(Boolean).filter(w => w !== `/${Names.COMMAND}` && w !== Names.COMMAND)
328 if (words.length === 0) return { verb: 'open', rest: [] }
329 const at = words.findIndex(w => VERBS.has(w.toLowerCase()) || prNumberOf(w) !== null)
330 if (at < 0) return { verb: 'unknown', rest: words }
331 const head = words[at]!
332 const rest = words.slice(at + 1)
333 if (VERBS.has(head.toLowerCase())) return { verb: head.toLowerCase(), rest }
334 return { verb: 'pr', rest: [head, ...rest] }
335}
336
337const statusText = (state: State) => {
338 if (state.model.phase === 'error') return `review: ${state.model.error}`
339 const t = state.model.target
340 if (!t) return 'review: loading'
341 if (t.mode === 'pr' && t.pr) {
342 const where = t.diffSource.kind === 'github' ? `${t.pr.nameWithOwner} ` : ''
343 return `review: ${where}PR #${t.pr.number} ${t.branch} → ${t.base} @${t.headSha.slice(0, 7)}, ${state.model.files.length} file(s), ${state.store?.open().length ?? 0} open comment(s)`
344 }
345 return `review: ${t.branch} → ${t.base}${t.pr ? ` (PR #${t.pr.number})` : ''}, ${state.model.files.length} file(s), ${state.store?.open().length ?? 0} open comment(s)`
346}
347
348const addressOfDraft = (files: readonly FileDiff[], editing: Editing): { path: string; side: Side; line: number; startLine?: number } => {
349 const end = editing.endLine ?? editing.line
350 if (end === editing.line || !inOneHunk(files, editing.path, editing.side, editing.line, end)) return { path: editing.path, side: editing.side, line: editing.line }
351 return { path: editing.path, side: editing.side, line: Math.max(editing.line, end), startLine: Math.min(editing.line, end) }
352}
353
354const actionsOf = (state: State, $: EngineInterface): Actions => ({
355 refresh: () => { void refresh(state, $).catch(onFail(state, $)) },
356 sendReview: () => { void sendReview(state, $).catch(onFail(state, $)) },
357 claudeReview: () => startClaudeReview(state, $),
358 toggleRisk: () => { state.model.showRisk = !state.model.showRisk; redraw(state, $) },
359 toggleFiles: () => { state.model.showFiles = !state.model.showFiles; redraw(state, $) },
360 toggleSent: () => { state.model.showSent = !state.model.showSent; redraw(state, $) },
361 toggleFile: path => {
362 const c = state.model.collapsed
363 state.model.collapsed = c.includes(path) ? c.filter(p => p !== path) : [...c, path]
364 state.cursorPath = path
365 state.cursorCommentId = null
366 redraw(state, $)
367 },
368 focusFile: path => {
369 state.model.collapsed = state.model.files.map(f => f.path).filter(p => p !== path)
370 state.model.showFiles = false
371 state.cursorPath = path
372 state.cursorCommentId = null
373 redraw(state, $)
374 },
375 commentUp: () => stepComment(state, $, -1),
376 commentDown: () => stepComment(state, $, 1),
377 expandAll: () => { if (state.model.collapsed.length) state.model.collapsed = []; redraw(state, $) },
378 startComment: (path, side, line) => {
379 const editing = state.model.editing
380 if (editing && editing.path === path && editing.side === side && editing.endLine === null && line !== editing.line) {
381 editing.endLine = line
382 redraw(state, $)
383 return
384 }
385 state.model.editing = { path, side, line, endLine: null, commentId: null, draft: '' }
386 redraw(state, $)
387 },
388 startEdit: id => {
389 const c = state.store?.all().find(x => x.id === id)
390 if (c) state.model.editing = { path: c.path, side: c.side, line: c.line, endLine: null, commentId: c.id, draft: c.body }
391 redraw(state, $)
392 },
393 remove: id => {
394 const c = state.store?.all().find(x => x.id === id)
395 if (c && c.author === 'claude' && c.sentAt === null) void state.memory?.record({ kind: 'rejected', path: c.path, body: c.body }).catch(onFail(state, $))
396 void state.store?.remove(id).then(() => redraw(state, $)).catch(onFail(state, $))
397 },
398 submitDraft: value => {
399 const editing = state.model.editing
400 const body = value.trim()
401 state.model.editing = null
402 if (!editing || !state.store || !body) { redraw(state, $); return }
403 const prior = editing.commentId ? state.store.all().find(x => x.id === editing.commentId) : undefined
404 if (prior && prior.author === 'claude' && prior.body !== body) {
405 void state.memory?.record({ kind: 'corrected', path: prior.path, body: prior.body, newBody: body }).catch(onFail(state, $))
406 }
407 const done = editing.commentId ? state.store.edit(editing.commentId, body) : state.store.add({ ...addressOfDraft(state.model.files, editing), body, author: 'user' })
408 void Promise.resolve(done).then(() => redraw(state, $)).catch(onFail(state, $))
409 },
410 updateDraft: value => { if (state.model.editing) state.model.editing.draft = value },
411 saveDraft: () => actionsOf(state, $).submitDraft(state.model.editing?.draft ?? ''),
412 ask: () => {
413 const editing = state.model.editing
414 if (!editing || !state.model.target) return
415 const file = fileOf(state.model.files, editing.path)
416 const from = Math.min(editing.line, editing.endLine ?? editing.line)
417 const to = Math.max(editing.line, editing.endLine ?? editing.line)
418 const sel: AskSelection = { path: editing.path, side: editing.side, from, to }
419 const text = file ? askTextOf(state.model.target, file, sel, editing.draft) : editing.draft
420 state.model.editing = null
421 state.model.footer = `asked Claude about ${editing.path}:${from}-${to}`
422 redraw(state, $)
423 void $.prompt.submit({ text }).catch(onFail(state, $))
424 },
425 cancelDraft: () => { state.model.editing = null; redraw(state, $) },
426 cycleBase: () => {
427 void (async () => {
428 if (!state.model.target) return
429 const key = baseKey(state.model.target.toplevel, state.model.target.branch)
430 const current = (await $.store.get(key)) as string | undefined
431 if (current) await $.store.delete(key)
432 else await $.store.set(key, state.model.target.base === 'main' ? 'master' : 'main')
433 await refresh(state, $)
434 })().catch(onFail(state, $))
435 },
436})
437
438const answerCommand = async (state: State, $: EngineInterface, args: string | undefined): Promise<string> => {
439 const raw = (args ?? '').trim()
440 const { verb, rest } = parseArgs(raw)
441 switch (verb) {
442 case 'unknown':
443 return `review: unknown input "${raw}" (use: pr <number|url>, claude, risk, note <text>, memory [clear], branch, close, refresh, send, clear, base <ref>)`
444 case 'close':
445 await closePane(state, $)
446 return 'review: pane closed'
447 case 'refresh':
448 if (state.isOpen) await refresh(state, $); else await openPane(state, $)
449 return statusText(state)
450 case 'send':
451 if (!state.isOpen) await openPane(state, $)
452 return sendReview(state, $)
453 case 'claude':
454 if (!state.isOpen) await openPane(state, $)
455 startClaudeReview(state, $)
456 return `review: asked Claude for a risk analysis, then a review with ${state.reviewSkill} and draft comments`
457 case 'risk':
458 if (!state.isOpen) await openPane(state, $)
459 startRiskAnalysis(state, $)
460 return 'review: asked Claude for a risk analysis'
461 case 'clear':
462 await state.store?.clear()
463 redraw(state, $)
464 return 'review: comments cleared'
465 case 'note': {
466 const text = rest.join(' ').trim()
467 if (!text) return 'review: usage: /diff-review note <what Claude should remember about your reviews>'
468 if (!state.isOpen) await openPane(state, $)
469 if (!state.memory) return 'review: cannot read the repository, so nothing was noted'
470 await state.memory.record({ kind: 'note', body: text })
471 return `review: noted (${state.memory.countOf('note')} notes)`
472 }
473 case 'memory': {
474 if (!state.isOpen) await openPane(state, $)
475 if (rest[0]?.toLowerCase() === 'clear') {
476 await state.memory?.clear()
477 return 'review: feedback memory cleared'
478 }
479 return state.memory?.promptText(8000) || 'review: no feedback memory yet for this repository'
480 }
481 case 'pr': {
482 const ref = prRefOf(rest.join(' '))
483 if (ref === null) return 'review: usage: /diff-review pr <number|url|owner/repo#number>'
484 const n = ref.number
485 state.prNumber = n
486 state.prRepo = ref.nameWithOwner || null
487 state.model.footer = ref.nameWithOwner ? `loading ${ref.nameWithOwner}#${n}` : `loading PR #${n}`
488 state.scrollTop = 0
489 if (!state.isOpen) await openPane(state, $); else await refresh(state, $)
490 return statusText(state)
491 }
492 case 'branch':
493 state.prNumber = null
494 state.prRepo = null
495 await refresh(state, $)
496 return statusText(state)
497 case 'base': {
498 if (state.prNumber !== null) return 'review: base override applies to branch mode only'
499 if (!state.model.target) await openPane(state, $)
500 if (state.model.target) {
501 await $.store.set(baseKey(state.model.target.toplevel, state.model.target.branch), rest.join(' '))
502 await refresh(state, $)
503 }
504 return statusText(state)
505 }
506 default:
507 await openPane(state, $)
508 return statusText(state)
509 }
510}
511
512export const register: Register = (on, options) => {
513 const state = newState()
514 if (typeof options.reviewSkill === 'string' && options.reviewSkill.trim()) state.reviewSkill = options.reviewSkill.trim()
515
516 on('session.start', async ($, e, next) => {
517 const result = await next(e)
518 await $.command.register({
519 name: 'diff-review',
520 description: 'Review the branch diff with draft comments; send them as a pending GitHub review',
521 argumentHint: '[pr <number|url>|claude|risk|note <text>|memory [clear]|branch|close|refresh|send|clear|base <ref>]',
522 })
523 await $.tool.register({
524 name: 'add_comment',
525 description: 'Add a draft review comment on a line of the diff shown in the /diff-review pane. Drafts are triaged by the user before the review is sent. side RIGHT uses new-file line numbers (added or unchanged lines); LEFT uses old-file numbers (removed lines). One finding per call. Refused when the pane is closed or the line is not on the diff.',
526 inputSchema: { type: 'object', required: ['path', 'line', 'body'], properties: {
527 path: { type: 'string', description: 'Repository-relative path as shown in the review pane' },
528 line: { type: 'integer', minimum: 1 },
529 side: { type: 'string', enum: ['LEFT', 'RIGHT'], default: 'RIGHT' },
530 body: { type: 'string', minLength: 1, maxLength: 4000 },
531 } },
532 })
533 await $.tool.register({
534 name: 'list_comments',
535 description: 'List the open draft review comments in the /diff-review pane, grouped by file.',
536 inputSchema: { type: 'object', properties: {} },
537 })
538 await $.tool.register({
539 name: 'set_risk',
540 description: 'Record the risk analysis of the diff shown in the /diff-review pane: overall level, summary, review dimensions that need human judgment, decisions for the reviewer, and a level per file. Low-risk files collapse in the pane; files sort by risk. Call once per analysis; a new call replaces the old one.',
541 inputSchema: { type: 'object', required: ['level', 'summary', 'files'], properties: {
542 level: { type: 'string', enum: ['low', 'medium', 'high'] },
543 summary: { type: 'string', minLength: 1, maxLength: 1000 },
544 dimensions: { type: 'array', items: { type: 'string' }, description: 'Review dimensions needing human judgment, e.g. architecture, security, data, tests' },
545 decisions: { type: 'array', items: { type: 'string' }, description: 'Up to 5 decisions only the reviewer can make, one sentence each' },
546 files: { type: 'array', items: { type: 'object', required: ['path', 'level'], properties: {
547 path: { type: 'string' },
548 level: { type: 'string', enum: ['low', 'medium', 'high'] },
549 reason: { type: 'string', maxLength: 200 },
550 } } },
551 } },
552 })
553 await $.tool.register({
554 name: 'get_diff',
555 description: 'Read the diff shown in the /diff-review pane, with R<n>/L<n> line numbers that add_comment accepts. Optional path returns one file in full.',
556 inputSchema: { type: 'object', properties: { path: { type: 'string', description: 'Repository-relative path; omit for the whole diff' } } },
557 })
558 if (e.isInteractive) {
559 try {
560 state.sessionId = await $.session.id()
561 const run: Runner = runnerOf((argv, init) => $.process.run(argv, init))
562 const top = await run(['git', '--no-optional-locks', 'rev-parse', '--show-toplevel'])
563 if (top.ok) {
564 const key = openKey(top.stdout.trim())
565 const stored = (await $.store.get(key)) as OpenMarker | undefined
566 if (stored && stored.sessionId === state.sessionId) {
567 state.prNumber = stored.prNumber
568 state.prRepo = stored.prRepo ?? null
569 await openPane(state, $)
570 } else if (stored) {
571 await $.store.delete(key)
572 }
573 }
574 } catch {}
575 }
576 return result
577 })
578
579 on('command.run', { command: 'diff-review' }, async ($, e, next) => {
580 const passed = await next(e)
581 return { ...passed, text: await answerCommand(state, $, e.args) }
582 })
583
584 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
585 if (e.surface !== 'terminal' || e.requestId !== Names.PANE_ID) return next(e)
586 const { Box, Text, Button, Input } = await $.ui.resolve(e)
587 const layout = layoutOf(state, e.props.bodyColumns)
588 return paneTree({ Box, Text, Button, Input }, state.model, state.store?.all() ?? [], layout, actionsOf(state, $), e.props.bodyColumns, { path: state.cursorPath, commentId: state.cursorCommentId }, { offset: state.scrollTop, bodyRows: e.props.scroll.bodyRows }, (w, bodyCount) => { state.bodyCount = bodyCount; state.contentRows = w.total; state.columns = e.props.bodyColumns })
589 })
590
591 on('ui.close', { id: 'diff-review' }, async ($, e, next) => {
592 await clearOpenState(state, $)
593 state.model.editing = null
594 return next(e)
595 })
596
597 on('ui.scroll', { requestId: 'diff-review' }, async ($, e) => {
598 scrollBy(state, $, e.by)
599 return {}
600 })
601
602 on('ui.focus', { plugin: 'diff-review' }, async ($, e, next) => {
603 if (e.element?.startsWith('h:')) { state.cursorPath = e.element.slice(2); state.cursorCommentId = null }
604 if (e.element?.startsWith('e:') || e.element?.startsWith('d:')) state.cursorCommentId = e.element.slice(2)
605 if (e.element) revealKey(state, $, e.element)
606 return next(e)
607 })
608
609 on('turn.start', async ($, e, next) => {
610 if (state.chain?.isArmed) {
611 state.chain.turnId = e.turnId
612 state.chain.isArmed = false
613 }
614 return next(e)
615 })
616
617 on('turn.complete', async ($, e, next) => {
618 const chain = state.chain
619 if (chain && e.agentId === undefined && e.turnId === chain.turnId) {
620 chain.turnId = null
621 if (e.reason !== 'answer') {
622 state.chain = null
623 state.model.footer = `claude review stopped: ${chain.label} ended (${e.reason})`
624 redraw(state, $)
625 } else {
626 $.clock.after(REDRAW_MS, () => { if (state.chain === chain) runChainStep(state, $) })
627 }
628 }
629 return next(e)
630 })
631
632 on('tool.call', { tool: EDIT_TOOLS }, async ($, e, next) => {
633 const result = await next(e)
634 scheduleRefresh(state, $)
635 return result
636 })
637
638 on('tool.call', { tool: 'mcp__diff-review__add_comment' }, async ($, e) => {
639 const a = await serveAddComment(e as unknown as Record<string, unknown>, { store: state.store, files: state.model.files, isOpen: state.isOpen })
640 if (a.ok) redraw(state, $)
641 return a.ok ? { result: a.text } : { deny: a.text }
642 })
643
644 on('tool.call', { tool: 'mcp__diff-review__set_risk' }, async ($, e) => {
645 const a = serveSetRisk(e as unknown as Record<string, unknown>, { store: state.store, files: state.model.files, isOpen: state.isOpen, headSha: state.model.target?.headSha })
646 if (a.ok && a.risk) await applyRisk(state, $, a.risk)
647 return a.ok ? { result: a.text } : { deny: a.text }
648 })
649
650 on('tool.call', { tool: 'mcp__diff-review__get_diff' }, async ($, e) => {
651 const a = serveGetDiff(e as unknown as Record<string, unknown>, { store: state.store, files: state.model.files, isOpen: state.isOpen, isDiffTruncated: state.model.isDiffTruncated })
652 return a.ok ? { result: a.text } : { deny: a.text }
653 })
654
655 on('tool.call', { tool: 'mcp__diff-review__list_comments' }, async () => {
656 const a = serveListComments({ store: state.store, files: state.model.files, isOpen: state.isOpen })
657 return a.ok ? { result: a.text } : { deny: a.text }
658 })
659}
660hooks/comments/store.ts 86 lines1import { hasAddress, inOneHunk } from '../diff/address.ts'
2import type { FileDiff, Side } from '../diff/types.ts'
3import { Names } from '../names.ts'
4import type { Author, Comment, ReviewState } from './types.ts'
5
6export type StoreOps = {
7 get: (key: string) => Promise<unknown>
8 set: (key: string, value: unknown) => Promise<void>
9 delete: (key: string) => Promise<void>
10 now: () => Promise<number>
11}
12
13const newId = () => Math.random().toString(36).slice(2, 10)
14
15export class CommentStore {
16 private state: ReviewState = { comments: [] }
17 revision = 0
18
19 constructor(private readonly ops: StoreOps, readonly key: string) {}
20
21 static keyOf(toplevel: string, branch: string): string {
22 return `${Names.STORE_PREFIX}${toplevel}:${branch}`
23 }
24
25 static prKeyOf(toplevel: string, prNumber: number): string {
26 return `${Names.STORE_PREFIX}${toplevel}:pr-${prNumber}`
27 }
28
29 async load(): Promise<void> {
30 const raw = (await this.ops.get(this.key)) as ReviewState | undefined
31 this.state = raw && Array.isArray(raw.comments) ? raw : { comments: [] }
32 this.revision++
33 }
34
35 all(): readonly Comment[] { return this.state.comments.map(c => ({ ...c })) }
36
37 open(): Comment[] { return this.state.comments.filter(c => c.sentAt === null).map(c => ({ ...c })) }
38
39 forLine(path: string, side: Side, line: number): Comment[] {
40 return this.state.comments.filter(c => c.path === path && c.side === side && c.line === line).map(c => ({ ...c }))
41 }
42
43 async add(input: { path: string; side: Side; line: number; startLine?: number; body: string; author: Author }): Promise<Comment> {
44 const comment: Comment = { id: newId(), ...input, createdAt: await this.ops.now(), sentAt: null }
45 this.state.comments.push(comment)
46 await this.save()
47 return { ...comment }
48 }
49
50 async edit(id: string, body: string): Promise<boolean> {
51 const c = this.state.comments.find(x => x.id === id)
52 if (!c || c.sentAt !== null) return false
53 c.body = body
54 await this.save()
55 return true
56 }
57
58 async remove(id: string): Promise<boolean> {
59 const c = this.state.comments.find(x => x.id === id)
60 if (!c || c.sentAt !== null) return false
61 this.state.comments = this.state.comments.filter(x => x.id !== id)
62 await this.save()
63 return true
64 }
65
66 async markSent(ids: readonly string[]): Promise<void> {
67 const at = await this.ops.now()
68 for (const c of this.state.comments) if (ids.includes(c.id)) c.sentAt = at
69 await this.save()
70 }
71
72 async clear(): Promise<void> {
73 this.state = { comments: [] }
74 await this.ops.delete(this.key)
75 }
76
77 orphans(files: readonly FileDiff[]): Comment[] {
78 return this.open().filter(c => (c.startLine === undefined ? !hasAddress(files, c.path, c.side, c.line) : !inOneHunk(files, c.path, c.side, c.startLine, c.line)))
79 }
80
81 private save(): Promise<void> {
82 this.revision++
83 return this.ops.set(this.key, this.state)
84 }
85}
86hooks/diff/address.ts 35 lines1import type { DiffLine, FileDiff, Hunk, Side } from './types.ts'
2
3export function addressOf(line: DiffLine): { side: Side; line: number } {
4 if (line.kind === 'del') return { side: 'LEFT', line: line.oldLine ?? 0 }
5 return { side: 'RIGHT', line: line.newLine ?? 0 }
6}
7
8export function fileOf(files: readonly FileDiff[], path: string): FileDiff | undefined {
9 return files.find(f => f.path === path || f.oldPath === path)
10}
11
12export function hasAddress(files: readonly FileDiff[], path: string, side: Side, line: number): boolean {
13 const file = fileOf(files, path)
14 if (!file) return false
15 return file.hunks.some(h => hunkHas(h, side, line))
16}
17
18const hunkHas = (hunk: Hunk, side: Side, line: number): boolean =>
19 hunk.lines.some(l => (side === 'LEFT' ? l.oldLine === line : l.newLine === line))
20
21export function inOneHunk(files: readonly FileDiff[], path: string, side: Side, from: number, to: number): boolean {
22 const file = fileOf(files, path)
23 if (!file) return false
24 return file.hunks.some(h => hunkHas(h, side, from) && hunkHas(h, side, to))
25}
26
27export function nearestLines(files: readonly FileDiff[], path: string, side: Side, limit = 8): number[] {
28 const file = fileOf(files, path)
29 if (!file) return []
30 const numbers = file.hunks
31 .flatMap(h => h.lines.map(l => (side === 'LEFT' ? l.oldLine : l.newLine)))
32 .filter((n): n is number => n !== null)
33 return numbers.slice(0, limit)
34}
35hooks/diff/types.ts 5 lines1export type Side = 'LEFT' | 'RIGHT'
2export type DiffLine = { kind: 'context' | 'add' | 'del'; text: string; oldLine: number | null; newLine: number | null }
3export type Hunk = { header: string; oldStart: number; newStart: number; lines: DiffLine[] }
4export type FileDiff = { path: string; oldPath: string | null; status: 'modified' | 'added' | 'deleted' | 'renamed'; isBinary: boolean; isLarge: boolean; hunks: Hunk[] }
5hooks/send/github.ts 20 lines1import type { Runner } from '../git/run.ts'
2import type { Target } from '../git/target.ts'
3import type { Payload } from './payload.ts'
4
5export type ExportResult = { ok: true; url: string } | { ok: false; error: string }
6
7export async function createPendingReview(run: Runner, target: Target, payload: Payload): Promise<ExportResult> {
8 if (!target.pr) return { ok: false, error: 'no pull request for this branch' }
9 const body = JSON.stringify({ commit_id: payload.commit_id, comments: payload.comments })
10 const endpoint = `repos/${target.pr.nameWithOwner}/pulls/${target.pr.number}/reviews`
11 const r = await run(['gh', 'api', '-X', 'POST', endpoint, '--input', '-'], { stdin: body, timeoutMs: 60000 })
12 if (!r.ok) return { ok: false, error: r.stderr.split('\n')[0] || 'gh api failed' }
13 try {
14 const j = JSON.parse(r.stdout) as { html_url?: string }
15 return { ok: true, url: j.html_url ?? target.pr.url }
16 } catch {
17 return { ok: true, url: target.pr.url }
18 }
19}
20hooks/send/payload.ts 30 lines1import type { Comment } from '../comments/types.ts'
2import { hasAddress, inOneHunk } from '../diff/address.ts'
3import type { FileDiff, Side } from '../diff/types.ts'
4
5export type PayloadComment = { path: string; side: Side; line: number; start_line?: number; start_side?: Side; body: string }
6export type Payload = { commit_id: string; comments: PayloadComment[]; ids: string[] }
7export type PayloadError = { error: string }
8
9export const lineLabelOf = (c: Pick<Comment, 'line' | 'startLine'>): string =>
10 c.startLine === undefined ? String(c.line) : `${c.startLine}-${c.line}`
11
12const commentOf = (c: Comment): PayloadComment =>
13 c.startLine === undefined
14 ? { path: c.path, side: c.side, line: c.line, body: c.body }
15 : { path: c.path, side: c.side, start_line: c.startLine, start_side: c.side, line: c.line, body: c.body }
16
17export function payloadOf(comments: readonly Comment[], files: readonly FileDiff[], headSha: string): Payload | PayloadError {
18 const open = comments.filter(c => c.sentAt === null)
19 if (open.length === 0) return { error: 'no open comments' }
20 for (const c of open) {
21 const isOnDiff = c.startLine === undefined ? hasAddress(files, c.path, c.side, c.line) : inOneHunk(files, c.path, c.side, c.startLine, c.line)
22 if (!isOnDiff) return { error: `off the diff: ${c.path}:${lineLabelOf(c)} (${c.side})` }
23 }
24 return {
25 commit_id: headSha,
26 comments: open.map(commentOf),
27 ids: open.map(c => c.id),
28 }
29}
30hooks/git/fetch-diff.ts 33 lines1import { parseUnifiedDiff } from '../diff/parse.ts'
2import type { FileDiff } from '../diff/types.ts'
3import type { Runner } from './run.ts'
4import type { Target } from './target.ts'
5
6export const MAX_FILE_DIFF_BYTES = 204800
7
8const argvOf = (target: Target): string[] => {
9 if (target.diffSource.kind === 'github') {
10 const { nameWithOwner, number } = target.diffSource
11 return ['gh', 'api', `repos/${nameWithOwner}/pulls/${number}`, '-H', 'Accept: application/vnd.github.v3.diff']
12 }
13 const base = ['git', '--no-optional-locks', 'diff', '--no-color', '-U3', '--find-renames', target.mergeBase]
14 return target.mode === 'pr' ? [...base, target.headSha] : base
15}
16
17export type DiffFetch = { files: FileDiff[]; error: string | null; isTruncated: boolean }
18
19export async function fetchDiff(run: Runner, target: Target): Promise<DiffFetch> {
20 const r = await run(argvOf(target), { timeoutMs: 60000 })
21 if (!r.ok) return { files: [], error: r.stderr.split('\n')[0] || `exit code ${r.exitCode}`, isTruncated: false }
22 const sections = r.stdout.split(/^(?=diff --git )/m).filter(s => s.length > 0)
23 const kept = sections.map(s => (s.length > MAX_FILE_DIFF_BYTES ? s.split('\n').slice(0, 4).join('\n') + '\n' : s))
24 const files = parseUnifiedDiff(kept.join(''))
25 sections.forEach((s, i) => {
26 const f = files[i]
27 if (f && s.length > MAX_FILE_DIFF_BYTES) { f.isLarge = true; f.hunks = [] }
28 })
29 const last = files.at(-1)
30 if (r.isStdoutTruncated && last) { last.isLarge = true; last.hunks = [] }
31 return { files, error: null, isTruncated: r.isStdoutTruncated }
32}
33hooks/git/run.ts 17 lines1export type Run = { ok: boolean; stdout: string; stderr: string; exitCode: number; isStdoutTruncated: boolean }
2export type RunInit = { stdin?: string; timeoutMs?: number; cwd?: string }
3export type Runner = (argv: readonly string[], init?: RunInit) => Promise<Run>
4
5export type ProcessRun = (argv: readonly string[], init?: RunInit) => Promise<{ exitCode: number; stdout: string; stderr: string; isStdoutTruncated?: boolean }>
6
7export function runnerOf(run: ProcessRun): Runner {
8 return async (argv, init) => {
9 try {
10 const r = await run(argv, init)
11 return { ok: r.exitCode === 0, stdout: r.stdout, stderr: r.stderr, exitCode: r.exitCode, isStdoutTruncated: r.isStdoutTruncated ?? false }
12 } catch (error) {
13 return { ok: false, stdout: '', stderr: String(error), exitCode: 1, isStdoutTruncated: false }
14 }
15 }
16}
17hooks/git/uncommitted.ts 11 lines1import type { Runner } from './run.ts'
2
3export type Uncommitted = { paths: string[]; error: string | null }
4
5export async function uncommittedOf(run: Runner, toplevel: string, paths: readonly string[]): Promise<Uncommitted> {
6 if (paths.length === 0) return { paths: [], error: null }
7 const r = await run(['git', '--no-optional-locks', 'diff', '--name-only', 'HEAD', '--', ...paths], { cwd: toplevel })
8 if (!r.ok) return { paths: [], error: r.stderr.split('\n')[0] || `exit code ${r.exitCode}` }
9 return { paths: r.stdout.split('\n').map(p => p.trim()).filter(Boolean), error: null }
10}
11hooks/memory/store.ts 120 lines1import type { StoreOps } from '../comments/store.ts'
2
3export type MemoryKind = 'rejected' | 'corrected' | 'accepted' | 'note'
4export type MemoryEntry = { kind: MemoryKind; at: number; path?: string; body: string; newBody?: string }
5export type MemoryState = { entries: MemoryEntry[] }
6
7export const MEMORY_PREFIX = 'memory:'
8export const MAX_ENTRIES = 300
9export const MAX_NOTES = 20
10export const MAX_REJECTED = 15
11export const MAX_CORRECTED = 10
12
13export const MEMORY_HEADING = 'Reviewer preferences for this repository (learned from earlier reviews):'
14const REJECTED_HEADING = 'Drafts the reviewer deleted (do not raise these again unless clearly wrong):'
15const CORRECTED_HEADING = 'Drafts the reviewer rewrote (match this tone and precision):'
16
17const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
18
19const cut = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`)
20
21const fitLines = (lines: readonly string[], maxChars: number): string => {
22 const kept: string[] = []
23 let length = 0
24 for (const line of lines) {
25 const next = length + line.length + (kept.length ? 1 : 0)
26 if (next > maxChars) break
27 kept.push(line)
28 length = next
29 }
30 return kept.join('\n')
31}
32
33export class MemoryStore {
34 private state: MemoryState = { entries: [] }
35 revision = 0
36
37 constructor(private readonly ops: StoreOps, readonly key: string) {}
38
39 static keyOf(toplevel: string): string {
40 return `${MEMORY_PREFIX}${toplevel}`
41 }
42
43 async load(): Promise<void> {
44 const raw = (await this.ops.get(this.key)) as MemoryState | undefined
45 this.state = raw && Array.isArray(raw.entries) ? raw : { entries: [] }
46 this.revision++
47 }
48
49 all(): readonly MemoryEntry[] {
50 return this.state.entries.map(e => ({ ...e }))
51 }
52
53 countOf(kind: MemoryKind): number {
54 return this.state.entries.filter(e => e.kind === kind).length
55 }
56
57 async record(entry: Omit<MemoryEntry, 'at'>): Promise<MemoryEntry> {
58 const full: MemoryEntry = { ...entry, body: oneLine(entry.body), at: await this.ops.now() }
59 if (typeof full.newBody === 'string') full.newBody = oneLine(full.newBody)
60 this.state.entries.push(full)
61 this.capEntries()
62 await this.save()
63 return { ...full }
64 }
65
66 async removeNote(index: number): Promise<boolean> {
67 const target = this.state.entries.filter(e => e.kind === 'note')[index]
68 if (!target) return false
69 this.state.entries = this.state.entries.filter(e => e !== target)
70 await this.save()
71 return true
72 }
73
74 async clear(): Promise<void> {
75 this.state = { entries: [] }
76 this.revision++
77 await this.ops.delete(this.key)
78 }
79
80 promptText(maxChars = 3500): string {
81 if (this.state.entries.length === 0) return ''
82 const notes = this.state.entries.filter(e => e.kind === 'note').slice(-MAX_NOTES)
83 const rejected = this.state.entries.filter(e => e.kind === 'rejected').slice(-MAX_REJECTED)
84 const corrected = this.state.entries.filter(e => e.kind === 'corrected').slice(-MAX_CORRECTED)
85 const accepted = this.countOf('accepted')
86
87 const lines: string[] = [MEMORY_HEADING]
88 for (const n of notes) lines.push(`- ${oneLine(n.body)}`)
89 if (rejected.length) {
90 lines.push(REJECTED_HEADING)
91 for (const r of rejected) lines.push(`- ${r.path ?? 'unknown'}: ${cut(oneLine(r.body), 160)}`)
92 }
93 if (corrected.length) {
94 lines.push(CORRECTED_HEADING)
95 for (const c of corrected) lines.push(`- ${c.path ?? 'unknown'}: "${cut(oneLine(c.body), 100)}" -> "${cut(oneLine(c.newBody ?? ''), 100)}"`)
96 }
97 if (accepted > 0) lines.push(`Accepted drafts so far: ${accepted}.`)
98 if (lines.length === 1) return ''
99 return fitLines(lines, maxChars)
100 }
101
102 private capEntries(): void {
103 let over = this.state.entries.length - MAX_ENTRIES
104 if (over <= 0) return
105 const drop = new Set<number>()
106 for (let i = 0; i < this.state.entries.length && over > 0; i++) {
107 if (this.state.entries[i]?.kind !== 'note') { drop.add(i); over-- }
108 }
109 for (let i = 0; i < this.state.entries.length && over > 0; i++) {
110 if (!drop.has(i)) { drop.add(i); over-- }
111 }
112 this.state.entries = this.state.entries.filter((_, i) => !drop.has(i))
113 }
114
115 private save(): Promise<void> {
116 this.revision++
117 return this.ops.set(this.key, this.state)
118 }
119}
120hooks/git/target.ts 191 lines1import type { Runner } from './run.ts'
2
3export type DiffSource = { kind: 'git' } | { kind: 'github'; nameWithOwner: string; number: number }
4
5export type Target = {
6 mode: 'branch' | 'pr'
7 toplevel: string
8 repoKey: string
9 branch: string
10 base: string
11 mergeBase: string
12 headSha: string
13 headRef: string
14 diffSource: DiffSource
15 pr: { number: number; url: string; headRef: string; nameWithOwner: string } | null
16 sendBlocked: string | null
17}
18export type TargetFailure = { error: string }
19
20const GIT = ['git', '--no-optional-locks']
21
22type PrView = { number: number; baseRefName: string; headRefName: string; url: string }
23type PrViewFull = { number: number; url: string; baseRefName: string; headRefName: string; headRefOid: string }
24
25async function defaultBase(run: Runner): Promise<string> {
26 const sym = await run([...GIT, 'symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'])
27 if (sym.ok) return sym.stdout.trim().replace(/^refs\/remotes\/origin\//, '')
28 for (const candidate of ['main', 'master']) {
29 const r = await run([...GIT, 'rev-parse', '--verify', '--quiet', `origin/${candidate}`])
30 if (r.ok) return candidate
31 }
32 return 'main'
33}
34
35export async function resolveTarget(run: Runner, baseOverride: string | null = null): Promise<Target | TargetFailure> {
36 const top = await run([...GIT, 'rev-parse', '--show-toplevel'])
37 if (!top.ok) return { error: 'not a git repository' }
38 const br = await run([...GIT, 'rev-parse', '--abbrev-ref', 'HEAD'])
39 if (!br.ok || br.stdout.trim() === 'HEAD') return { error: 'detached HEAD: check out a branch' }
40 const branch = br.stdout.trim()
41
42 let pr: Target['pr'] = null
43 let prBase: string | null = null
44 let sendBlocked: string | null = null
45 const prView = await run(['gh', 'pr', 'view', '--json', 'number,baseRefName,headRefName,url'])
46 if (prView.ok) {
47 try {
48 const j = JSON.parse(prView.stdout) as PrView
49 const repo = await run(['gh', 'repo', 'view', '--json', 'nameWithOwner'])
50 const nameWithOwner = repo.ok ? (JSON.parse(repo.stdout) as { nameWithOwner: string }).nameWithOwner : ''
51 pr = { number: j.number, url: j.url, headRef: j.headRefName, nameWithOwner }
52 prBase = j.baseRefName
53 if (!nameWithOwner) sendBlocked = 'gh repo view failed'
54 } catch {
55 sendBlocked = 'gh returned unreadable JSON'
56 }
57 } else {
58 sendBlocked = /not found|no pull requests/i.test(prView.stderr) ? 'no pull request for this branch' : 'gh not available'
59 }
60
61 const base = baseOverride ?? prBase ?? (await defaultBase(run))
62 let mb = await run([...GIT, 'merge-base', `origin/${base}`, 'HEAD'])
63 if (!mb.ok) mb = await run([...GIT, 'merge-base', base, 'HEAD'])
64 if (!mb.ok) return { error: `cannot find merge base with ${base}` }
65 const head = await run([...GIT, 'rev-parse', 'HEAD'])
66 const headSha = head.stdout.trim()
67 const toplevel = top.stdout.trim()
68
69 if (pr && !sendBlocked) {
70 const remoteHead = await run([...GIT, 'rev-parse', `origin/${pr.headRef}`])
71 if (!remoteHead.ok || remoteHead.stdout.trim() !== headSha) sendBlocked = `push first: HEAD is not on origin/${pr.headRef}`
72 }
73
74 return { mode: 'branch', toplevel, repoKey: toplevel, branch, base, mergeBase: mb.stdout.trim(), headSha, headRef: 'HEAD', diffSource: { kind: 'git' }, pr, sendBlocked }
75}
76
77export type PrRef = { number: number; nameWithOwner: string }
78
79export function prNumberOf(arg: string): number | null {
80 const trimmed = arg.trim()
81 const hash = /^(?:[^/\s]+\/[^/\s]+)?#(\d+)$/.exec(trimmed)
82 if (hash) return Number(hash[1])
83 if (/^\d+$/.test(trimmed)) return Number(trimmed)
84 const url = /\/pull\/(\d+)(?:\/|$)/.exec(trimmed)
85 if (url) return Number(url[1])
86 return null
87}
88
89export function nameWithOwnerOf(arg: string): string {
90 const trimmed = arg.trim()
91 const hash = /^([^/\s]+\/[^/\s]+)#\d+$/.exec(trimmed)
92 if (hash) return hash[1]!
93 const url = /(?:^|github\.com[:/])([^/\s]+\/[^/\s]+)\/pull\/\d+/.exec(trimmed)
94 return url?.[1] ?? ''
95}
96
97export function prRefOf(arg: string): PrRef | null {
98 const number = prNumberOf(arg)
99 if (number === null) return null
100 return { number, nameWithOwner: nameWithOwnerOf(arg) }
101}
102
103export async function remoteOf(run: Runner, nameWithOwner: string): Promise<string> {
104 if (!nameWithOwner) return 'origin'
105 const remotes = await run([...GIT, 'remote', '-v'])
106 if (!remotes.ok) return 'origin'
107 const want = nameWithOwner.toLowerCase()
108 const names: string[] = []
109 for (const line of remotes.stdout.split('\n')) {
110 const m = /^(\S+)\s+(\S+)\s+\(fetch\)$/.exec(line.trim())
111 if (!m) continue
112 const repo = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(m[2]!)?.[1]?.toLowerCase()
113 if (repo === want) names.push(m[1]!)
114 }
115 if (names.includes('origin')) return 'origin'
116 return names[0] ?? ''
117}
118
119export async function resolvePrTarget(run: Runner, prNumber: number, repoOverride: string | null = null): Promise<Target | TargetFailure> {
120 const top = await run([...GIT, 'rev-parse', '--show-toplevel'])
121 if (!top.ok) return { error: 'not a git repository' }
122 const toplevel = top.stdout.trim()
123
124 const repoArgs = repoOverride ? ['--repo', repoOverride] : []
125 const prView = await run(['gh', 'pr', 'view', String(prNumber), ...repoArgs, '--json', 'number,url,baseRefName,headRefName,headRefOid'])
126 if (!prView.ok) {
127 const first = prView.stderr.split('\n')[0] || 'unknown error'
128 const hint = !repoOverride && /multiple remotes|set-default/i.test(prView.stderr) ? ' (run: gh repo set-default)' : ''
129 return { error: `gh pr view failed: ${first}${hint}` }
130 }
131 let j: PrViewFull
132 try {
133 j = JSON.parse(prView.stdout) as PrViewFull
134 } catch {
135 return { error: 'gh pr view failed: unreadable JSON' }
136 }
137
138 let sendBlocked: string | null = null
139 let nameWithOwner = nameWithOwnerOf(j.url) || repoOverride || ''
140 if (!nameWithOwner) {
141 const repo = await run(['gh', 'repo', 'view', '--json', 'nameWithOwner'])
142 try {
143 nameWithOwner = repo.ok ? (JSON.parse(repo.stdout) as { nameWithOwner: string }).nameWithOwner : ''
144 } catch {
145 nameWithOwner = ''
146 }
147 if (!nameWithOwner) sendBlocked = 'gh repo view failed'
148 }
149
150 const pr = { number: j.number, url: j.url, headRef: j.headRefName, nameWithOwner }
151 const remote = await remoteOf(run, nameWithOwner)
152 if (!remote) {
153 return {
154 mode: 'pr',
155 toplevel,
156 repoKey: nameWithOwner,
157 branch: j.headRefName,
158 base: j.baseRefName,
159 mergeBase: '',
160 headSha: j.headRefOid,
161 headRef: '',
162 diffSource: { kind: 'github', nameWithOwner, number: j.number },
163 pr,
164 sendBlocked,
165 }
166 }
167
168 const headRef = `refs/remotes/diff-review/pr-${prNumber}`
169 const fetchHead = await run([...GIT, 'fetch', '--quiet', remote, `+pull/${prNumber}/head:${headRef}`])
170 if (!fetchHead.ok) return { error: `fetch failed: ${fetchHead.stderr.split('\n')[0] || 'unknown error'}` }
171 const fetchBase = await run([...GIT, 'fetch', '--quiet', remote, j.baseRefName])
172 if (!fetchBase.ok) return { error: `fetch failed: ${fetchBase.stderr.split('\n')[0] || 'unknown error'}` }
173
174 const mb = await run([...GIT, 'merge-base', `${remote}/${j.baseRefName}`, headRef])
175 if (!mb.ok) return { error: `cannot find merge base with ${j.baseRefName}` }
176
177 return {
178 mode: 'pr',
179 toplevel,
180 repoKey: toplevel,
181 branch: j.headRefName,
182 base: j.baseRefName,
183 mergeBase: mb.stdout.trim(),
184 headSha: j.headRefOid,
185 headRef,
186 diffSource: { kind: 'git' },
187 pr,
188 sendBlocked,
189 }
190}
191hooks/names.ts 6 lines1export const Names = {
2 PANE_ID: 'diff-review',
3 COMMAND: 'diff-review',
4 STORE_PREFIX: 'review:',
5} as const
6