The repo's open pull requests in a pane and a band line: CI and merge state, merge, update branch, and fix-conflict / fix-CI prompts.

The open pull requests of the repo your Claude Code session stands in, in a pane and a line above the prompt. It is optional. Blacksmith runs the same without it.
/plugin marketplace add juzser/blacksmith
/plugin install pr-mod@blacksmith
pr-mod is a Claude Code plugin module, not skills or agents. It needs a Claude Code build that loads plugin modules, and the gh CLI signed in to GitHub. claude plugin details pr-mod shows it once installed.
PR 4 open, then the counts that are not zero: green, CI red, conflict and pending. It shows only while a PR is open, marks itself stale when the last read failed, and stacks over other plugins' bands rather than hiding them./pr-mod) lists every open PR, the ones this session created with gh pr create first, then the newest. A header row carries the repo, the number open and how long ago it was fetched, with a Refresh button. Each row shows the PR number, its title, its CI state and its merge state./config theme (dark-ansi too), Latte under a light one, and the theme's own colors under a daltonized theme, auto, or one it cannot read. Green is success, red is failure, yellow is pending or a conflict. Labels and secondary text are soft. Changing the theme repaints at once.| Button | Does |
|---|---|
| Refresh | Reads the PR list now. It is read every 60 s while a PR is open or the pane is shown. |
| Merge | Merges the PR with the repo's first allowed method (squash, merge, rebase). See the gate below. |
| Update branch | Runs gh pr update-branch, on a PR that is behind its base. |
| Fix conflict | Sends a prompt into this session asking it to merge the base into the PR's head and resolve the conflict. |
| Fix CI | Sends a prompt into this session naming the failing checks and their runs. |
| Open | Copies the PR's URL and shows it in a toast. |
The fix buttons do not run anything themselves: they submit a prompt into this session, which you can read and which does the work. After one is sent it reads fix sent until the PR's head moves or 15 minutes pass.
Merge shows only when the PR can be merged cleanly:
CLEAN,Pressing it once only arms it: the button reads Confirm merge #N (method) for 8 seconds. A second press in that window runs gh pr merge. Any other state hides the button, and the row says why where it can, for example a review that is still required.
Load this checkout's copy into one session. This installs nothing:
claude --plugin-dir "$PWD/mods/pr-mod"
From the repo root, three checks cover the mod:
claude plugin validate mods/pr-mod # manifest, hooks.json, what register.tsx reads and writes
claude plugin test mods/pr-mod # hooks/*.test.ts, in Claude Code's own test runner
pnpm exec tsc -p mods/pr-mod # types; needs .claude-plugin/types/
Claude Code generates .claude-plugin/types/ when a session loads the mod, and the folder is gitignored, so load the mod once with --plugin-dir before the type check. The tests fake $.process.run, so none of them calls gh.
scripts/check.sh runs the first two checks whenever claude is on PATH; CI has no claude, so there they print SKIP, and factory/orchestrator/test/pluginManifest.test.ts guards the marketplace wiring. The repo's vitest does not run this folder's tests.
hooks/palette.ts is pr-mod's own copy of the few colors it needs from bs-mod's palette, because a plugin cannot import another's files. Keep the hex values in step with mods/bs-mod/hooks/fold.ts.
A release bumps .claude-plugin/plugin.json's version together with the package's. A test enforces that.
hooks/register.tsx 572 lines1// pr-mod: the open PRs of the repo this session stands in, as a pane (/pr-mod) with merge, update and fix
2// actions, and a one-line band of counts above the prompt that stacks over whatever the plugins beneath draw.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, RenderElement, RenderNode, TextProps, Timer, UiPressArgument } from 'claude-code'
5
6import type { Armed, FixHold, Pr, PrCache, RepoInfo } from '../types'
7import {
8 FIX_HOLD_MS,
9 PR_LIST_ARGV,
10 REPO_VIEW_ARGV,
11 ago,
12 bandCounts,
13 canMerge,
14 stillMergeable,
15 ciPart,
16 firstLine,
17 fixCiPrompt,
18 fixConflictPrompt,
19 isConflict,
20 isHeld,
21 isMine,
22 parsePrs,
23 parseRepo,
24 prUrlsIn,
25 rollup,
26 sortPrs,
27 statePart,
28 toastsBetween,
29 unconsumedMerges,
30} from './prs'
31import type { Tone } from './prs'
32import { paletteOf } from './palette'
33import type { Palette, PaletteRole } from './palette'
34
35const PANE = 'pr-mod'
36const POLL_MS = 60_000
37const RECHECK_MS = 5_000
38const RECHECK_MAX = 3
39const ARM_MS = 8_000
40const LIST_TIMEOUT_MS = 30_000
41const ACTION_TIMEOUT_MS = 120_000
42/** A Button with chrome draws its label plus two columns each side. */
43const BUTTON_CHROME_W = 4
44const GAP = 1
45const GH_PR_CREATE = /\bgh\s+pr\s+create\b/
46const REFRESHES = /\bgit\s+push\b|\bgh\s+pr\s/
47
48const EMPTY_CACHE: PrCache = { cwd: '', prs: [], fetchedAt: null, error: null }
49const cacheAtom = atom({ plugin: 'pr-mod', key: 'cache' } as const, EMPTY_CACHE)
50const repoAtom = atom({ plugin: 'pr-mod', key: 'repo' } as const, null as RepoInfo | null)
51const mineAtom = atom({ plugin: 'pr-mod', key: 'mine' } as const, [] as string[])
52const armedAtom = atom({ plugin: 'pr-mod', key: 'armed' } as const, null as Armed | null)
53const fixAtom = atom({ plugin: 'pr-mod', key: 'fixSent' } as const, {} as Record<string, FixHold>)
54const busyAtom = atom({ plugin: 'pr-mod', key: 'busy' } as const, {} as Record<string, string>)
55/** the `/config` theme, which picks the palette (palette.ts paletteOf); null until read, so the theme keys draw */
56const themeAtom = atom({ plugin: 'pr-mod', key: 'theme' } as const, null as string | null)
57
58type St = {
59 running: Promise<void> | null
60 timer: Timer | null
61 recheckTimer: Timer | null
62 disarmTimer: Timer | null
63 recheck: number
64 /** PRs this mod merged: their leaving the list is no news */
65 mergedHere: number[]
66}
67
68type FixKind = 'conflict' | 'ci'
69
70// ---------------------------------------------------------------- drawing helpers
71
72/** `color`: a palette role (palette.ts PaletteRole) */
73type Look = { color?: PaletteRole; bold?: boolean; dim?: boolean }
74type Run = Look & { text: string; shrink?: boolean }
75
76function run(text: string, look: Look = {}): Run {
77 return { text, ...look }
78}
79
80function cols(text: string): number {
81 return [...text].length
82}
83
84function cut(text: string, n: number): string {
85 return [...text].slice(0, Math.max(0, n)).join('')
86}
87
88function runsWidth(runs: readonly Run[]): number {
89 return runs.reduce((n, r) => n + cols(r.text), 0)
90}
91
92/** Fits runs into width: the shrinkable run gives first, else the line is cut with an ellipsis. */
93function clip(runs: readonly Run[], width: number): Run[] {
94 if (width < 1) return []
95 const out = runs.filter(r => r.text)
96 const over = runsWidth(out) - width
97 if (over <= 0) return out
98 const i = out.findIndex(r => r.shrink)
99 const give = out[i]
100 const keep = give ? cols(give.text) - over - 1 : 0
101 if (give && keep > 0) {
102 out[i] = { ...give, text: `${cut(give.text, keep).trimEnd()}…` }
103 return out
104 }
105 const kept: Run[] = []
106 let room = width - 1
107 for (const r of out) {
108 if (cols(r.text) <= room) {
109 kept.push(r)
110 room -= cols(r.text)
111 continue
112 }
113 kept.push({ ...r, text: `${cut(r.text, room).trimEnd()}…` })
114 break
115 }
116 return kept
117}
118
119/** Whether a tree beneath draws anything: core's own component and empty Boxes do not. */
120function drawable(node: RenderNode | null | undefined): boolean {
121 if (node === null || node === undefined) return false
122 if (typeof node === 'string') return node.length > 0
123 if (typeof node !== 'object') return false
124 if (node.type === 'engine') return false
125 if (node.type === 'Box' || node.type === 'Text') return (node.children ?? []).some(drawable)
126 return true
127}
128
129function messageOf(err: unknown): string {
130 return err instanceof Error ? err.message : String(err)
131}
132
133function style(look: Look, pal: Palette): TextProps {
134 const out: TextProps = {}
135 if (look.color) out.color = pal[look.color]
136 if (look.bold) out.bold = true
137 if (look.dim) out.dimColor = true
138 return out
139}
140
141function toneLook(tone: Tone): Look {
142 if (tone === 'success') return { color: 'success' }
143 if (tone === 'error') return { color: 'error' }
144 if (tone === 'warning') return { color: 'warning' }
145 if (tone === 'pending') return { color: 'warning' }
146 return { dim: true }
147}
148
149// ---------------------------------------------------------------- fetching
150
151function failure(out: { exitCode: number; stderr: string }): string {
152 return firstLine(out.stderr) || `exit ${out.exitCode}`
153}
154
155async function tick($: EngineInterface, st: St, isRecheck: boolean): Promise<void> {
156 const cwd = await $.session.cwd()
157 const prev = await read($, cacheAtom)
158 let repo = await read($, repoAtom)
159 if (!repo || repo.cwd !== cwd) {
160 let found: RepoInfo
161 try {
162 const out = await $.process.run(REPO_VIEW_ARGV, { cwd, timeoutMs: LIST_TIMEOUT_MS })
163 if (out.exitCode !== 0) throw new Error(failure(out))
164 found = parseRepo(out.stdout, cwd)
165 } catch (err) {
166 const error = messageOf(err)
167 await update($, repoAtom, () => null)
168 await update($, cacheAtom, () => ({ cwd, prs: [], fetchedAt: null, error }))
169 return
170 }
171 repo = found
172 await update($, repoAtom, () => found)
173 }
174
175 const kept = prev.cwd === cwd ? prev.prs : []
176 let next: Pr[]
177 try {
178 const out = await $.process.run(PR_LIST_ARGV, { cwd, timeoutMs: LIST_TIMEOUT_MS })
179 if (out.exitCode !== 0) throw new Error(failure(out))
180 next = parsePrs(out.stdout, kept)
181 } catch (err) {
182 const error = firstLine(messageOf(err)) || 'gh pr list failed'
183 await update($, cacheAtom, c => (c.cwd === cwd ? { ...c, error } : { cwd, prs: [], fetchedAt: null, error }))
184 return
185 }
186
187 if (prev.fetchedAt !== null && prev.cwd === cwd) {
188 const mine = await read($, mineAtom)
189 for (const text of toastsBetween(prev.prs, next, mine, st.mergedHere)) $.ui.toast(text)
190 }
191 st.mergedHere = unconsumedMerges(st.mergedHere, next)
192 const holds = await read($, fixAtom)
193 const stillHeld = Object.entries(holds).filter(([key, hold]) => {
194 const pr = next.find(p => p.number === Number(key.slice(key.indexOf(':') + 1)))
195 return pr !== undefined && pr.headRefOid === hold.oid
196 })
197 if (stillHeld.length !== Object.keys(holds).length) await update($, fixAtom, () => Object.fromEntries(stillHeld))
198 const now = await $.clock.now()
199 await update($, cacheAtom, () => ({ cwd, prs: next, fetchedAt: now, error: null }))
200
201 // GitHub answers UNKNOWN while it computes mergeability: look again soon, a few times
202 if (!isRecheck) st.recheck = 0
203 st.recheckTimer?.cancel()
204 st.recheckTimer = null
205 const computing = next.some(p => p.mergeStateStatus === 'UNKNOWN' || p.mergeable === 'UNKNOWN')
206 if (computing && st.recheck < RECHECK_MAX) {
207 st.recheck += 1
208 st.recheckTimer = $.clock.after(RECHECK_MS, () => {
209 st.recheckTimer = null
210 void kick($, st, true)
211 })
212 }
213}
214
215/** Starts a fetch, or joins the one in flight. */
216function kick($: EngineInterface, st: St, isRecheck = false): Promise<void> {
217 if (st.running) return st.running
218 st.running = tick($, st, isRecheck)
219 .catch(() => {
220 // a failed read leaves the cache as it was; the next tick tries again
221 })
222 .finally(() => {
223 st.running = null
224 })
225 return st.running
226}
227
228/** A fetch that starts now: one already in flight may predate what changed. */
229async function refresh($: EngineInterface, st: St): Promise<void> {
230 if (st.running) await st.running
231 await kick($, st)
232}
233
234async function paneShown($: EngineInterface): Promise<boolean> {
235 try {
236 return (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
237 } catch {
238 return false
239 }
240}
241
242/** Polls only while something shows the PRs: the band (PRs open) or the pane. */
243async function poll($: EngineInterface, st: St): Promise<void> {
244 const cache = await read($, cacheAtom)
245 if (cache.prs.length === 0 && !(await paneShown($))) return
246 await kick($, st)
247}
248
249// ---------------------------------------------------------------- actions
250
251async function prOf($: EngineInterface, n: number): Promise<Pr | undefined> {
252 return (await read($, cacheAtom)).prs.find(p => p.number === n)
253}
254
255async function setBusy($: EngineInterface, n: number, what: string | null): Promise<void> {
256 await update($, busyAtom, b => {
257 const out = { ...b }
258 if (what === null) delete out[String(n)]
259 else out[String(n)] = what
260 return out
261 })
262}
263
264async function pressMerge($: EngineInterface, st: St, n: number): Promise<void> {
265 if ((await read($, busyAtom))[String(n)]) return
266 const repo = await read($, repoAtom)
267 const method = repo?.method
268 if (!repo || !method) return
269 const now = await $.clock.now()
270 const armed = await read($, armedAtom)
271 const pr = await prOf($, n)
272 if (!armed || armed.number !== n || now >= armed.until) {
273 if (!pr || !canMerge(pr)) return
274 const until = now + ARM_MS
275 const oid = pr.headRefOid
276 await update($, armedAtom, () => ({ number: n, until, oid }))
277 st.disarmTimer?.cancel()
278 st.disarmTimer = $.clock.after(ARM_MS, () => {
279 st.disarmTimer = null
280 void update($, armedAtom, a => (a && a.number === n && a.until === until ? null : a))
281 })
282 return
283 }
284 st.disarmTimer?.cancel()
285 st.disarmTimer = null
286 await update($, armedAtom, () => null)
287 if (!stillMergeable(pr, armed.oid)) {
288 $.ui.toast(`#${n} changed since you armed it; press Merge again`)
289 return
290 }
291 await setBusy($, n, 'merge')
292 try {
293 const out = await $.process.run(['gh', 'pr', 'merge', String(n), `--${method}`, '--match-head-commit', armed.oid], { cwd: repo.cwd, timeoutMs: ACTION_TIMEOUT_MS })
294 if (out.exitCode === 0) {
295 st.mergedHere.push(n)
296 $.ui.toast(`Merged #${n} (${method})`)
297 } else {
298 $.ui.toast(`Merge #${n} failed: ${failure(out)}`)
299 }
300 } catch (err) {
301 $.ui.toast(`Merge #${n} failed: ${firstLine(messageOf(err))}`)
302 } finally {
303 await setBusy($, n, null)
304 }
305 await refresh($, st)
306}
307
308async function pressUpdate($: EngineInterface, st: St, n: number): Promise<void> {
309 if ((await read($, busyAtom))[String(n)]) return
310 const repo = await read($, repoAtom)
311 const pr = await prOf($, n)
312 if (!repo || !pr) return
313 await setBusy($, n, 'update')
314 try {
315 const out = await $.process.run(['gh', 'pr', 'update-branch', String(n)], { cwd: repo.cwd, timeoutMs: ACTION_TIMEOUT_MS })
316 if (out.exitCode === 0) $.ui.toast(`Updated #${n} from ${pr.baseRefName}`)
317 else $.ui.toast(`Update #${n} failed: ${failure(out)}`)
318 } catch (err) {
319 $.ui.toast(`Update #${n} failed: ${firstLine(messageOf(err))}`)
320 } finally {
321 await setBusy($, n, null)
322 }
323 await refresh($, st)
324}
325
326async function dropHold($: EngineInterface, key: string, hold: FixHold): Promise<void> {
327 await update($, fixAtom, h => {
328 const cur = h[key]
329 if (!cur || cur.at !== hold.at || cur.oid !== hold.oid) return h
330 const out = { ...h }
331 delete out[key]
332 return out
333 })
334}
335
336async function pressFix($: EngineInterface, n: number, kind: FixKind): Promise<void> {
337 const pr = await prOf($, n)
338 if (!pr) return
339 const key = `${kind}:${n}`
340 const now = await $.clock.now()
341 if (isHeld((await read($, fixAtom))[key], pr, now)) return
342 // the hold goes up before the prompt: a second press while it submits does nothing
343 const hold: FixHold = { at: now, oid: pr.headRefOid, kind }
344 await update($, fixAtom, h => ({ ...h, [key]: hold }))
345 $.clock.after(FIX_HOLD_MS, () => void dropHold($, key, hold))
346 const what = kind === 'conflict' ? 'conflict' : 'CI'
347 try {
348 const sent = await $.prompt.submit({ text: kind === 'conflict' ? fixConflictPrompt(pr) : fixCiPrompt(pr) })
349 if (sent.drop !== undefined) {
350 await dropHold($, key, hold)
351 $.ui.toast(`Fix ${what} for #${n} not queued: ${firstLine(sent.drop)}`)
352 return
353 }
354 $.ui.toast(`Queued: fix ${what} for #${n}`)
355 } catch (err) {
356 await dropHold($, key, hold)
357 $.ui.toast(`Fix ${what} for #${n} not queued: ${firstLine(messageOf(err))}`)
358 }
359}
360
361/** No platform call opens a browser: the URL goes to a toast, and to the clipboard where one is reachable. */
362async function pressOpen($: EngineInterface, n: number, press: UiPressArgument): Promise<void> {
363 const pr = await prOf($, n)
364 if (!pr) return
365 let isCopied = false
366 try {
367 isCopied = (await $.ui.copy({ text: pr.url, surface: press.surface })).isCopied
368 } catch {
369 // no clipboard on this surface: the toast still carries the URL
370 }
371 $.ui.toast(`#${n} ${pr.url}${isCopied ? ' (copied)' : ''}`)
372}
373
374function textOf(ran: unknown): string {
375 if (!ran || typeof ran !== 'object') return ''
376 const r = ran as { text?: unknown; result?: unknown }
377 const parts: string[] = []
378 if (typeof r.text === 'string') parts.push(r.text)
379 const res = r.result as { stdout?: unknown } | null | undefined
380 if (res && typeof res === 'object' && typeof res.stdout === 'string') parts.push(res.stdout)
381 return parts.join('\n')
382}
383
384// ---------------------------------------------------------------- register
385
386export const register: Register = on => {
387 const st: St = { running: null, timer: null, recheckTimer: null, disarmTimer: null, recheck: 0, mergedHere: [] }
388
389 on('session.start', async ($, e, next) => {
390 await $.command.register({
391 name: PANE,
392 description: 'Open PRs of this repo: a pane with merge, update and fix actions',
393 })
394 st.timer?.cancel()
395 st.timer = $.clock.every(POLL_MS, () => void poll($, st))
396 void kick($, st)
397 const started = await next(e)
398 try {
399 const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
400 await update($, themeAtom, () => (typeof theme === 'string' ? theme : null))
401 } catch {
402 // no theme read: the theme keys draw until a theme is set
403 }
404 return started
405 })
406
407 // A theme written from /config or a plugin repaints the band and the pane; a deny or a failed write keeps the palette.
408 on('config.set', { key: 'theme' }, async ($, e, next) => {
409 const set = await next(e)
410 if (set.deny === undefined && typeof set.value === 'string') {
411 const theme = set.value
412 try {
413 await update($, themeAtom, () => theme)
414 } catch {
415 // the palette stays as it was
416 }
417 }
418 return set
419 }).catch(($, e, next) => next(e))
420
421 // gh pr create names a PR this session made; a push or any gh pr command may have changed the list
422 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
423 const ran = await next(e)
424 if (ran.deny !== undefined) return ran
425 if (GH_PR_CREATE.test(e.command)) {
426 const urls = prUrlsIn(textOf(ran))
427 if (urls.length > 0) await update($, mineAtom, mine => [...new Set([...mine, ...urls])])
428 }
429 if (REFRESHES.test(e.command)) void refresh($, st)
430 return ran
431 }).catch(($, e, next) => next(e))
432
433 on('command.run', { command: PANE }, async $ => {
434 await refresh($, st)
435 try {
436 await $.ui.open({ id: PANE, title: 'PRs' })
437 } catch (err) {
438 return { text: `pr-mod: the pane did not open: ${messageOf(err)}` }
439 }
440 const repo = await read($, repoAtom)
441 const cache = await read($, cacheAtom)
442 if (!repo) return { text: `pr-mod: no GitHub repo here (${cache.error ?? 'not read yet'})` }
443 return { text: `Opened the PR pane: ${repo.nameWithOwner}, ${cache.prs.length} open` }
444 })
445
446 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
447 const { Box, Button, Text } = $.ui.resolve(e)
448 const cache = await read($, cacheAtom)
449 const repo = await read($, repoAtom)
450 const mine = await read($, mineAtom)
451 const armed = await read($, armedAtom)
452 const holds = await read($, fixAtom)
453 const busy = await read($, busyAtom)
454 const now = await $.clock.now()
455 const width = e.props.bodyColumns
456 const pal = paletteOf(await read($, themeAtom))
457 const texts = (runs: Run[]) => runs.map(r => <Text {...style(r, pal)}>{r.text}</Text>)
458
459 if (!repo) {
460 const line = cache.error ? `pr-mod: no GitHub repo here (${cache.error})` : 'pr-mod: reading open PRs…'
461 // the reason is the point of this line: it wraps rather than losing its end
462 return (
463 <Box flexDirection="column">
464 <Box key="none">
465 <Text dimColor wrap="wrap">
466 {line}
467 </Text>
468 </Box>
469 </Box>
470 )
471 }
472
473 const rows: RenderElement[] = []
474 const refreshW = cols('Refresh') + BUTTON_CHROME_W
475 const head = [
476 run(repo.nameWithOwner, { dim: true }),
477 run(' · ', { dim: true }),
478 run(String(cache.prs.length), { bold: true, color: 'claude' }),
479 run(' open', { dim: true }),
480 run(' · ', { dim: true }),
481 run(cache.fetchedAt === null ? 'not fetched yet' : `fetched ${ago(now - cache.fetchedAt)}`, { dim: true }),
482 ]
483 rows.push(
484 <Box key="header" flexDirection="row" justifyContent="space-between">
485 <Box flexDirection="row">{texts(clip(head, width - refreshW - GAP))}</Box>
486 <Button key="refresh" label="Refresh" onPress={() => refresh($, st)} />
487 </Box>,
488 )
489 if (cache.error) rows.push(<Box key="stale">{texts(clip([run(`stale: ${cache.error}`, { dim: true })], width))}</Box>)
490
491 const sorted = sortPrs(cache.prs, mine)
492 const room = Math.max(1, e.props.scroll.bodyRows - rows.length)
493 const shown = sorted.length > room ? sorted.slice(0, room - 1) : sorted
494
495 for (const pr of shown) {
496 const n = pr.number
497 const ci = ciPart(pr)
498 const state = statePart(pr)
499 const left: Run[] = [run(`#${n}`, { bold: true }), run(' '), { text: pr.title, shrink: true }, run(' '), run(ci.text, toneLook(ci.tone))]
500 if (state) left.push(run(' '), run(state.text, toneLook(state.tone)))
501 if (canMerge(pr) && !repo.method) left.push(run(' · no merge method allowed', { dim: true }))
502 if (isMine(pr, mine)) left.push(run(' · mine', { dim: true }))
503
504 type Act = { key: string; label: string; dim?: boolean; onPress: (press: UiPressArgument) => void }
505 const acts: Act[] = []
506 const doing = busy[String(n)]
507 if (canMerge(pr) && repo.method) {
508 const isArmed = armed !== null && armed.number === n && now < armed.until
509 const text = doing === 'merge' ? 'Merging…' : isArmed ? `Confirm merge #${n} (${repo.method})` : 'Merge'
510 acts.push({ key: `merge:${n}`, label: text, onPress: () => pressMerge($, st, n) })
511 }
512 if (pr.mergeStateStatus === 'BEHIND') {
513 acts.push({ key: `update:${n}`, label: doing === 'update' ? 'Updating…' : 'Update branch', onPress: () => pressUpdate($, st, n) })
514 }
515 if (isConflict(pr)) {
516 const held = isHeld(holds[`conflict:${n}`], pr, now)
517 acts.push({ key: `fixc:${n}`, label: held ? 'fix sent' : 'Fix conflict', dim: held, onPress: () => pressFix($, n, 'conflict') })
518 }
519 if (rollup(pr.checks).kind === 'red') {
520 const held = isHeld(holds[`ci:${n}`], pr, now)
521 acts.push({ key: `fixci:${n}`, label: held ? 'fix sent' : 'Fix CI', dim: held, onPress: () => pressFix($, n, 'ci') })
522 }
523 acts.push({ key: `open:${n}`, label: 'Open', dim: true, onPress: press => pressOpen($, n, press) })
524
525 const actsW = acts.reduce((w, a) => w + cols(a.label) + BUTTON_CHROME_W, 0) + GAP * (acts.length - 1)
526 rows.push(
527 <Box key={`pr:${n}`} flexDirection="row" justifyContent="space-between">
528 <Box flexDirection="row">{texts(clip(left, width - actsW - GAP))}</Box>
529 <Box flexDirection="row" columnGap={GAP}>
530 {acts.map(a => (
531 <Button key={a.key} label={a.label} dimColor={a.dim === true} onPress={a.onPress} />
532 ))}
533 </Box>
534 </Box>,
535 )
536 }
537 if (shown.length < sorted.length) {
538 rows.push(<Box key="more">{texts([run(`+${sorted.length - shown.length} more`, { dim: true })])}</Box>)
539 }
540 return <Box flexDirection="column">{rows}</Box>
541 })
542
543 // One line of counts over what the plugins beneath draw; nothing when no PR is open, and a survey passes through.
544 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
545 const cache = await read($, cacheAtom)
546 const repo = await read($, repoAtom)
547 if (e.props.hasSurvey || !repo || cache.prs.length === 0) return next(e)
548 const below = await next(e)
549 const { Box, Text } = $.ui.resolve(e)
550 const pal = paletteOf(await read($, themeAtom))
551 const c = bandCounts(cache.prs)
552 const runs: Run[] = [run('PR', { bold: true, color: 'claude' }), run(' '), run(String(c.open), { bold: true, color: 'claude' }), run(' open', { dim: true })]
553 const add = (count: number, text: string, look: Look) => {
554 if (count > 0) runs.push(run(' · ', { dim: true }), run(text, look))
555 }
556 add(c.green, `✔ ${c.green} green`, { color: 'success' })
557 add(c.red, `✖ ${c.red} CI red`, { color: 'error' })
558 add(c.conflict, `⚠ ${c.conflict} conflict`, { color: 'warning' })
559 add(c.pending, `◌ ${c.pending} pending`, { color: 'warning' })
560 if (cache.error) runs.push(run(' · stale', { dim: true }))
561 const rows: RenderElement[] = [
562 <Box key="pr-mod" flexDirection="row">
563 {clip(runs, e.props.bodyColumns).map(r => (
564 <Text {...style(r, pal)}>{r.text}</Text>
565 ))}
566 </Box>,
567 ]
568 if (drawable(below)) rows.push(<Box key="below" flexDirection="column">{below}</Box>)
569 return <Box flexDirection="column">{rows}</Box>
570 })
571}
572hooks/prs.ts 338 lines1// The PR model: what `gh` answers, read into plain values the band, the pane and the toasts draw from.
2// No `$` here: every function is pure, so the tests drive it with plain JSON.
3import type { Check, FixHold, MergeMethod, Pr, RepoInfo } from '../types'
4
5export type CheckClass = 'ok' | 'bad' | 'pending'
6export type RollupKind = 'green' | 'red' | 'pending' | 'none'
7export type Rollup = { kind: RollupKind; failing: Check[] }
8export type Counts = { open: number; green: number; red: number; conflict: number; pending: number }
9export type Tone = 'success' | 'error' | 'warning' | 'pending' | 'dim'
10export type Part = { text: string; tone: Tone }
11
12const PR_FIELDS = [
13 'number', 'title', 'headRefName', 'baseRefName', 'isDraft', 'author', 'mergeable', 'mergeStateStatus',
14 'reviewDecision', 'statusCheckRollup', 'url', 'updatedAt', 'headRefOid',
15].join(',')
16
17export const PR_LIST_ARGV: readonly string[] = ['gh', 'pr', 'list', '--state', 'open', '--json', PR_FIELDS, '--limit', '50']
18export const REPO_VIEW_ARGV: readonly string[] = ['gh', 'repo', 'view', '--json', 'nameWithOwner,squashMergeAllowed,mergeCommitAllowed,rebaseMergeAllowed']
19/** How long a sent fix holds its button, unless the PR's head moves first. */
20export const FIX_HOLD_MS = 15 * 60_000
21
22type Json = Record<string, unknown>
23
24function obj(v: unknown): Json {
25 return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : {}
26}
27function str(v: unknown): string {
28 return typeof v === 'string' ? v : ''
29}
30
31function checkOf(v: unknown): Check {
32 const c = obj(v)
33 if (c.__typename === 'StatusContext') {
34 return { type: 'StatusContext', name: str(c.context) || str(c.name), status: '', conclusion: '', state: str(c.state), url: str(c.targetUrl) }
35 }
36 return {
37 type: 'CheckRun',
38 name: str(c.name) || str(c.context),
39 status: str(c.status),
40 conclusion: str(c.conclusion),
41 state: '',
42 url: str(c.detailsUrl) || str(c.targetUrl),
43 }
44}
45
46/**
47 * `gh pr list --json` into Prs. An UNKNOWN merge state (GitHub still computing) keeps the state the
48 * previous poll knew, so a recompute does not read as a change. Throws on anything but a JSON array.
49 */
50export function parsePrs(stdout: string, prev: readonly Pr[] = []): Pr[] {
51 const data: unknown = JSON.parse(stdout)
52 if (!Array.isArray(data)) throw new Error('gh pr list did not answer a JSON array')
53 return data.map((item): Pr => {
54 const p = obj(item)
55 const number = typeof p.number === 'number' ? p.number : Number(p.number) || 0
56 const state = str(p.mergeStateStatus)
57 const rollupRaw: unknown = p.statusCheckRollup
58 const lastKnownState = state && state !== 'UNKNOWN' ? state : (prev.find(x => x.number === number)?.lastKnownState ?? '')
59 return {
60 number,
61 title: str(p.title),
62 headRefName: str(p.headRefName),
63 baseRefName: str(p.baseRefName),
64 isDraft: p.isDraft === true,
65 author: str(obj(p.author).login),
66 mergeable: str(p.mergeable),
67 mergeStateStatus: state,
68 lastKnownState,
69 reviewDecision: str(p.reviewDecision),
70 checks: Array.isArray(rollupRaw) ? rollupRaw.map(checkOf) : [],
71 url: str(p.url),
72 updatedAt: str(p.updatedAt),
73 headRefOid: str(p.headRefOid),
74 }
75 })
76}
77
78/** squash, else merge, else rebase; null when the repo allows none. */
79export function mergeMethod(allowed: { squash: boolean; merge: boolean; rebase: boolean }): MergeMethod | null {
80 if (allowed.squash) return 'squash'
81 if (allowed.merge) return 'merge'
82 if (allowed.rebase) return 'rebase'
83 return null
84}
85
86export function parseRepo(stdout: string, cwd: string): RepoInfo {
87 const data: unknown = JSON.parse(stdout)
88 if (data === null || typeof data !== 'object' || Array.isArray(data)) throw new Error('gh repo view did not answer a JSON object')
89 const r = data as Json
90 return {
91 cwd,
92 nameWithOwner: str(r.nameWithOwner),
93 method: mergeMethod({ squash: r.squashMergeAllowed === true, merge: r.mergeCommitAllowed === true, rebase: r.rebaseMergeAllowed === true }),
94 }
95}
96
97const RUN_OK = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
98const RUN_BAD = new Set(['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
99
100/** A StatusContext reads its state; a CheckRun its status, then its conclusion. */
101export function checkClass(c: Check): CheckClass {
102 if (c.type === 'StatusContext') {
103 if (c.state === 'SUCCESS') return 'ok'
104 if (c.state === 'FAILURE' || c.state === 'ERROR') return 'bad'
105 return 'pending'
106 }
107 if (c.status !== 'COMPLETED') return 'pending'
108 if (RUN_OK.has(c.conclusion)) return 'ok'
109 if (RUN_BAD.has(c.conclusion)) return 'bad'
110 return 'pending'
111}
112
113/** red if any check failed, else pending if any runs, else green; none without checks. */
114export function rollup(checks: readonly Check[]): Rollup {
115 if (checks.length === 0) return { kind: 'none', failing: [] }
116 const classes = checks.map(checkClass)
117 const failing = checks.filter((_, i) => classes[i] === 'bad')
118 if (failing.length > 0) return { kind: 'red', failing }
119 if (classes.includes('pending')) return { kind: 'pending', failing: [] }
120 return { kind: 'green', failing: [] }
121}
122
123export function isConflict(pr: Pr): boolean {
124 return pr.mergeStateStatus === 'DIRTY' || pr.mergeable === 'CONFLICTING'
125}
126
127function blocker(pr: Pr): string {
128 if (pr.reviewDecision === 'REVIEW_REQUIRED') return 'review required'
129 if (pr.reviewDecision === 'CHANGES_REQUESTED') return 'changes requested'
130 const kind = rollup(pr.checks).kind
131 if (kind === 'red') return 'failing checks'
132 if (kind === 'pending') return 'pending checks'
133 return 'branch rules'
134}
135
136/** Why Merge is not offered, or null when it is: CI green, state CLEAN, not a draft. */
137export function mergeBlock(pr: Pr): string | null {
138 if (pr.isDraft || pr.mergeStateStatus === 'DRAFT') return 'draft'
139 const kind = rollup(pr.checks).kind
140 if (kind === 'red') return 'CI red'
141 if (kind === 'pending') return 'CI pending'
142 if (kind === 'none') return 'no checks'
143 switch (pr.mergeStateStatus) {
144 case 'CLEAN': return null
145 case 'DIRTY': return 'conflict'
146 case 'BEHIND': return `behind ${pr.baseRefName}`
147 case 'BLOCKED': return `blocked: ${blocker(pr)}`
148 case 'UNSTABLE': return 'unstable'
149 case 'UNKNOWN': return 'computing'
150 default: return pr.mergeStateStatus.toLowerCase() || 'computing'
151 }
152}
153
154export function canMerge(pr: Pr): boolean {
155 return mergeBlock(pr) === null
156}
157
158/** Whether the PR armed at `oid` may still be merged: present, mergeable, and still at that head commit. */
159export function stillMergeable(pr: Pr | undefined, oid: string): pr is Pr {
160 return pr !== undefined && canMerge(pr) && pr.headRefOid === oid
161}
162
163export function ciPart(pr: Pr): Part {
164 const r = rollup(pr.checks)
165 if (r.kind === 'green') return { text: '✔ green', tone: 'success' }
166 if (r.kind === 'red') return { text: `✖ CI red (${r.failing.length})`, tone: 'error' }
167 if (r.kind === 'pending') return { text: '◌ pending', tone: 'pending' }
168 return { text: 'no checks', tone: 'dim' }
169}
170
171/** The merge state as a mark; null where the CI part already says it. */
172export function statePart(pr: Pr): Part | null {
173 const s = pr.mergeStateStatus
174 if (s === 'DRAFT' || (pr.isDraft && s === 'CLEAN')) return { text: 'draft', tone: 'dim' }
175 switch (s) {
176 case 'CLEAN': return { text: '⚑ CLEAN', tone: 'success' }
177 case 'DIRTY': return { text: '⚠ conflict', tone: 'warning' }
178 case 'BEHIND': return { text: `↓ behind ${pr.baseRefName}`, tone: 'warning' }
179 case 'BLOCKED': return { text: `⊘ blocked: ${blocker(pr)}`, tone: 'warning' }
180 case 'UNSTABLE': return rollup(pr.checks).kind === 'red' ? null : { text: '⚠ unstable', tone: 'warning' }
181 case 'UNKNOWN': return { text: '… computing', tone: 'dim' }
182 case '': return null
183 default: return { text: s.toLowerCase(), tone: 'dim' }
184 }
185}
186
187export function isMine(pr: Pr, mine: readonly string[]): boolean {
188 return mine.includes(pr.url)
189}
190
191/** This session's PRs first, then the most recently updated. */
192export function sortPrs(prs: readonly Pr[], mine: readonly string[]): Pr[] {
193 const t = (p: Pr) => Date.parse(p.updatedAt) || 0
194 return [...prs].sort(
195 (a, b) => Number(isMine(b, mine)) - Number(isMine(a, mine)) || t(b) - t(a) || b.number - a.number,
196 )
197}
198
199export function bandCounts(prs: readonly Pr[]): Counts {
200 const out: Counts = { open: prs.length, green: 0, red: 0, conflict: 0, pending: 0 }
201 for (const pr of prs) {
202 const kind = rollup(pr.checks).kind
203 if (kind === 'green') out.green++
204 else if (kind === 'red') out.red++
205 else if (kind === 'pending') out.pending++
206 if (isConflict(pr)) out.conflict++
207 }
208 return out
209}
210
211/** A one-line, quoted copy of text from GitHub: a title cannot open a line of its own in a prompt. */
212function quoted(text: string): string {
213 return JSON.stringify(text.replace(/\s+/g, ' ').trim())
214}
215
216/** A URL from GitHub, only when it is one plain https token: anything else is left out of a prompt. */
217function safeUrl(url: string): string | null {
218 return /^https:\/\/\S+$/.test(url) && !/["'`]/.test(url) ? url : null
219}
220
221function prHeader(lead: string, pr: Pr): string[] {
222 const url = safeUrl(pr.url)
223 return [
224 `${lead} pull request #${pr.number} ${quoted(pr.title)}.`,
225 ...(url ? [`URL: ${url}`] : []),
226 `Head branch: ${quoted(pr.headRefName)}`,
227 `Base branch: ${quoted(pr.baseRefName)}`,
228 ]
229}
230
231export function fixConflictPrompt(pr: Pr): string {
232 return [
233 ...prHeader('Fix the merge conflict on', pr),
234 '',
235 'Steps:',
236 `1. If the head branch is not checked out here, work in a separate git worktree rather than switching this checkout's branch.`,
237 `2. Fetch, then merge ${quoted(`origin/${pr.baseRefName}`)} into the head branch. Do not rebase and do not force-push.`,
238 "3. Resolve each conflict keeping both sides' intent.",
239 "4. Run the project's checks and commit.",
240 '5. Push.',
241 '6. Report what conflicted and how it was resolved.',
242 '',
243 'Do not merge the PR.',
244 ].join('\n')
245}
246
247function runId(url: string): string | null {
248 return /\/actions\/runs\/(\d+)/.exec(url)?.[1] ?? null
249}
250
251export function fixCiPrompt(pr: Pr): string {
252 const failing = rollup(pr.checks).failing.map(c => {
253 const url = safeUrl(c.url)
254 const id = url ? runId(url) : null
255 return `- ${quoted(c.name)}${id ? ` (run ${id})` : ''}${url ? `: ${url}` : ''}`
256 })
257 return [
258 ...prHeader('Fix the failing CI on', pr),
259 '',
260 'Failing checks:',
261 ...failing,
262 '',
263 'Steps:',
264 `1. Read the failure with \`gh pr checks ${pr.number}\` and \`gh run view <run-id> --log-failed\`.`,
265 '2. Reproduce it locally.',
266 `3. Fix it in the head branch. If that branch is not checked out here, work in a separate git worktree rather than switching this checkout's branch.`,
267 "4. Run the project's checks, then push.",
268 '5. Report the root cause.',
269 '',
270 'Do not merge the PR.',
271 ].join('\n')
272}
273
274function conflicting(pr: Pr): boolean {
275 return pr.lastKnownState === 'DIRTY' || pr.mergeable === 'CONFLICTING'
276}
277function known(pr: Pr): boolean {
278 return pr.lastKnownState !== '' || pr.mergeable === 'MERGEABLE' || pr.mergeable === 'CONFLICTING'
279}
280function titleOf(pr: Pr): string {
281 return pr.title.replace(/\s+/g, ' ').trim()
282}
283
284/**
285 * What changed between two polls, as toast lines: CI went red (any PR), CI went green (mine),
286 * turned conflicting (any PR), left the list (mine, unless this session merged it). A PR new to the
287 * list toasts nothing.
288 */
289export function toastsBetween(prev: readonly Pr[], next: readonly Pr[], mine: readonly string[], mergedHere: readonly number[]): string[] {
290 const out: string[] = []
291 for (const now of next) {
292 const was = prev.find(p => p.number === now.number)
293 if (!was) continue
294 const before = rollup(was.checks).kind
295 const after = rollup(now.checks).kind
296 if (after === 'red' && before !== 'red') out.push(`#${now.number} CI went red: ${titleOf(now)}`)
297 if (after === 'green' && before !== 'green' && isMine(now, mine)) out.push(`#${now.number} CI is green: ${titleOf(now)}`)
298 if (conflicting(now) && !conflicting(was) && known(was)) out.push(`#${now.number} has a conflict with ${now.baseRefName}: ${titleOf(now)}`)
299 }
300 for (const was of prev) {
301 if (next.some(p => p.number === was.number)) continue
302 if (!isMine(was, mine) || mergedHere.includes(was.number)) continue
303 out.push(`#${was.number} left the open list (merged or closed): ${titleOf(was)}`)
304 }
305 return out
306}
307
308/**
309 * The merges this session made that the list has not yet lost: a number is only needed to mute the
310 * "left the open list" toast, which fires on the poll that sees the PR gone, so it is dropped then.
311 */
312export function unconsumedMerges(mergedHere: readonly number[], next: readonly Pr[]): number[] {
313 return mergedHere.filter(n => next.some(p => p.number === n))
314}
315
316export function isHeld(hold: FixHold | undefined, pr: Pr | undefined, now: number): boolean {
317 if (!hold || !pr) return false
318 return hold.oid === pr.headRefOid && now < hold.at + FIX_HOLD_MS
319}
320
321export function prUrlsIn(text: string): string[] {
322 return [...new Set(text.match(/https?:\/\/[^\s"'<>]+\/pull\/\d+/g) ?? [])]
323}
324
325export function firstLine(text: string): string {
326 return text.split('\n').map(l => l.trim()).find(l => l.length > 0) ?? ''
327}
328
329export function ago(ms: number): string {
330 const s = Math.floor(Math.max(0, ms) / 1000)
331 if (s < 60) return `${s}s ago`
332 const m = Math.floor(s / 60)
333 if (m < 60) return `${m}m ago`
334 const h = Math.floor(m / 60)
335 if (h < 24) return `${h}h ago`
336 return `${Math.floor(h / 24)}d ago`
337}
338hooks/palette.ts 23 lines1// The colours pr-mod draws with: the Catppuccin pastels bs-mod uses, by the same theme rule and the same hex
2// values, so one colour means the same thing in both bands. A plugin cannot import another's files, so this is
3// pr-mod's own copy of the few roles it needs; keep it in step with bs-mod's hooks/fold.ts `paletteOf`.
4export type PaletteRole = 'success' | 'error' | 'warning' | 'claude'
5export type Palette = Readonly<Record<PaletteRole, string>>
6
7/** Catppuccin Mocha, for a dark theme. */
8export const MOCHA: Palette = { success: '#a6e3a1', error: '#f38ba8', warning: '#f9e2af', claude: '#fab387' }
9
10/** Catppuccin Latte, for a light theme. */
11export const LATTE: Palette = { success: '#40a02b', error: '#d20f39', warning: '#df8e1d', claude: '#fe640b' }
12
13/** The engine's own theme keys: what a daltonized, `auto` or unknown theme, or an unread one, keeps. */
14export const THEME_KEYS: Palette = { success: 'success', error: 'error', warning: 'warning', claude: 'claude' }
15
16/** The palette for a `/config` theme name: Latte for `light*`, Mocha for `dark*`, the theme keys for anything else. */
17export function paletteOf(theme: string | null | undefined): Palette {
18 if (typeof theme !== 'string' || theme.includes('daltonized')) return THEME_KEYS
19 if (theme.startsWith('light')) return LATTE
20 if (theme.startsWith('dark')) return MOCHA
21 return THEME_KEYS
22}
23types/index.d.ts 69 lines1// The pr-mod contract: what the mod keeps in `$.state`, and the shapes its
2// pure module (hooks/prs.ts) hands the drawing.
3
4/** One entry of `statusCheckRollup`, flattened: a CheckRun carries status/conclusion, a StatusContext state. */
5export type Check = {
6 type: 'CheckRun' | 'StatusContext'
7 name: string
8 /** CheckRun: QUEUED, IN_PROGRESS, COMPLETED, ...; '' for a StatusContext */
9 status: string
10 /** CheckRun once COMPLETED: SUCCESS, FAILURE, ...; '' otherwise */
11 conclusion: string
12 /** StatusContext: SUCCESS, FAILURE, ERROR, PENDING, EXPECTED; '' for a CheckRun */
13 state: string
14 /** detailsUrl or targetUrl */
15 url: string
16}
17
18/** An open pull request as `gh pr list --json ...` answers it, normalized. */
19export type Pr = {
20 number: number
21 title: string
22 headRefName: string
23 baseRefName: string
24 isDraft: boolean
25 /** the author's login */
26 author: string
27 mergeable: string
28 mergeStateStatus: string
29 /** the last mergeStateStatus that was not UNKNOWN, kept across polls so a recompute does not read as a change */
30 lastKnownState: string
31 reviewDecision: string
32 checks: Check[]
33 url: string
34 updatedAt: string
35 headRefOid: string
36}
37
38export type MergeMethod = 'squash' | 'merge' | 'rebase'
39
40/** The last good `gh pr list`, when it was read, and why the latest read failed (null: it did not). */
41export type PrCache = { cwd: string; prs: Pr[]; fetchedAt: number | null; error: string | null }
42
43/** `gh repo view`, read once per session and cwd; method null when the repo allows none. */
44export type RepoInfo = { cwd: string; nameWithOwner: string; method: MergeMethod | null }
45
46/** A Merge button pressed once: the second press within `until` merges. */
47export type Armed = { number: number; until: number; oid: string }
48
49/** A fix prompt sent for a PR: held until its head moves or 15 minutes pass. */
50export type FixHold = { at: number; oid: string; kind: 'conflict' | 'ci' }
51
52declare module 'claude-code' {
53 interface PluginState {
54 'pr-mod': {
55 cache: PrCache
56 repo: RepoInfo | null
57 /** URLs of the PRs this session's `gh pr create` printed */
58 mine: string[]
59 armed: Armed | null
60 /** per PR number */
61 fixSent: Record<string, FixHold>
62 /** per PR number: what runs on it now (merging, updating) */
63 busy: Record<string, string>
64 /** the `/config` theme, read at session start and on each theme write; null when unread, so the theme keys draw (hooks/palette.ts) */
65 theme: string | null
66 }
67 }
68}
69