A review queue for the open temperlang/temper PRs from one author: grouped, sized, overlap-aware, with a guided review per PR

A Claude Code mod for reviewing a batch of open pull requests on temperlang/temper. It was written for the 50 or so fix PRs from notactuallytreyanastasio (plus #507, the Elixir backend), and works for any author.
At a Claude Code prompt in a terminal, add the marketplace, then install from it:
/plugin marketplace add notactuallytreyanastasio/temper-pr-queue
/plugin install temper-pr-queue@temper-pr-queue
Or from a shell:
claude plugin marketplace add notactuallytreyanastasio/temper-pr-queue
claude plugin install temper-pr-queue@temper-pr-queue
claude plugin list should then show temper-pr-queue@temper-pr-queue as enabled, and a new session has /pr-queue and /pr-review. It needs gh, logged in, on your PATH.
The mod is a function-hooks module (hooks/register.tsx), which is newer than other plugin kinds. It was built and tested on Claude Code 2.1.296. On an older version the install can succeed while the commands never appear; claude --version tells you which you have, and claude update moves you forward.
If /pr-queue answers that gh was not found, Claude Code was started without gh on its PATH (the desktop app and IDEs do not read your shell profile). The mod also looks in /opt/homebrew/bin, /usr/local/bin and /usr/bin; otherwise start claude from a terminal where gh --version works.
To take a new version:
claude plugin marketplace update temper-pr-queue
claude plugin update temper-pr-queue@temper-pr-queue
then start a new session.
/pr-queue [login] fetches the author's open PRs on temperlang/temper and opens a pane that groups them by the subsystem prefix in their titles (frontend, be-rust, be-py, ...). Each row shows the issues the PR fixes, its size, whether GitHub can merge it, its review state, and the other queued PRs that edit the same source files. The command's reply lists a suggested merge order.
/pr-review <number> asks Claude to review one PR for merging. The prompt it queues has Claude read the PR and the issues it fixes, check it out in a worktree next to yours, build it and origin/main, and run the PR's reproduction case from temper-issue-repros with both. It then runs the tests of the modules the diff touches, confirms the regression test fails with the fix reverted, tries a program or two beside the fixed shape, and says whether merge order matters given the PRs that share its files. It ends with a verdict and the evidence. It does not comment, approve or push on GitHub unless you ask it to.
The pr_queue tool gives Claude the same data plus the merge order, so a question like "which of these can I merge without rebasing anything?" gets answered from the queue rather than from guesses.
Two PRs overlap when both change the same file. Files that every PR adding a functional test regenerates (functional-test-matrix.md, FunctionalTests.kt, FunctionalTestSuiteI.kt, FunctionalTestStatus.kt, the suite's config.temper.md, the docs snippet hashes) are left out: merging two such PRs means rerunning the generator, not resolving a conflict.
A PR over 2,000 changed lines that carries copies of other PRs' changes, as #507 does, is listed as "also in #507" on those rows instead of as an overlap. Merging one of the small PRs shrinks #507's diff.
The suggested order puts independent, mergeable, small PRs first, then PRs that share files (fewest overlaps and smallest first, so each later one has less to rebase over), then anything GitHub reports as conflicting, then PRs over 2,000 lines, then drafts.
It reads GitHub's mergeable per PR against main. Two PRs that each merge cleanly on their own can still conflict with each other; the overlap column is the warning for that, not a guarantee either way.
The review prompt is a checklist for Claude, not a script. How well a review goes depends on the PR and on the machine: building every backend needs the JDKs, Python, Rust, Node, a C++ toolchain, Lua and Elixir that the temper repo expects.
The repository and the default author are constants at the top of hooks/register.tsx.
claude --plugin-dir /path/to/temper-pr-queue
claude plugin validate /path/to/temper-pr-queue
claude plugin test /path/to/temper-pr-queue
tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code writes when it loads the mod from a folder; run tsc -p . after that.
hooks/register.tsx 260 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookFailure, Register } from 'claude-code'
3
4import type { Queue, QueuedPr } from '../types'
5
6const REPO = 'temperlang/temper'
7const DEFAULT_AUTHOR = 'notactuallytreyanastasio'
8const PANE = 'temper-pr-queue'
9const TOOL = 'pr_queue'
10const UMBRELLA_LINES = 2000
11
12const queue = atom({ plugin: 'temper-pr-queue', key: 'queue' } as const, null)
13
14// Files every PR that adds a functional test regenerates. Two PRs touching
15// them merge with a regeneration, not a real conflict, so they are not
16// counted as overlap.
17const REGENERATED = [
18 /functional-test-matrix\.md$/,
19 /FunctionalTests\.kt$/,
20 /FunctionalTestSuiteI\.kt$/,
21 /FunctionalTestStatus\.kt$/,
22 /functional-test-suite\/src\/commonMain\/resources\/config\.temper\.md$/,
23 /\.snippet-hashes\.json$/,
24 /helpful-snippets\.json$/,
25]
26
27type GhPr = {
28 number: number
29 title: string
30 body: string
31 files: { path: string }[] | null
32 additions: number
33 deletions: number
34 mergeable: string
35 reviewDecision: string
36 isDraft: boolean
37}
38
39export function toQueue(raw: GhPr[], author: string, fetchedAt: string): Queue {
40 // A PR this big (the Elixir backend, #507) carries copies of many fixes on
41 // purpose; sharing files with it says the fix shrinks it, not that the two
42 // conflict, so it is listed apart from real overlaps.
43 const isUmbrella = (p: GhPr) => p.additions + p.deletions > UMBRELLA_LINES
44 const umbrellas = new Set(raw.filter(isUmbrella).map(p => p.number))
45 const owners = new Map<string, number[]>()
46 for (const pr of raw) {
47 for (const f of pr.files ?? []) {
48 if (REGENERATED.some(r => r.test(f.path))) continue
49 owners.set(f.path, [...(owners.get(f.path) ?? []), pr.number])
50 }
51 }
52 const prs: QueuedPr[] = raw.map(pr => {
53 const files = (pr.files ?? []).map(f => f.path)
54 const overlaps = new Set<number>()
55 const alsoIn = new Set<number>()
56 for (const path of files) {
57 for (const n of owners.get(path) ?? []) {
58 if (n === pr.number) continue
59 if (umbrellas.has(n) && !isUmbrella(pr)) alsoIn.add(n)
60 else if (!umbrellas.has(pr.number)) overlaps.add(n)
61 }
62 }
63 const colon = pr.title.indexOf(':')
64 const subsystem = colon > 0 && colon < 24 ? pr.title.slice(0, colon).trim() : 'other'
65 const fixes = [...(pr.body ?? '').matchAll(/\b(?:Fixes|Refs)\s+#(\d+)/gi)].map(m => Number(m[1]))
66 return {
67 number: pr.number,
68 title: pr.title,
69 subsystem,
70 fixes,
71 additions: pr.additions,
72 deletions: pr.deletions,
73 files,
74 mergeable: pr.mergeable,
75 review: pr.reviewDecision || 'NONE',
76 isDraft: pr.isDraft,
77 overlaps: [...overlaps].sort((a, b) => a - b),
78 alsoIn: [...alsoIn].sort((a, b) => a - b),
79 }
80 })
81 prs.sort((a, b) => a.subsystem.localeCompare(b.subsystem) || a.number - b.number)
82 return { repo: REPO, author, fetchedAt, prs }
83}
84
85// Independent, small, ready PRs first; then those that share files, smallest
86// first, so each later one rebases over less; drafts and anything over 2,000
87// changed lines last.
88export function mergeOrder(q: Queue): number[] {
89 const weight = (p: QueuedPr) =>
90 (p.isDraft ? 1e9 : 0) +
91 (p.additions + p.deletions > UMBRELLA_LINES ? 1e8 : 0) +
92 (p.mergeable === 'CONFLICTING' ? 1e7 : 0) +
93 p.overlaps.length * 1e5 +
94 p.additions + p.deletions
95 return [...q.prs].sort((a, b) => weight(a) - weight(b) || a.number - b.number).map(p => p.number)
96}
97
98// Claude Code started from the desktop app or an IDE does not get the shell's
99// PATH, so gh installed by Homebrew is not found by name. These are the usual
100// places it lives, tried in order after the bare name.
101const GH_CANDIDATES = ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/home/linuxbrew/.linuxbrew/bin/gh', '/usr/bin/gh']
102
103export const GH_MISSING =
104 'gh (the GitHub CLI) was not found on the PATH Claude Code started with, nor in /opt/homebrew/bin, ' +
105 '/usr/local/bin or /usr/bin. Install it from https://cli.github.com, run `gh auth login`, and start ' +
106 'Claude Code from a shell where `gh --version` works.'
107
108async function fetchQueue($: EngineInterface, author: string): Promise<Queue> {
109 const fields = 'number,title,body,files,additions,deletions,mergeable,reviewDecision,isDraft'
110 const args = ['pr', 'list', '-R', REPO, '--author', author, '--state', 'open', '--limit', '200', '--json', fields]
111 const failed = (error: string): Queue => ({ repo: REPO, author, fetchedAt: new Date().toISOString(), prs: [], error })
112 for (const gh of GH_CANDIDATES) {
113 let ran
114 try {
115 ran = await $.process.run([gh, ...args], { timeoutMs: 120_000 })
116 } catch {
117 continue // not installed here; try the next place
118 }
119 if (ran.exitCode !== 0) return failed(`gh failed: ${ran.stderr.trim() || `exit ${ran.exitCode}`}`)
120 try {
121 return toQueue(JSON.parse(ran.stdout) as GhPr[], author, new Date().toISOString())
122 } catch (err) {
123 return failed(`gh answered with something that is not the expected JSON: ${String(err)}`)
124 }
125 }
126 return failed(GH_MISSING)
127}
128
129function reviewPrompt(n: number, q: Queue | null): string {
130 const pr = q?.prs.find(p => p.number === n)
131 const overlap = pr && pr.overlaps.length > 0
132 ? `It shares files with ${pr.overlaps.map(o => '#' + o).join(', ')}; say whether merge order matters and which should go first.`
133 : 'No other PR in the queue shares its files.'
134 return [
135 `Review ${REPO}#${n} for merging. Do not comment, approve or push on GitHub unless I ask.`,
136 '',
137 `1. Read it: \`gh pr view ${n} -R ${REPO} --comments\` and \`gh pr diff ${n} -R ${REPO}\`. Read every issue it says it fixes, with comments.`,
138 `2. Check it out beside the current checkout: \`git fetch origin pull/${n}/head:review-${n}\` and \`git worktree add ../review-${n} review-${n}\`. Build the CLI there (\`./gradlew cli:installDist\`), and one from origin/main for comparison.`,
139 '3. If the body links a case in notactuallytreyanastasio/temper-issue-repros, fetch that repo and run the case with both CLIs (`TEMPER=<cli> ./repro.sh <case>/<library> <backend>`) on the backends the PR is about. Show the before and after output.',
140 "4. Run the tests of the modules the diff touches, and any functional test it adds or changes, on the backends it names. Revert the PR's non-test change locally and confirm its regression test fails, then restore it.",
141 '5. Read the diff for correctness. Write one or two programs just beside the shape it fixes and run them before and after. Check the commit message claims only what the evidence shows.',
142 `6. ${overlap}`,
143 '',
144 'Report: a verdict (merge, merge after another PR, or changes needed), the evidence for it with real output, and any concern with file:line. Remove the worktree when done.',
145 ].join('\n')
146}
147
148const failure = (f: HookFailure) =>
149 f.kind === 'timeout' ? `it ran past its ${f.budget} ms budget` : f.message ?? f.kind
150
151export const register: Register = on => {
152 on('session.start', async ($, e, next) => {
153 await $.command.register({
154 name: 'pr-queue',
155 description: `Open a review queue of ${REPO} PRs (default author ${DEFAULT_AUTHOR}; pass another login to change it)`,
156 })
157 await $.command.register({
158 name: 'pr-review',
159 description: `Run a guided merge review of one ${REPO} PR: /pr-review <number>`,
160 })
161 await $.tool.register({
162 name: TOOL,
163 description:
164 `The open ${REPO} PRs by one author as a review queue: per PR its subsystem, the issues it fixes, size, ` +
165 'mergeability, review decision, the other queued PRs that touch the same source files (regenerated test ' +
166 'registries excluded; a PR over 2,000 changed lines that carries copies of others is listed as alsoIn, ' +
167 'not as an overlap), and a suggested merge order. Pass refresh to fetch again.',
168 inputSchema: {
169 type: 'object',
170 properties: {
171 author: { type: 'string', description: `GitHub login; default ${DEFAULT_AUTHOR}` },
172 refresh: { type: 'boolean' },
173 },
174 },
175 isDeferred: false,
176 })
177 return next(e)
178 })
179
180 on('command.run', { command: 'pr-queue' }, async ($, e) => {
181 const author = e.args.trim() || DEFAULT_AUTHOR
182 void $.ui.toast(`Fetching open ${REPO} PRs by ${author}`)
183 const q = await fetchQueue($, author)
184 await update($, queue, () => q)
185 await $.ui.open({ id: PANE, title: `PRs by ${author}` })
186 if (q.error) return { text: q.error }
187 const order = mergeOrder(q).slice(0, 10).map(n => '#' + n).join(' ')
188 return { text: `${q.prs.length} open PRs by ${author}. Suggested first merges: ${order}` }
189 }).catch((_$, _e, next) => ({ text: `pr-queue failed: ${failure(next.error)}` }))
190
191 on('command.run', { command: 'pr-review' }, async ($, e) => {
192 const n = Number(e.args.trim().replace(/^#/, ''))
193 if (!Number.isInteger(n) || n <= 0) return { text: 'Usage: /pr-review <PR number>' }
194 void $.prompt.submit({ text: reviewPrompt(n, await read($, queue)) })
195 return { text: `Queued a merge review of ${REPO}#${n}.` }
196 }).catch((_$, _e, next) => ({ text: `pr-review failed: ${failure(next.error)}` }))
197
198 on('tool.call', { tool: 'mcp__temper-pr-queue__pr_queue' }, async ($, e) => {
199 const input = (e.input ?? {}) as { author?: string; refresh?: boolean }
200 const author = input.author || DEFAULT_AUTHOR
201 let q = await read($, queue)
202 if (!q || input.refresh || q.author !== author) {
203 q = await fetchQueue($, author)
204 await update($, queue, () => q)
205 }
206 const out = { ...q, mergeOrder: mergeOrder(q) }
207 // A plugin tool's result is text or content blocks; the queue goes as JSON text.
208 return { result: JSON.stringify(out) }
209 }).catch((_$, _e, next) => ({ result: `pr_queue failed: ${failure(next.error)}` }))
210
211 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
212 const { Box, Text } = $.ui.resolve(e)
213 const q = await read($, queue)
214 if (!q) return <Text dimColor>Run /pr-queue to fetch the queue.</Text>
215 if (q.error) return <Text color="red">{q.error}</Text>
216
217 const groups = new Map<string, QueuedPr[]>()
218 for (const p of q.prs) groups.set(p.subsystem, [...(groups.get(p.subsystem) ?? []), p])
219 const ready = q.prs.filter(p => !p.isDraft && p.mergeable === 'MERGEABLE' && p.overlaps.length === 0).length
220
221 return (
222 <Box flexDirection="column">
223 <Text bold>
224 {q.prs.length} open, {ready} independent and mergeable
225 </Text>
226 <Text dimColor>
227 fetched {q.fetchedAt.slice(0, 16).replace('T', ' ')} · /pr-review <n> to review one
228 </Text>
229 {[...groups.entries()].map(([name, prs]) => (
230 <Box flexDirection="column" marginTop={1}>
231 <Text bold color="cyan">
232 {name} ({prs.length})
233 </Text>
234 {prs.map(p => (
235 <Box flexDirection="column">
236 <Text wrap="truncate-end" dimColor={p.isDraft}>
237 #{p.number} {p.isDraft ? '[draft] ' : ''}
238 {p.title.slice(p.title.indexOf(':') + 1).trim()}
239 </Text>
240 <Text wrap="truncate-end" dimColor>
241 {' '}+{p.additions}/-{p.deletions}
242 {' · '}
243 <Text color={p.mergeable === 'CONFLICTING' ? 'red' : p.mergeable === 'MERGEABLE' ? 'green' : 'yellow'}>
244 {p.mergeable.toLowerCase()}
245 </Text>
246 {' · '}
247 {p.review.toLowerCase().replace('_', ' ')}
248 {p.fixes.length > 0 ? ` · fixes ${p.fixes.map(f => '#' + f).join(' ')}` : ''}
249 {p.overlaps.length > 0 ? ` · shares files with ${p.overlaps.map(o => '#' + o).join(' ')}` : ''}
250 {p.alsoIn.length > 0 ? ` · also in ${p.alsoIn.map(o => '#' + o).join(' ')}` : ''}
251 </Text>
252 </Box>
253 ))}
254 </Box>
255 ))}
256 </Box>
257 )
258 })
259}
260types/index.d.ts 29 lines1export type QueuedPr = {
2 number: number
3 title: string
4 subsystem: string
5 fixes: number[]
6 additions: number
7 deletions: number
8 files: string[]
9 mergeable: string
10 review: string
11 isDraft: boolean
12 overlaps: number[]
13 alsoIn: number[]
14}
15
16export type Queue = {
17 repo: string
18 author: string
19 fetchedAt: string
20 prs: QueuedPr[]
21 error?: string
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'temper-pr-queue': { queue: Queue | null }
27 }
28}
29