A docked pane of the session repo's issues and PRs in run order, with buttons that fill the next command, plus two for this session's ship runs: send the merge…

Claude Code mods (function-hook plugins), installed through one marketplace.
smart-compact: lets Claude compact its own context at a step boundary it picks, with its own instructions, then resume the work on its own. Reminders arrive at 60% and 80% of the auto-compact point. Interactive sessions only: a -p or SDK session cannot compact on request yet, so there the mod only adds its keep-list to automatic compactions.gh-pane: /gh-pane, or the Open gh-pane button above the prompt (Hide gh-pane while the pane is up, each followed by the Nerd Font GitHub mark, which /plugin configure can blank where the terminal font has none), docks a pane of the session repo's open issues and PRs in run order: spec trees from sub-issues, blocked-by edges, claims and their ship runs, planning maps whose tickets close without a PR, and the needs-triage and needs-info lists. Its buttons fill the prompt with /ship N, /triage N, a map's next ticket (/wayfinder MAP N), a request to release a stale claim, or a request to close an issue whose sub-issues are all closed (all done), and never send it. Two band buttons act on this session's own state instead: when a main-loop answer carries the merge-gate text and a PR link, merge PR #N (hotkey m) sends the merge reply as your own prompt, and once that PR reads as merged over REST, clear + /ship N (hotkey n) clears the session and runs the first ready row's command (one that does not start with / shows no button), and goes once the next turn ends. When a ship run merges on its own on a clean gate, its answer's merged line (Merged on a clean gate: with the PR link after it on that line) takes the same read, so clear + /ship N shows with no merge button. A hotkey fires only once the band holds focus (ctrl+x then tab, or a click), never from the prompt, where m types a letter; a click always works. A /clear keeps the open pane's last read of the same folder on screen until the next one lands. It reads the repo through git and gh api (REST only), so gh must be installed and signed in. The label names (the map label included), PR cap, ship worktree layout, the band button's icon, the text each button fills, the merge-gate text, the merged text and the merge reply are options in /plugin configure gh-pane@claude-mods.claude plugin marketplace add Gharib89/claude-mods
claude plugin install smart-compact@claude-mods
claude plugin install gh-pane@claude-modshooks/register.tsx 438 lines1// gh-pane: `/gh-pane`, or the band's button above the prompt, docks a pane of the session repo's open issues and PRs
2// in run order. Its buttons fill the prompt with the next command and never send it: the person reads it and presses
3// Enter. Two band buttons act on this session's own state instead: at its own merge gate one sends the merge reply,
4// and once the PR is merged, there or by a ship run on its own, one clears the session and runs the next command.
5
6import { atom, read, update } from 'claude-code'
7import type { Elements, EngineInterface, PluginOptions, Register } from 'claude-code'
8
9import type { Gate, Issue, Snapshot } from '../types'
10import { isMerged, readBacklog, type Runner } from './github'
11import { commandOf, fill, gatePr, mergedPrOf, runLine } from './rules'
12
13const PANE = 'gh-pane'
14const snapshot = atom({ plugin: 'gh-pane', key: 'snapshot' } as const, null)
15const gate = atom({ plugin: 'gh-pane', key: 'gate' } as const, null)
16
17type E = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Link'>
18type Config = ReturnType<typeof configOf>
19
20const configOf = (o: PluginOptions) => ({
21 needsTriage: String(o.needsTriageLabel),
22 needsInfo: String(o.needsInfoLabel),
23 readyForAgent: String(o.readyForAgentLabel),
24 readyForHuman: String(o.readyForHumanLabel),
25 mapLabel: String(o.mapLabel),
26 mapCommand: String(o.mapCommand),
27 prCap: Number(o.prCap),
28 worktreeLayout: String(o.worktreeLayout),
29 shipCommand: String(o.shipCommand),
30 triageCommand: String(o.triageCommand),
31 releaseRequest: String(o.releaseRequest),
32 closeRequest: String(o.closeRequest),
33 gateText: String(o.gateText),
34 mergedText: String(o.mergedText),
35 mergeReply: String(o.mergeReply),
36 buttonIcon: String(o.buttonIcon),
37})
38
39const cut = (text: string, room: number) => (text.length <= room ? text : `${text.slice(0, Math.max(1, room - 1))}…`)
40
41const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)).split('\n')[0]
42
43// Reads overlap (a timer, a gh call, the command): only the latest one started may land.
44let reads = 0
45// The last snapshot landed, which the pane draws while `$.state` holds none; the state copy stays because its write
46// redraws the pane. A /clear empties `$.state` while the pane stays up, and its one hook, `session.end`, runs before
47// that, so a read started there lands in the old session (seen on 2.1.289). This module variable outlives the /clear,
48// and draws only in the folder it was read in: one process may host more than one session.
49let kept: { cwd: string; view: Snapshot } | null = null
50
51const runnerOf = ($: EngineInterface): Runner => async argv => {
52 const { exitCode, stdout, stderr } = await $.process.run(argv).catch((error: unknown) => {
53 throw new Error(`${argv[0]} did not run (not installed, or timed out): ${messageOf(error)}`)
54 })
55 if (exitCode !== 0) throw new Error(`${argv[0]} ${argv[1]}: ${stderr.trim().split('\n')[0]}`)
56 return stdout
57}
58
59// Answers the snapshot it read, landed or not: a read overtaken by a newer one is still fresh for its caller.
60async function refresh($: EngineInterface, config: Config): Promise<Snapshot> {
61 const ticket = ++reads
62 const cwd = await $.session.cwd()
63 const run = runnerOf($)
64 const now = new Date(await $.clock.now()).toISOString()
65 const next = await readBacklog(run, config.worktreeLayout, now).catch((error: unknown) => ({ error: `gh-pane: ${messageOf(error)}` }))
66 if (ticket !== reads) return next
67 kept = { cwd, view: next }
68 await update($, snapshot, () => next)
69 return next
70}
71
72// Opened by the person (the command, the band's button), the pane is placed at any width. The band draws from
73// `$.ui.panes()`, which no redraw follows (2.1.289), so show, hide and the ui.close hook each invalidate it.
74async function show($: EngineInterface, config: Config) {
75 const opened = await $.ui.open({ id: PANE, title: 'gh-pane' })
76 $.ui.invalidate('ui.render')
77 await refresh($, config)
78 return opened
79}
80
81// The mod's own $.ui.close skips its own ui.close hook (seen in `claude plugin test` on 2.1.289), so hide
82// invalidates the band itself.
83async function hide($: EngineInterface) {
84 await $.ui.close({ id: PANE })
85 $.ui.invalidate('ui.render')
86}
87
88async function isOpen($: EngineInterface) {
89 return (await $.ui.panes()).some(pane => pane.id === PANE)
90}
91
92// Nothing awaits a timer tick or a re-read after a tool call, so a failure there is toasted rather than dropped.
93async function refreshIfOpen($: EngineInterface, config: Config) {
94 try {
95 if (await isOpen($)) await refresh($, config)
96 } catch (error) {
97 $.ui.toast(`gh-pane did not refresh: ${messageOf(error)}`)
98 }
99}
100
101async function propose($: EngineInterface, text: string) {
102 const draft = (await $.prompt.read()).text.trim()
103 await $.prompt.fill(draft === '' ? { text } : { text: ` ${text}`, mode: 'append' })
104 $.ui.toast(`In the prompt: ${text} (Enter sends it)`)
105}
106
107/**
108 * A row's state, first match wins. An issue whose sub-issues are all closed waits only on its own close. A map ticket
109 * (a sub-issue of a map) left open, unblocked, unclaimed and waiting on neither triage nor info is next.
110 */
111function stateOf(
112 issue: Issue,
113 config: Config,
114 isMapTicket: boolean,
115): { tag: string; color: string; pr?: number; blockers?: Issue['blockers']; isReady?: true; isNext?: true; isDone?: true } {
116 if (issue.pr !== undefined) return { tag: 'in PR ', color: 'cyan', pr: issue.pr }
117 if (issue.subs.total > 0 && issue.subs.done === issue.subs.total) return { tag: 'all done', color: 'green', isDone: true }
118 if (issue.blockers.length > 0) return { tag: 'blocked by ', color: 'yellow', blockers: issue.blockers }
119 if (issue.assignees.length > 0) return { tag: `claimed (${issue.assignees.join(', ')})`, color: 'blue' }
120 if (issue.labels.includes(config.readyForAgent)) return { tag: 'ready now', color: 'green', isReady: true }
121 if (issue.labels.includes(config.readyForHuman)) return { tag: 'yours', color: 'magenta' }
122 const waiting = issue.labels.find(l => l === config.needsTriage || l === config.needsInfo)
123 if (waiting !== undefined) return { tag: waiting, color: 'gray' }
124 if (issue.labels.includes(config.mapLabel)) return { tag: 'map', color: 'gray' }
125 if (isMapTicket) return { tag: 'ready now', color: 'green', isNext: true }
126 return { tag: 'untriaged', color: 'gray' }
127}
128
129type Open = Exclude<Snapshot, { error: string }>
130
131const has = (issue: Issue, label: string) => issue.labels.includes(label)
132
133/** The pane's order: each root's tree, then the lone issues waiting on triage or info. */
134function arrange(view: Open, config: Config) {
135 const byNumber = new Map(view.issues.map(issue => [issue.number, issue]))
136 // A map and its tickets at every depth resolve by a closing comment, never a PR.
137 const mapOf = (issue: Issue): number | undefined =>
138 has(issue, config.mapLabel) ? issue.number : issue.parent === undefined ? undefined : mapOf(byNumber.get(issue.parent)!)
139 const isMapTicket = (issue: Issue) => mapOf(issue) !== undefined && !has(issue, config.mapLabel)
140 // A spec tree draws in place whatever its root's label; a lone issue waiting on triage or info waits below.
141 const top = view.issues.filter(issue => issue.parent === undefined)
142 const isWaiting = (issue: Issue, label: string) => issue.subs.total === 0 && has(issue, label)
143 const roots = top.filter(issue => !isWaiting(issue, config.needsTriage) && !isWaiting(issue, config.needsInfo))
144 const waiting = [
145 { name: 'needs triage', issues: top.filter(issue => isWaiting(issue, config.needsTriage)), canTriage: true },
146 { name: 'needs info', issues: top.filter(issue => isWaiting(issue, config.needsInfo)), canTriage: false },
147 ]
148 return { byNumber, mapOf, isMapTicket, roots, waiting }
149}
150
151/** The command of the first row in the pane's order whose button starts work: a ready issue's ship or a map's next. */
152function nextCommand(view: Open, config: Config): string | undefined {
153 const { byNumber, mapOf, isMapTicket, roots, waiting } = arrange(view, config)
154 const walk = (issue: Issue): Issue[] => [issue, ...issue.children.flatMap(n => walk(byNumber.get(n)!))]
155 for (const issue of [...roots.flatMap(walk), ...waiting.flatMap(group => group.issues)]) {
156 const state = stateOf(issue, config, isMapTicket(issue))
157 if (state.isReady) return fill(config.shipCommand, { n: issue.number })
158 if (state.isNext) return fill(config.mapCommand, { map: mapOf(issue)!, n: issue.number })
159 }
160 return undefined
161}
162
163const setGate = ($: EngineInterface, to: Gate | null) => update($, gate, () => to)
164
165// Sends the reply as the person's own words. The press marks the merging before the send, so the state is right
166// whether or not the mod's own prompt.submit hook sees this call.
167async function pressMerge($: EngineInterface, config: Config, at: Gate) {
168 await setGate($, { ...at, phase: 'merging' })
169 try {
170 await $.prompt.submit({ text: config.mergeReply, asUser: true })
171 } catch (error) {
172 await setGate($, at)
173 throw error
174 }
175}
176
177// The merge reply's turn, or one carrying the merged text, ended: the PR itself says whether it merged (a stale-base
178// or a no leaves it open), and a fresh read, pane open or not, picks the next command.
179async function settleMerge($: EngineInterface, config: Config, pr: number) {
180 try {
181 if (!(await isMerged(runnerOf($), pr))) return await setGate($, null)
182 const view = await refresh($, config)
183 if ('error' in view) $.ui.toast(view.error)
184 const text = 'error' in view ? undefined : nextCommand(view, config)
185 const run = text === undefined ? undefined : commandOf(text)
186 await setGate($, { pr, phase: 'merged', next: text === undefined || run === undefined ? undefined : { text, ...run } })
187 } catch (error) {
188 await setGate($, null)
189 $.ui.toast(`gh-pane could not read PR #${pr}: ${messageOf(error)}`)
190 }
191}
192
193// Seen on 2.1.291: `$.command.run` works from a button press but is refused inside a `command.run` hook, no
194// `session.start` fires after a /clear, and `$.prompt.submit` refuses text starting with `/`. So the next button runs
195// the whole chain inside one press: /clear, then the command as a command.
196async function pressNext($: EngineInterface, next: NonNullable<Gate['next']>) {
197 await setGate($, null)
198 await $.command.run({ command: 'clear' })
199 await $.command.run({ command: next.command, args: next.args })
200}
201
202export const register: Register = (on, options) => {
203 const config = configOf(options)
204
205 // A main-loop answer carrying the gate text puts this session at the gate, and one carrying the merged text says a
206 // ship run merged on its own, with no gate to answer; a subagent's turn is neither.
207 on('turn.complete', async ($, e, next) => {
208 const done = await next(e)
209 if (e.agentId !== undefined) return done
210 const mergedPr = mergedPrOf(e.answer, config.mergedText)
211 const pr = gatePr(e.answer, config.gateText)
212 if (mergedPr !== undefined) void settleMerge($, config, mergedPr)
213 else if (pr !== undefined) await setGate($, { pr, phase: 'gate' })
214 else {
215 const at = await read($, gate)
216 if (at?.phase === 'merging') void settleMerge($, config, at.pr)
217 // The next command was picked at merge time: it stands for the turn that follows the merge, no longer.
218 else if (at?.phase === 'merged') await setGate($, null)
219 }
220 return done
221 })
222
223 // A typed reply: the merge word counts as the button's press, anything else hides the button until the gate returns.
224 on('prompt.submit', async ($, e, next) => {
225 const at = await read($, gate)
226 if (at?.phase === 'gate') await setGate($, e.text.trim() === config.mergeReply ? { ...at, phase: 'merging' } : null)
227 return next(e)
228 })
229
230 on('session.end', async ($, e, next) => {
231 await setGate($, null)
232 return next(e)
233 })
234
235 on('session.start', async ($, e, next) => {
236 await $.command.register({
237 name: 'gh-pane',
238 description: "Open a pane of this repo's issues and PRs in run order",
239 // Typed mid-turn, the pane opens at once instead of waiting for the turn to end.
240 immediate: true,
241 })
242 $.clock.every(120_000, () => refreshIfOpen($, config))
243 return next(e)
244 })
245
246 on('command.run', { command: 'gh-pane' }, async $ => {
247 const opened = await show($, config)
248 return { text: opened.isPlaced ? 'gh-pane opened.' : `gh-pane is waiting: ${opened.reason}` }
249 })
250
251 // Closed by the person's close mark; an unload runs none of the opener's hooks.
252 on('ui.close', { id: PANE }, async ($, e, next) => {
253 const closed = await next(e)
254 $.ui.invalidate('ui.render')
255 return closed
256 })
257
258 // One button above the prompt that shows or hides the pane; whatever another plugin draws there stays above it.
259 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
260 const below = await next(e)
261 if (e.props.hasSurvey) return below
262 const { Box, Text, Button }: E = $.ui.resolve(e)
263 // An unreadable pane list or gate reads as closed: a throw here would take the band of every plugin beneath with it.
264 const shown = await isOpen($).catch(() => false)
265 const at = await read($, gate).catch(() => null)
266 const upNext = at?.phase === 'merged' ? at.next : undefined
267 const toast = (error: unknown) => $.ui.toast(`gh-pane: ${messageOf(error)}`)
268 return (
269 <Box flexDirection="column">
270 {below}
271 <Box gap={2}>
272 <Button
273 key="toggle"
274 plain
275 label={`${shown ? 'Hide' : 'Open'} gh-pane ${config.buttonIcon}`.trim()}
276 onPress={() => (shown ? hide($) : show($, config)).catch(toast)}
277 />
278 {at?.phase === 'gate' && <Button key="merge" hotkey="m" label={`merge PR #${at.pr}`} onPress={() => pressMerge($, config, at).catch(toast)} />}
279 {at?.phase === 'merging' && <Text color="cyan">{`merging PR #${at.pr}…`}</Text>}
280 {upNext && <Button key="next" hotkey="n" label={`clear + ${upNext.text}`} onPress={() => pressNext($, upNext).catch(toast)} />}
281 </Box>
282 </Box>
283 )
284 })
285
286 // A gh call or a push moves issues and PRs: re-read once it has run. Each word counts only where a command starts
287 // or follows a space or shell operator, and git's global options (`-C <path>`, `-c <key=value>`, `--no-pager`) may
288 // sit before `push`.
289 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
290 const ran = await next(e)
291 if (/(?:^|[\s;&|(])(?:gh\s|git\s+(?:-[Cc]\s+\S+\s+|-\S+\s+)*push\b)/.test(e.command)) void refreshIfOpen($, config)
292 return ran
293 })
294
295 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
296 const { Box, Text, Button, Link }: E = $.ui.resolve(e)
297 const view = (await read($, snapshot)) ?? (kept?.cwd === (await $.session.cwd()) ? kept.view : null)
298 const room = e.props.bodyColumns
299 if (view === null) return <Text dimColor>Reading the repo over gh api…</Text>
300 // A failed read (a network blip) holds the pane until the next re-read, so retry starts one now.
301 if ('error' in view) {
302 return (
303 <Box gap={1}>
304 <Text color="red">{cut(view.error, room - 10)}</Text>
305 <Button key="retry" label="retry" onPress={() => refreshIfOpen($, config)} />
306 </Box>
307 )
308 }
309 if (view.issues.length === 0 && view.prs === 0) return <Text dimColor>{`Nothing open in ${view.repo}.`}</Text>
310
311 const { byNumber, mapOf, isMapTicket, roots, waiting } = arrange(view, config)
312 const ref = (n: number, kind: 'issues' | 'pull' = 'issues') => (
313 <Link href={(kind === 'issues' && byNumber.get(n)?.url) || `https://github.com/${view.repo}/${kind}/${n}`} label={`#${n}`} />
314 )
315 const blockers = (list: Issue['blockers']) =>
316 list.flatMap(({ label, url }, i) => [...(i === 0 ? [] : [', ']), <Link key={label} href={url} label={label} />])
317 const runs = new Map(
318 view.issues
319 .filter(issue => issue.assignees.length > 0)
320 .map(issue => [issue.number, runLine({ ...issue, now: view.fetchedAt, hasPr: issue.pr !== undefined, expectsPr: mapOf(issue) === undefined })] as const),
321 )
322 const lines = [...runs.values()].filter(line => line !== null)
323 const stale = lines.filter(line => line.isStale).length
324 // Ready now: every row with a button that starts work, a ship or a map's next.
325 const ready = view.issues.filter(issue => {
326 const state = stateOf(issue, config, isMapTicket(issue))
327 return state.isReady || state.isNext
328 }).length
329 const isCapFull = view.prs >= config.prCap
330
331 const triage = (issues: number[]) =>
332 fill(config.triageCommand, { issues: issues.length === 1 ? `${issues[0]}` : `${issues.map(n => `#${n}`).join(', ')} one by one` })
333
334 const row = (issue: Issue, prefix: string) => {
335 const state = stateOf(issue, config, isMapTicket(issue))
336 const bar = '█'.repeat(Math.round((issue.subs.done / Math.max(1, issue.subs.total)) * 8)).padEnd(8, '░')
337 const run = runs.get(issue.number)
338 const pad = `${prefix.replace('├', '│').replace(/[└•▾]/, ' ')} ↳`
339 return [
340 <Box key={`row-${issue.number}`} gap={1}>
341 <Text dimColor>{prefix}</Text>
342 <Text bold={state.isReady === true}>
343 {ref(issue.number)} {cut(issue.title, Math.max(8, room - prefix.length - 36))}
344 </Text>
345 {issue.subs.total > 0 && <Text dimColor>{`${bar} ${issue.subs.done}/${issue.subs.total}`}</Text>}
346 <Text color={state.color}>
347 {state.tag}
348 {state.pr !== undefined ? ref(state.pr, 'pull') : ''}
349 {state.blockers ? blockers(state.blockers) : ''}
350 </Text>
351 {state.isReady && (
352 <Button key={`ship-${issue.number}`} plain label="ship" onPress={() => propose($, fill(config.shipCommand, { n: issue.number }))} />
353 )}
354 {state.isNext && (
355 <Button
356 key={`next-${issue.number}`}
357 plain
358 label="next"
359 onPress={() => propose($, fill(config.mapCommand, { map: mapOf(issue)!, n: issue.number }))}
360 />
361 )}
362 {state.isDone && (
363 <Button
364 key={`close-${issue.number}`}
365 plain
366 label="close"
367 onPress={() => propose($, fill(config.closeRequest, { n: issue.number, total: issue.subs.total }))}
368 />
369 )}
370 {has(issue, config.needsTriage) && (
371 <Button key={`triage-${issue.number}`} plain label="triage" onPress={() => propose($, triage([issue.number]))} />
372 )}
373 </Box>,
374 run && (
375 <Box key={`run-${issue.number}`} gap={1}>
376 <Text dimColor>{pad}</Text>
377 <Text color={run.isStale ? 'red' : 'cyan'} bold={run.isStale}>
378 {cut(run.text, Math.max(12, room - pad.length - 12))}
379 </Text>
380 {run.isStale && (
381 <Button
382 key={`release-${issue.number}`}
383 plain
384 label="release"
385 onPress={() => propose($, fill(config.releaseRequest, { n: issue.number, user: issue.assignees.join(', ') }))}
386 />
387 )}
388 </Box>
389 ),
390 ]
391 }
392
393 // Each sub-issue below `issue`, at every depth.
394 const branch = (issue: Issue, indent: string): ReturnType<typeof row> => [
395 issue.subs.done > 0 ? <Text key={`done-${issue.number}`} dimColor>{`${indent}├ ✓ ${issue.subs.done} done`}</Text> : undefined,
396 ...issue.children.flatMap((n, i) => {
397 const isLast = i === issue.children.length - 1
398 const child = byNumber.get(n)!
399 return [...row(child, `${indent}${isLast ? '└' : '├'}`), ...branch(child, `${indent}${isLast ? ' ' : '│'} `)]
400 }),
401 ]
402
403 return (
404 <Box flexDirection="column" width={room}>
405 <Box gap={1}>
406 <Text color={isCapFull ? 'yellow' : undefined} dimColor={!isCapFull}>
407 {`PR cap ${view.prs}/${config.prCap} · ready now: ${ready} · runs: ${lines.length - stale} active · ${stale} stale`}
408 </Text>
409 <Button key="refresh" plain label="↻" onPress={() => refreshIfOpen($, config)} />
410 </Box>
411 {roots.map(root =>
412 root.subs.total === 0 ? (
413 row(root, '•')
414 ) : (
415 <Box key={`tree-${root.number}`} flexDirection="column" marginTop={1}>
416 {row(root, '▾')}
417 {branch(root, ' ')}
418 </Box>
419 ),
420 )}
421 {waiting.map(({ name, issues, canTriage }) =>
422 issues.length === 0 ? null : (
423 <Box key={name} flexDirection="column" marginTop={1}>
424 <Box gap={1}>
425 <Text bold color="gray">{`${name} (${issues.length})`}</Text>
426 {canTriage && issues.length > 1 && (
427 <Button key="triage-all" plain label="triage all" onPress={() => propose($, triage(issues.map(issue => issue.number)))} />
428 )}
429 </Box>
430 {issues.flatMap((issue, i) => row(issue, i === issues.length - 1 ? ' └' : ' ├'))}
431 </Box>
432 ),
433 )}
434 </Box>
435 )
436 })
437}
438hooks/github.ts 81 lines1// Reads the session repo's open issues and PRs over GitHub REST (`gh api`), never GraphQL, which flakes 401
2// mid-session (docs/adr/0001-mods-may-read-the-session-repo.md).
3
4import type { Issue, Snapshot } from '../types'
5import { closesOf, worktreeIssue } from './rules'
6
7/** Runs argv on the host and answers its stdout, throwing on a nonzero exit; register.tsx owns `$`. */
8export type Runner = (argv: string[]) => Promise<string>
9
10type Raw = Record<string, any>
11
12// Every path read here answers a list; --slurp wraps each page in an outer array.
13const list = async (run: Runner, path: string): Promise<Raw[]> =>
14 (JSON.parse(await run(['gh', 'api', '--paginate', '--slurp', path])) as Raw[][]).flat()
15
16/** Runs `tasks` at most 8 at a time: GitHub's secondary rate limit refuses many concurrent requests. */
17async function inTurn(tasks: (() => Promise<void>)[]) {
18 const queue = [...tasks]
19 await Promise.all(Array.from({ length: 8 }, async () => {
20 for (let task = queue.shift(); task !== undefined; task = queue.shift()) await task()
21 }))
22}
23
24/** The open backlog, `layout` matching each claimed issue to its ship worktree. */
25export async function readBacklog(run: Runner, layout: string, now: string): Promise<Snapshot> {
26 // gh fills `{owner}/{repo}` from the checkout's remotes, and with none on a GitHub host fails in its own words.
27 const repo = (await run(['gh', 'api', 'repos/{owner}/{repo}', '--jq', '.full_name'])).trim()
28 // The first worktree listed is the main checkout.
29 const [main = '', ...paths] = [...(await run(['git', 'worktree', 'list', '--porcelain'])).matchAll(/^worktree (.+)$/gm)].map(m => m[1]!)
30 const worktrees = new Map(paths.map(path => [worktreeIssue(layout, main, path), path.slice(path.lastIndexOf('/') + 1)]))
31
32 const open = await list(run, `repos/${repo}/issues?state=open&per_page=100`)
33 const prs = open.filter(raw => raw.pull_request)
34 const closer = new Map(prs.flatMap(pr => closesOf(pr.body ?? '').map(n => [n, pr.number as number])))
35 const raws = open.filter(raw => !raw.pull_request)
36 const issues: Issue[] = raws.map(raw => ({
37 number: raw.number,
38 title: raw.title,
39 url: raw.html_url,
40 labels: raw.labels.map((l: Raw) => l.name),
41 assignees: raw.assignees.map((a: Raw) => a.login),
42 children: [],
43 subs: { total: raw.sub_issues_summary?.total ?? 0, done: raw.sub_issues_summary?.completed ?? 0 },
44 blockers: [],
45 pr: closer.get(raw.number),
46 worktree: worktrees.get(raw.number),
47 }))
48 // Sub-issues and blockers may live in another repo, so they match by url, never by number.
49 const byUrl = new Map(issues.map(issue => [issue.url, issue]))
50
51 await inTurn(
52 issues.flatMap((issue, i) => [
53 issue.subs.total > 0 && (() =>
54 list(run, `repos/${repo}/issues/${issue.number}/sub_issues?per_page=100`).then(subs => {
55 const children = subs.flatMap(s => byUrl.get(s.html_url) ?? [])
56 issue.children = children.map(child => child.number)
57 for (const child of children) child.parent = issue.number
58 })),
59 raws[i]!.issue_dependencies_summary?.blocked_by > 0 && (() =>
60 list(run, `repos/${repo}/issues/${issue.number}/dependencies/blocked_by`).then(blockers => {
61 issue.blockers = blockers
62 .filter(b => b.state === 'open')
63 .map(b => {
64 const where = b.repository_url.slice(b.repository_url.lastIndexOf('/repos/') + '/repos/'.length)
65 return { label: where === repo ? `#${b.number}` : `${where}#${b.number}`, url: b.html_url }
66 })
67 })),
68 issue.assignees.length > 0 && (() =>
69 list(run, `repos/${repo}/issues/${issue.number}/events?per_page=100`).then(events => {
70 issue.claimedAt = events.findLast(ev => ev.event === 'assigned')?.created_at
71 })),
72 ]).filter(task => task !== false),
73 )
74 return { repo, issues, prs: prs.length, fetchedAt: now }
75}
76
77/** Whether PR `n` of the session repo is merged. */
78export async function isMerged(run: Runner, n: number): Promise<boolean> {
79 return (await run(['gh', 'api', `repos/{owner}/{repo}/pulls/${n}`, '--jq', '.merged'])).trim() === 'true'
80}
81hooks/rules.ts 67 lines1// The pane's rules as pure functions, so a test reaches them without the pane.
2
3const STALE_HOURS = 24
4
5export type RunLine = { text: string; isStale: boolean }
6
7/**
8 * The line under a claimed issue, first match wins: a claim older than a day with no PR closing it is stale, unless
9 * the issue closes without a PR (`expectsPr: false`); a matching ship worktree names the run; with neither and no PR,
10 * the claim's age. A claim with a PR and no worktree has no line, since its row already says `in PR #N`.
11 */
12export function runLine(run: { claimedAt?: string; now: string; hasPr: boolean; expectsPr?: boolean; worktree?: string }): RunLine | null {
13 const hours = run.claimedAt === undefined ? undefined : (Date.parse(run.now) - Date.parse(run.claimedAt)) / 3_600_000
14 const age = hours === undefined ? '' : hours < 24 ? ` ${Math.floor(hours)}h ago` : ` ${Math.floor(hours / 24)}d ago`
15 const expectsPr = run.expectsPr ?? true
16 if (expectsPr && !run.hasPr && hours !== undefined && hours > STALE_HOURS) {
17 return { text: `✗ stale claim: claimed${age}, no PR`, isStale: true }
18 }
19 if (run.worktree !== undefined) return { text: `◐ ship worktree ${run.worktree}`, isStale: false }
20 if (run.hasPr) return null
21 return { text: `◐ claimed${age}${expectsPr ? ', no PR yet' : ''}`, isStale: false }
22}
23
24const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
25
26/**
27 * The issue a worktree belongs to under `layout`, a path relative to the main checkout's parent folder in which
28 * `{repo}` is the main checkout's folder name, `{slug}` any one path segment and `{n}` the issue number.
29 */
30export function worktreeIssue(layout: string, main: string, path: string): number | undefined {
31 const parent = main.slice(0, main.lastIndexOf('/') + 1)
32 if (!path.startsWith(parent)) return undefined
33 const repo = main.slice(parent.length)
34 const pattern = layout
35 .split(/(\{repo\}|\{slug\}|\{n\})/)
36 .map(part => (part === '{repo}' ? escape(repo) : part === '{slug}' ? '[^/]+' : part === '{n}' ? '(\\d+)' : escape(part)))
37 .join('')
38 const n = new RegExp(`^${pattern}$`).exec(path.slice(parent.length))?.[1]
39 return n === undefined ? undefined : Number(n)
40}
41
42/** The issues a PR body closes, by GitHub's closing keywords on a same-repo `#N`. */
43export const closesOf = (body: string): number[] =>
44 [...body.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+#(\d+)\b/gi)].map(m => Number(m[1]))
45
46/** A template with each `{key}` replaced by its value. */
47export const fill = (template: string, values: Record<string, string | number>): string =>
48 template.replace(/\{(\w+)\}/g, (whole, key: string) => (Object.hasOwn(values, key) ? String(values[key]) : whole))
49
50/** The PR a merge-gate answer names: the number in its first `/pull/<N>`, when the answer carries the gate text. */
51export function gatePr(answer: string, gateText: string): number | undefined {
52 const n = answer.includes(gateText) ? /\/pull\/(\d+)/.exec(answer)?.[1] : undefined
53 return n === undefined ? undefined : Number(n)
54}
55
56/** The PR a merged answer names: the first `/pull/<N>` after the merged text on that text's line, as ship's merged line ends. */
57export function mergedPrOf(answer: string, mergedText: string): number | undefined {
58 const at = answer.indexOf(mergedText)
59 return at === -1 ? undefined : gatePr(answer.slice(at).split('\n')[0]!, mergedText)
60}
61
62/** A slash command's name and args, or undefined for text that is no command. */
63export function commandOf(text: string): { command: string; args: string } | undefined {
64 const match = /^\/(\S+)(?:\s+(.*))?$/s.exec(text.trim())
65 return match === null ? undefined : { command: match[1]!, args: match[2] ?? '' }
66}
67types/index.d.ts 43 lines1// The state contract of the gh-pane mod.
2
3/** One open issue of the session repo, as the pane orders it. */
4export type Issue = {
5 number: number
6 title: string
7 url: string
8 labels: string[]
9 assignees: string[]
10 /** Its open sub-issues in this repo, in the parent's order. */
11 children: number[]
12 /** Its sub-issues, all of them and the closed ones (GitHub's `sub_issues_summary`). */
13 subs: { total: number; done: number }
14 /** The open issue it is a sub-issue of. */
15 parent?: number
16 /** Its open blockers, labelled `#N` in this repo and `owner/repo#N` in another. */
17 blockers: { label: string; url: string }[]
18 /** The open PR whose body closes it. */
19 pr?: number
20 /** When it was last assigned: the claim's start. */
21 claimedAt?: string
22 /** The folder name of its ship worktree, when one matches the layout. */
23 worktree?: string
24}
25
26export type Snapshot =
27 | { repo: string; issues: Issue[]; prs: number; fetchedAt: string }
28 | { error: string }
29
30/** Where this session stands at a ship run's merge gate, or once a ship run merged on its own: the PR and the band's offer. */
31export type Gate = {
32 pr: number
33 phase: 'gate' | 'merging' | 'merged'
34 /** Once merged: the command the next button runs after a /clear, the first ready row's. None when nothing is ready or its command is no slash command. */
35 next?: { text: string; command: string; args: string }
36}
37
38declare module 'claude-code' {
39 interface PluginState {
40 'gh-pane': { snapshot: Snapshot | null; gate: Gate | null }
41 }
42}
43