SLOPSHOPPER

temper-pr-queue

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

newpaneguardcommandtoasttool
v0.1.1no licenseupdated 2026-10-09notactuallytreyanastasio/temper-pr-queue
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · temper-pr-queue
│ ┃ PRs by notactuallytreyanastasio ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ gh answered with something that is not the │ temper-pr-queue │ │ ┃ expected JSON: SyntaxError: JSON Parse ⏺ Read(src/auth.ts) │ Fetching open temperlang/temper PRs by │ │ ┃ error: Unexpected EOF ⎿ Read 6 lines │ notactuallytreyanastasio │ │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pr-queue │ ⎿ temper-pr-queue: gh answered with something that is not the expe │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PRs by notactuallytreyanastasio
gh answered with something that is not the expected JSON: SyntaxError: JSON Parse error: Unexpected EOF
README

temper-pr-queue

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.

Install

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.

What it adds

/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.

How overlap and order are worked out

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.

What it does not do

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.

Developing it

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.

Source 2 files
hooks/register.tsx 260 lines
1import { 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 &lt;n&gt; 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}
260
types/index.d.ts 29 lines
1export 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