/backlog: this repo's open GitHub issues, a board on desktop and a list in the terminal, by surface then label; each links to GitHub, ↻ refreshes, "work on it"…

/backlog [label] shows the current repo's open GitHub issues as a working board, so you can pick the next thing to do without leaving Claude Code.

desktop or terminal get columns of their own; the rest are grouped by their first label, unlabelled last. /backlog bug shows only issues labelled bug.Needs the GitHub CLI (gh), signed in. Any repo gh can see works, private ones included. Outside a GitHub repo, or when GitHub doesn't answer, it says which in one line.
Status: the terminal list has been run live; the desktop board has passed its tests but I haven't yet seen it live.
claude plugin marketplace add bencsmartucph/ben-mods
claude plugin install backlog@ben-modshooks/register.tsx 298 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BacklogGroup, BacklogIssue, BacklogListing } from '../types'
5
6// Listings by filter, so each /backlog row draws the issues it listed.
7const lists = atom({ plugin: 'backlog', key: 'lists' } as const, {} as Record<string, BacklogListing>)
8// Expanded issues, and issues whose prompt fill the surface refused, as "owner/repo#n".
9const open = atom({ plugin: 'backlog', key: 'open' } as const, [] as string[])
10const unfilled = atom({ plugin: 'backlog', key: 'unfilled' } as const, [] as string[])
11// Per filter: what the last ↻ press said when it failed, or "…" while it runs.
12const refreshing = atom({ plugin: 'backlog', key: 'refreshing' } as const, {} as Record<string, string>)
13
14const UNLABELLED = 'no label'
15const FALLBACK_COLUMNS = 100
16const EXCERPT_LINES = 6
17const CHIP = 'magenta'
18// Surface labels get their own columns first; any other label groups the rest.
19const SURFACES = ['desktop + terminal', 'desktop', 'terminal'] as const
20const MAX_PER_ROW = 3
21
22type GhIssue = {
23 number: number
24 title: string
25 labels?: { name: string }[]
26 body?: string
27 createdAt?: string
28 comments?: unknown[]
29}
30
31/** The state key for a run's arguments: the label filter, case folded. */
32export const keyOf = (args: string) => args.trim().toLowerCase()
33
34/** The first lines of an issue body, leading blank lines dropped, "…" when cut. */
35export function excerpt(body: string, lines = EXCERPT_LINES): string {
36 const all = body.replace(/\r/g, '').replace(/^\s*\n/, '').trimEnd().split('\n')
37 const head = all.slice(0, lines)
38 return all.length > lines ? [...head, '…'].join('\n') : head.join('\n')
39}
40
41/** Compact age: 5m, 3h, 4d, 2mo, 1y. */
42export function age(ms: number): string {
43 const m = Math.max(0, Math.floor(ms / 60_000))
44 if (m < 60) return `${m}m`
45 const h = Math.floor(m / 60)
46 if (h < 24) return `${h}h`
47 const d = Math.floor(h / 24)
48 if (d < 60) return `${d}d`
49 return d < 365 ? `${Math.floor(d / 30)}mo` : `${Math.floor(d / 365)}y`
50}
51
52/** gh's issue as kept in state. */
53export function toIssue(i: GhIssue): BacklogIssue {
54 const t = i.createdAt ? Date.parse(i.createdAt) : NaN
55 return {
56 number: i.number,
57 title: i.title,
58 labels: (i.labels ?? []).map(l => l.name),
59 createdAt: Number.isFinite(t) ? t : 0,
60 comments: Array.isArray(i.comments) ? i.comments.length : 0,
61 excerpt: excerpt(i.body ?? ''),
62 }
63}
64
65/** Which column an issue sits in when no filter is given: its surface, else its first other label. */
66export function columnOf(i: BacklogIssue): string {
67 const names = i.labels.map(n => n.toLowerCase())
68 const d = names.includes('desktop'), t = names.includes('terminal')
69 if (d && t) return SURFACES[0]
70 if (d) return SURFACES[1]
71 if (t) return SURFACES[2]
72 return i.labels[0] ?? UNLABELLED
73}
74
75/** Groups issues: the filter's label, or surface columns first, then other labels, unlabelled last. */
76export function group(issues: BacklogIssue[], filter: string): BacklogGroup[] {
77 const f = filter.trim().toLowerCase()
78 const groups = new Map<string, BacklogGroup>()
79 for (const i of issues) {
80 const hit = f ? i.labels.find(n => n.toLowerCase() === f) : columnOf(i)
81 if (hit === undefined) continue
82 const g = groups.get(hit) ?? { label: hit, issues: [] }
83 g.issues.push(i)
84 groups.set(hit, g)
85 }
86 const rank = (l: string) => {
87 const s = (SURFACES as readonly string[]).indexOf(l)
88 return s >= 0 ? s : l === UNLABELLED ? 99 : 10
89 }
90 const out = [...groups.values()].sort((a, b) => rank(a.label) - rank(b.label) || a.label.localeCompare(b.label))
91 for (const g of out) g.issues.sort((a, b) => a.number - b.number)
92 return out
93}
94
95/** Age and comment count, as the row and the model text both show them. */
96export function meta(i: BacklogIssue, now: number): string {
97 const parts = i.createdAt ? [age(now - i.createdAt)] : []
98 if (i.comments > 0) parts.push(`${i.comments} comment${i.comments === 1 ? '' : 's'}`)
99 return parts.join(' · ')
100}
101
102/** The short text the model reads: a header, then a label line and one line per issue, no bodies. */
103export function asText(listing: BacklogListing, filter: string, now: number): string {
104 const n = listing.groups.reduce((s, g) => s + g.issues.length, 0)
105 const head = `${listing.repo}: ${n} open issue${n === 1 ? '' : 's'}${filter.trim() ? ` labelled ${filter.trim()}` : ''}`
106 const body = listing.groups.flatMap(g => [`${g.label}:`, ...g.issues.map(i => {
107 const m = meta(i, now)
108 return ` #${i.number} ${i.title}${m ? ` (${m})` : ''}`
109 })])
110 return [head, ...body].join('\n')
111}
112
113export const prefill = (repo: string, n: number, title: string) => `Work on ${repo}#${n}: ${title}`
114export const issueUrl = (repo: string, n: number) => `https://github.com/${repo}/issues/${n}`
115
116const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
117const firstLine = (s: string) => s.trim().split(/\r?\n/)[0] ?? ''
118const toggle = (ids: string[] | undefined, id: string) =>
119 (ids ?? []).includes(id) ? (ids ?? []).filter(x => x !== id) : [...(ids ?? []), id]
120
121// gh's words for "this folder has no GitHub repo", as opposed to GitHub not answering.
122const NO_REPO = /not a git repository|no git remotes|none of the git remotes|no default remote|could not determine/i
123
124/** Why `gh repo view` failed, in one line: no repo here, or GitHub (network, sign-in, rate limit). */
125export function whyNoRepo(stderr: string): string {
126 const why = clip(firstLine(stderr) || 'gh repo view failed', 120)
127 return NO_REPO.test(stderr) ? `no GitHub repo here (${why})` : `couldn't reach GitHub (${why})`
128}
129
130/** Runs gh for this folder's repo, or `repo` when known: the listing, or one line saying why not. */
131async function fetchListing($: EngineInterface, filter: string, known?: string): Promise<BacklogListing | string> {
132 let repo = known ?? ''
133 if (!repo) {
134 const view = await $.process.run(['gh', 'repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], { timeoutMs: 20_000 })
135 .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: String(err) }))
136 repo = view.stdout.trim()
137 if (view.exitCode !== 0 || !repo) return whyNoRepo(view.stderr)
138 }
139 const list = await $.process.run(
140 ['gh', 'issue', 'list', '-R', repo, '--state', 'open', '--limit', '300', '--json', 'number,title,labels,body,createdAt,comments'],
141 { timeoutMs: 30_000 },
142 ).catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: String(err) }))
143 if (list.exitCode !== 0) {
144 return `gh issue list failed for ${repo} (${clip(firstLine(list.stderr) || `exit ${list.exitCode}`, 120)})`
145 }
146 let issues: GhIssue[]
147 try {
148 issues = JSON.parse(list.stdout) as GhIssue[]
149 } catch {
150 return `could not read gh's issue list for ${repo}`
151 }
152 return { repo, groups: group(issues.map(toIssue), filter), fetchedAt: await $.clock.now() }
153}
154
155export const register: Register = on => {
156 on('session.start', async ($, e, next) => {
157 await $.command.register({ name: 'backlog', description: 'This repo’s open GitHub issues as a board; press one to read it, ↻ to refresh. Optional arg: a label.' })
158 return next(e)
159 })
160
161 on('command.run', { command: 'backlog' }, async ($, e) => {
162 const filter = (e.args ?? '').trim()
163 const listing = await fetchListing($, filter)
164 if (typeof listing === 'string') return { text: listing }
165 const now = listing.fetchedAt ?? 0
166 if (listing.groups.length === 0) {
167 return { text: `no open issues in ${listing.repo}${filter ? ` labelled ${filter}` : ''}` }
168 }
169 await update($, lists, m => ({ ...(m ?? {}), [keyOf(filter)]: listing }))
170 return { text: asText(listing, filter, now) }
171 })
172
173 on('ui.render', { component: 'CommandOutput', props: { command: 'backlog' } }, async ($, e, next) => {
174 if (e.props.isErrored) return next(e)
175 const filter = e.props.args ?? ''
176 const key = keyOf(filter)
177 const listing = (await read($, lists))[key]
178 // Error lines, empty results and a resumed transcript (state gone) draw as plain text.
179 if (!listing || !e.props.text.includes(`${listing.repo}: `)) return next(e)
180 const opened = await read($, open)
181 const refused = await read($, unfilled)
182 const status = (await read($, refreshing))[key]
183 const now = await $.clock.now()
184 const { Box, Button, Text, Markdown, Link } = $.ui.resolve(e)
185 const width = Math.max(30, (e.viewport?.columns ?? FALLBACK_COLUMNS) - 8)
186 const { repo } = listing
187 const total = listing.groups.reduce((s, g) => s + g.issues.length, 0)
188 const fetched = listing.fetchedAt ? ` · fetched ${age(now - listing.fetchedAt)} ago` : ''
189 const isDesktop = e.surface !== 'terminal'
190
191 const refresh = async () => {
192 await update($, refreshing, m => ({ ...(m ?? {}), [key]: '…' }))
193 // The repo is known from the first run, so a refresh only asks GitHub for the issues.
194 const fresh = await fetchListing($, filter, repo).catch((err: unknown) => clip(String(err), 120))
195 if (typeof fresh === 'string') {
196 await update($, refreshing, m => ({ ...(m ?? {}), [key]: `↻ failed, showing the last list: ${fresh}` }))
197 return
198 }
199 await update($, lists, m => ({ ...(m ?? {}), [key]: fresh }))
200 await update($, refreshing, m => { const { [key]: _gone, ...rest } = m ?? {}; return rest })
201 }
202
203 const header = (
204 <Box key="hdr" flexDirection="row" gap={1} flexWrap="wrap">
205 <Text dimColor>{`${repo} · ${total} open${filter.trim() ? ` · ${filter.trim()}` : ''}${fetched}`}</Text>
206 <Button key="refresh" plain label={status === '…' ? '↻ refreshing…' : '↻ refresh'} onPress={() => void refresh()} />
207 <Link href={`https://github.com/${repo}/issues`} label="GitHub ↗" />
208 {status && status !== '…' ? <Text key="rerr" color="red">{status}</Text> : null}
209 </Box>
210 )
211
212 // The opened part of a row or card: description, then the two ways onward.
213 const detail = (i: BacklogIssue, id: string, fill: string) => (
214 <Box key={`x-${i.number}`} flexDirection="column" marginLeft={isDesktop ? 0 : 4} gap={isDesktop ? 1 : 0}>
215 <Markdown key={`b-${i.number}`} dimColor text={i.excerpt || '_(no description)_'} />
216 <Box flexDirection="row" gap={1} flexWrap="wrap">
217 <Button key={`w-${i.number}`} variant="secondary" label="work on it" onPress={() => void workOn($, id, fill)} />
218 <Link href={issueUrl(repo, i.number)} label="open on GitHub ↗" />
219 </Box>
220 {refused.includes(id) ? <Text key={`f-${i.number}`}>{fill}</Text> : null}
221 </Box>
222 )
223
224 if (isDesktop) {
225 const perRow = Math.min(listing.groups.length, MAX_PER_ROW)
226 const colWidth = `${Math.floor(100 / perRow) - 2}%`
227 return (
228 <Box flexDirection="column" gap={1}>
229 {header}
230 <Box key="board" flexDirection="row" flexWrap="wrap" columnGap={2} rowGap={1} alignItems="flex-start">
231 {listing.groups.map(g => (
232 <Box key={`g-${g.label}`} width={colWidth} flexDirection="column" gap={1} borderStyle="round" borderDimColor paddingX={1} paddingY={1}>
233 <Text bold>{`${g.label} · ${g.issues.length}`}</Text>
234 {g.issues.map(i => {
235 const id = `${repo}#${i.number}`
236 const isOpen = opened.includes(id)
237 const m = meta(i, now)
238 const others = i.labels.filter(l => l.toLowerCase() !== g.label && !(['desktop', 'terminal'].includes(l.toLowerCase()) && (SURFACES as readonly string[]).includes(g.label)))
239 return (
240 <Box key={`r-${i.number}`} flexDirection="column" borderStyle="single" borderDimColor paddingX={1}>
241 <Box flexDirection="row" gap={1}>
242 <Link href={issueUrl(repo, i.number)} label={`#${i.number}`} />
243 <Button key={`i-${i.number}`} plain label={`${isOpen ? '▾' : '▸'} ${i.title}`}
244 onPress={() => update($, open, ids => toggle(ids, id))} />
245 </Box>
246 <Box flexDirection="row" gap={1} flexWrap="wrap">
247 {others.map(l => <Text key={`c-${i.number}-${l}`} color={CHIP}>{`[${l}]`}</Text>)}
248 {m ? <Text dimColor>{m}</Text> : null}
249 </Box>
250 {isOpen ? detail(i, id, prefill(repo, i.number, i.title)) : null}
251 </Box>
252 )
253 })}
254 </Box>
255 ))}
256 </Box>
257 </Box>
258 )
259 }
260
261 return (
262 <Box flexDirection="column">
263 {header}
264 {listing.groups.map(g => (
265 <Box key={`g-${g.label}`} flexDirection="column">
266 <Text bold>{g.label}</Text>
267 {g.issues.map(i => {
268 const id = `${repo}#${i.number}`
269 const isOpen = opened.includes(id)
270 const m = meta(i, now)
271 return (
272 <Box key={`r-${i.number}`} flexDirection="column">
273 <Box flexDirection="row" gap={1}>
274 <Button key={`i-${i.number}`} plain
275 label={clip(`${isOpen ? '▾' : '▸'} #${i.number} ${i.title}`, Math.max(20, width - 28))}
276 onPress={() => update($, open, ids => toggle(ids, id))} />
277 {i.labels.map(l => <Text key={`c-${i.number}-${l}`} color={CHIP}>{`[${l}]`}</Text>)}
278 {m ? <Text dimColor>{m}</Text> : null}
279 <Link href={issueUrl(repo, i.number)} label="↗" />
280 </Box>
281 {isOpen ? detail(i, id, prefill(repo, i.number, i.title)) : null}
282 </Box>
283 )
284 })}
285 </Box>
286 ))}
287 </Box>
288 )
289 })
290}
291
292/** Fills the prompt; where the surface refuses, marks the row so it shows the text to copy. */
293async function workOn($: EngineInterface, id: string, text: string): Promise<void> {
294 const r = await $.prompt.fill({ text, mode: 'replace' }).catch(() => ({ isFilled: false }))
295 const isFilled = r.isFilled === true
296 await update($, unfilled, ids => isFilled ? (ids ?? []).filter(x => x !== id) : (ids ?? []).includes(id) ? (ids ?? []) : [...(ids ?? []), id])
297}
298types/index.d.ts 28 lines1export type BacklogIssue = {
2 number: number
3 title: string
4 labels: string[]
5 /** When it was opened, epoch ms. */
6 createdAt: number
7 comments: number
8 /** The first lines of the body, markdown. */
9 excerpt: string
10}
11export type BacklogGroup = { label: string; issues: BacklogIssue[] }
12export type BacklogListing = { repo: string; groups: BacklogGroup[]; /** When gh answered, epoch ms. */ fetchedAt?: number }
13
14declare module 'claude-code' {
15 interface PluginState {
16 backlog: {
17 // One listing per /backlog filter (lowercased args), so each row draws its own.
18 lists: Record<string, BacklogListing>
19 // Expanded rows, as "owner/repo#n".
20 open: string[]
21 // Rows whose prompt fill the surface refused, so the text is shown to copy.
22 unfilled: string[]
23 // Per filter: "…" while ↻ runs, or the line saying why it failed.
24 refreshing: Record<string, string>
25 }
26 }
27}
28