A side pane listing the repository's GitHub issues, with one click to have Claude work on one

A side pane in Claude Code that lists a repository's GitHub issues as cards.
<img src="docs/issues.png" alt="The Issues pane: tabs, search, a label picker, and issue cards with labels, linked pull requests and actions" width="380"> <img src="docs/picker.png" alt="The repository picker: a filter box and repositories grouped by owner, with open issue counts" width="380">
<sub>Screenshots show made-up repositories, people and issues.</sub>
#number (opens the issue on GitHub), assignees, last update, comment count, linked pull request (open, draft, merged or closed), and labels as small pills in GitHub's colorsThe pane shows the 40 most recently updated issues that match. A spinner shows while it loads. The list refreshes when you open the pane, after each of Claude's turns, every two minutes, and when you press Refresh. If a repository's issues are too large to fit in the pane at once, the pane shows as many as fit and says how many more there are. Use search or a label to narrow the list. If you leave the pane open, it opens again in your next session.
| Command | |
|---|---|
/issues | Started in a folder whose repository is on GitHub: that repository's issues. Anywhere else: pick a repository, the 30 you pushed to most recently (yours, collaborations and your organizations'), each with its open issue count. Switch repo opens the picker from the issues |
/issues owner/name | Show that repository's issues |
/issues . | Show the session's repository (from the origin remote) |
In the picker, repositories are grouped by owner. Type in the filter box to narrow the list. Enter opens the first match, or any owner/name you type. Switch repo in the pane goes back to the picker. If the session isn't in a GitHub repository, the pane opens on the picker.
While the pane is open, the mod checks every two minutes which open issues are assigned to you in the repository it's watching: the one you picked, or the session's own. With the pane closed it makes no requests. The first check of a repository is silent and only records the current issues. After that, each newly assigned issue raises a toast. The issue numbers already seen are kept in Claude Code's plugin store, so alerts carry over between sessions.
The GitHub CLI (gh), signed in with gh auth login. The mod runs gh api graphql (issues, labels, assigned issues and repositories) and gh issue view on your machine. Nothing leaves your machine except those GitHub requests.
/plugin marketplace add MarcoCarnevali/claude-code-mods
/plugin install github-issues@marco-mods
/reload-pluginshooks/register.tsx 842 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type {
5 ActiveIssue,
6 Issue,
7 IssueDetail,
8 IssueFilter,
9 IssueLabel,
10 IssueView,
11 IssuesPane,
12 OpenIssue,
13 RepoChoice,
14 RepoPicker,
15} from '../types'
16import {
17 FILTERS,
18 SPINNER_MS,
19 SPINNER_SVG,
20 assignedToast,
21 byOwner,
22 chips,
23 excerpt,
24 fitCards,
25 isRepoName,
26 isSameRepo,
27 issuesArgs,
28 labelDot,
29 labelOptions,
30 labelPills,
31 matchesQuery,
32 meta,
33 mineArgs,
34 newlyAssigned,
35 parseAssigned,
36 parseDetail,
37 parseIssuesPage,
38 parseRemote,
39 parseRepos,
40 pickKey,
41 prBadge,
42 repoMeta,
43 reposArgs,
44 spinnerFrame,
45 viewArgs,
46 workPrompt,
47} from './lib'
48import type { Assigned, IssuesPage } from './lib'
49
50const PANE = 'github-issues'
51const TITLE = 'Issues'
52const REFRESH_MS = 2 * 60_000
53const GH_PATHS = ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh']
54// Mid-gray: a card's edge that reads on a light pane and on a dark one.
55const CARD_BORDER = '#8c959f'
56
57const EMPTY_PAGE: IssuesPane = {
58 repo: null,
59 scope: { filter: 'open', search: '', label: '' },
60 load: { kind: 'empty' },
61 issues: [],
62 total: 0,
63 labels: [],
64 assigned: 0,
65}
66
67const view = atom({ plugin: 'github-issues', key: 'view' } as const, 'issues')
68const query = atom({ plugin: 'github-issues', key: 'query' } as const, '')
69const picker = atom({ plugin: 'github-issues', key: 'picker' } as const, {
70 load: { kind: 'empty' },
71 repos: [],
72 here: null,
73})
74const pinned = atom({ plugin: 'github-issues', key: 'pinned' } as const, null)
75const page = atom({ plugin: 'github-issues', key: 'page' } as const, EMPTY_PAGE)
76const searchDraft = atom({ plugin: 'github-issues', key: 'searchDraft' } as const, '')
77const open = atom({ plugin: 'github-issues', key: 'open' } as const, null)
78const active = atom({ plugin: 'github-issues', key: 'active' } as const, null)
79
80type Gh = { ok: true; stdout: string } | { ok: false; message: string }
81
82let ghPath: string | undefined
83let generation = 0
84let repoGeneration = 0
85let spinner: Timer | undefined
86const details = new Map<string, IssueDetail>()
87
88/**
89 * The setters write a value only when it changed. Every write redraws the
90 * pane, and a redraw gives every Button a new handle, so a press already on
91 * its way with the old one is dropped: the fewer redraws, the fewer lost taps.
92 */
93function same(a: unknown, b: unknown): boolean {
94 return JSON.stringify(a) === JSON.stringify(b)
95}
96
97async function setView($: EngineInterface, value: IssueView): Promise<void> {
98 if (!same(await read($, view), value)) await update($, view, () => value)
99}
100
101async function setQuery($: EngineInterface, value: string): Promise<void> {
102 if (!same(await read($, query), value)) await update($, query, () => value)
103}
104
105async function setPicker($: EngineInterface, value: RepoPicker): Promise<void> {
106 if (!same(await read($, picker), value)) await update($, picker, () => value)
107}
108
109async function setPinned($: EngineInterface, value: string | null): Promise<void> {
110 if (!same(await read($, pinned), value)) await update($, pinned, () => value)
111}
112
113async function setPage($: EngineInterface, value: IssuesPane): Promise<void> {
114 if (!same(await read($, page), value)) await update($, page, () => value)
115}
116
117async function setSearchDraft($: EngineInterface, value: string): Promise<void> {
118 if (!same(await read($, searchDraft), value)) await update($, searchDraft, () => value)
119}
120
121async function setOpen($: EngineInterface, value: OpenIssue | null): Promise<void> {
122 if (!same(await read($, open), value)) await update($, open, () => value)
123}
124
125async function setActive($: EngineInterface, value: ActiveIssue | null): Promise<void> {
126 if (!same(await read($, active), value)) await update($, active, () => value)
127}
128
129async function gh($: EngineInterface, args: string[]): Promise<Gh> {
130 if (ghPath === undefined) {
131 for (const candidate of GH_PATHS) {
132 try {
133 const probe = await $.process.run([candidate, '--version'], { timeoutMs: 5_000 })
134 if (probe.exitCode === 0) {
135 ghPath = candidate
136 break
137 }
138 } catch {
139 // not installed at this path
140 }
141 }
142 }
143 if (ghPath === undefined) {
144 return { ok: false, message: 'The GitHub CLI (gh) was not found. Install it, then run `gh auth login`.' }
145 }
146
147 try {
148 const ran = await $.process.run([ghPath, ...args], { timeoutMs: 30_000 })
149 if (ran.exitCode === 0) return { ok: true, stdout: ran.stdout }
150 const reason = ran.stderr.trim().split('\n')[0] ?? ''
151
152 return { ok: false, message: reason || `gh exited with code ${ran.exitCode}` }
153 } catch {
154 return { ok: false, message: 'gh did not answer in time.' }
155 }
156}
157
158async function detectRepo($: EngineInterface): Promise<string | null> {
159 const found = await $.session.repo()
160 const fromRemote = found?.remote ? parseRemote(found.remote) : null
161 if (fromRemote !== null || found === null) return fromRemote
162
163 const viewed = await gh($, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'])
164
165 return viewed.ok && isRepoName(viewed.stdout.trim()) ? viewed.stdout.trim() : null
166}
167
168/**
169 * Reloads the issues shown; with no repository to show, opens the picker.
170 * `isQuiet` (the timer, a finished turn): no spinner, the list kept on a
171 * failure, and no redraw at all unless something changed.
172 */
173async function refresh($: EngineInterface, isQuiet = false): Promise<void> {
174 if ((await read($, view)) !== 'issues') return
175 try {
176 await loadIssues($, isQuiet)
177 } finally {
178 await syncSpinner($)
179 }
180}
181
182async function loadIssues($: EngineInterface, isQuiet: boolean): Promise<void> {
183 const mine = ++generation
184 const target = (await read($, pinned)) ?? (await detectRepo($))
185 if (mine !== generation) return
186 if (target === null) {
187 await setPage($, EMPTY_PAGE)
188 await showRepos($)
189 return
190 }
191
192 const before = await read($, page)
193 const isNewRepo = before.repo !== target
194 let current: IssuesPane = isNewRepo
195 ? { ...EMPTY_PAGE, repo: target, scope: { ...EMPTY_PAGE.scope, filter: before.scope.filter } }
196 : before
197 if (isNewRepo) {
198 await setOpen($, null)
199 await setActive($, null)
200 await setSearchDraft($, '')
201 }
202 if (isNewRepo || !isQuiet || current.load.kind !== 'ready') {
203 current = { ...current, load: { kind: 'loading' } }
204 await setPage($, current)
205 void syncSpinner($)
206 }
207
208 const { filter, search, label } = current.scope
209 const listed = await gh($, issuesArgs(target, filter, search, label))
210 if (mine !== generation) return
211 const keepsList = isQuiet && current.load.kind === 'ready'
212 if (!listed.ok) {
213 if (!keepsList) await setPage($, { ...current, load: { kind: 'error', message: listed.message } })
214 return
215 }
216
217 let parsed: IssuesPage
218 try {
219 parsed = parseIssuesPage(listed.stdout)
220 } catch {
221 const message = 'gh answered with something that is not JSON.'
222 if (!keepsList) await setPage($, { ...current, load: { kind: 'error', message } })
223 return
224 }
225 await noteAssigned($, target, parsed.assigned)
226 await setPage($, {
227 ...current,
228 load: { kind: 'ready' },
229 issues: parsed.issues,
230 total: parsed.total,
231 labels: parsed.labels,
232 assigned: parsed.assigned.total,
233 })
234}
235
236/** A toast for issues assigned since the last look at `target`, which the store keeps across sessions. */
237async function noteAssigned($: EngineInterface, target: string, mine: Assigned): Promise<void> {
238 const key = `seen:${target}`
239 const toast = assignedToast(target, newlyAssigned(mine, await $.store.get(key)))
240 if (toast !== undefined) $.ui.toast(toast, { timeoutMs: 8_000 })
241 await $.store.set(
242 key,
243 mine.issues.map(issue => issue.number),
244 )
245}
246
247/** The check while the picker shows instead of the list: only the person's assigned issues. */
248async function checkAssigned($: EngineInterface): Promise<void> {
249 const target = (await read($, pinned)) ?? (await detectRepo($))
250 if (target === null) return
251 const listed = await gh($, mineArgs(target))
252 if (!listed.ok) return
253 let mine: Assigned
254 try {
255 mine = parseAssigned(listed.stdout)
256 } catch {
257 return
258 }
259 await noteAssigned($, target, mine)
260 const current = await read($, page)
261 if (current.repo === target) await setPage($, { ...current, assigned: mine.total })
262}
263
264/** The timer: nothing while the pane is closed; the list, or the assigned check alone in the picker. */
265async function tick($: EngineInterface): Promise<void> {
266 if (!(await isOpen($))) return
267 if ((await read($, view)) === 'issues') await refresh($, true)
268 else await checkAssigned($)
269}
270
271/** Narrows the list: the new scope and the spinner in one write, the results in the next. */
272async function rescope($: EngineInterface, change: Partial<IssuesPane['scope']>): Promise<void> {
273 await setOpen($, null)
274 const current = await read($, page)
275 await setPage($, { ...current, scope: { ...current.scope, ...change }, load: { kind: 'loading' } })
276 await refresh($)
277}
278
279async function submitSearch($: EngineInterface, text: string): Promise<void> {
280 await setSearchDraft($, text)
281 await rescope($, { search: text.trim() })
282}
283
284async function clearSearch($: EngineInterface): Promise<void> {
285 await setSearchDraft($, '')
286 await rescope($, { search: '', label: '' })
287}
288
289/** Switches the pane to the repository picker and loads its choices. */
290async function showRepos($: EngineInterface): Promise<void> {
291 try {
292 await loadRepos($)
293 } finally {
294 await syncSpinner($)
295 }
296}
297
298async function loadRepos($: EngineInterface): Promise<void> {
299 const mine = ++repoGeneration
300 // The list doesn't read these, so they redraw nothing until the view switches.
301 const before = await read($, picker)
302 await setPicker($, { ...before, load: { kind: 'loading' } })
303 await setQuery($, '')
304 await setView($, 'repos')
305 void syncSpinner($)
306
307 const session = await detectRepo($)
308 const listed = await gh($, reposArgs())
309 if (mine !== repoGeneration) return
310 if (!listed.ok) {
311 await setPicker($, { ...before, here: session, load: { kind: 'error', message: listed.message } })
312 return
313 }
314
315 let parsed: RepoChoice[]
316 try {
317 parsed = parseRepos(listed.stdout)
318 } catch {
319 const message = 'gh answered with something that is not JSON.'
320 await setPicker($, { ...before, here: session, load: { kind: 'error', message } })
321 return
322 }
323 await setPicker($, { load: { kind: 'ready' }, repos: parsed, here: session })
324}
325
326/** Shows `name`'s issues: pinned, unless it is the session's own repository. */
327async function chooseRepo($: EngineInterface, name: string): Promise<void> {
328 const session = (await read($, picker)).here
329 await setPinned($, name === session ? null : name)
330 await backToIssues($)
331}
332
333/** Enter in the picker's filter: the name typed, else the first match. */
334async function submitQuery($: EngineInterface, text: string): Promise<void> {
335 const typed = text.trim()
336 if (isRepoName(typed)) {
337 await chooseRepo($, typed)
338 return
339 }
340 const { here, repos } = await read($, picker)
341 const names = [...(here === null ? [] : [here]), ...repos.map(one => one.name)]
342 const first = names.find(name => matchesQuery(name, typed))
343 if (first !== undefined) await chooseRepo($, first)
344}
345
346/** Back to the list: its spinner set while the picker still shows, so the switch is one redraw. */
347async function backToIssues($: EngineInterface): Promise<void> {
348 const current = await read($, page)
349 await setPage($, { ...current, load: { kind: 'loading' } })
350 await setView($, 'issues')
351 await refresh($)
352}
353
354async function isOpen($: EngineInterface): Promise<boolean> {
355 return (await $.ui.panes()).some(pane => pane.id === PANE)
356}
357
358async function openPane($: EngineInterface): Promise<void> {
359 await $.ui.open({ id: PANE, title: TITLE })
360 await $.store.set('isOpen', true)
361}
362
363async function toggleDetail($: EngineInterface, number: number): Promise<void> {
364 try {
365 await loadDetail($, number)
366 } finally {
367 await syncSpinner($)
368 }
369}
370
371/**
372 * Keeps the terminal's spinner turning while anything loads: a redraw per
373 * frame, and only then. Surfaces that draw SVG show one that turns by itself.
374 */
375async function syncSpinner($: EngineInterface): Promise<void> {
376 const shown = await read($, open)
377 const isBusy =
378 (await read($, page)).load.kind === 'loading' ||
379 (await read($, picker)).load.kind === 'loading' ||
380 (shown !== null && shown.detail === null)
381 const surfaces: readonly string[] = await $.session.surfaces().catch(() => [])
382 const isTicking = isBusy && surfaces.includes('terminal')
383 if (isTicking && spinner === undefined) {
384 spinner = $.clock.every(SPINNER_MS, () => $.ui.invalidate('ui.render'))
385 } else if (!isTicking && spinner !== undefined) {
386 spinner.cancel()
387 spinner = undefined
388 }
389}
390
391async function loadDetail($: EngineInterface, number: number): Promise<void> {
392 if ((await read($, open))?.number === number) {
393 await setOpen($, null)
394 return
395 }
396 const target = (await read($, page)).repo
397 if (target === null) return
398 const key = `${target}#${number}`
399 const known = details.get(key) ?? null
400 await setOpen($, { number, detail: known })
401 if (known !== null) return
402 void syncSpinner($)
403
404 const viewed = await gh($, viewArgs(target, number))
405 if ((await read($, open))?.number !== number) return
406 let loaded: IssueDetail
407 if (!viewed.ok) {
408 loaded = { number, body: `Could not load this issue: ${viewed.message}`, comments: 0 }
409 } else {
410 try {
411 loaded = parseDetail(viewed.stdout)
412 details.set(key, loaded)
413 } catch {
414 loaded = { number, body: 'gh answered with something that is not JSON.', comments: 0 }
415 }
416 }
417 await setOpen($, { number, detail: loaded })
418}
419
420/**
421 * Hands `issue` to Claude as a prompt of its own. Claude Code starts a mod's
422 * prompt once the session is idle, so while a turn runs it waits, and the
423 * card says Queued until Claude's turn on it begins. Not sent, with a toast
424 * saying why, when the session works in another repository, or when the
425 * issue was already handed over.
426 */
427async function workOn($: EngineInterface, issue: Issue): Promise<void> {
428 const target = (await read($, page)).repo
429 if (target === null) return
430 const current = await read($, active)
431 if (current?.number === issue.number) {
432 $.ui.toast(current.state === 'queued' ? `#${issue.number} is already queued for Claude.` : `Claude is already on #${issue.number}.`)
433 return
434 }
435 const session = await detectRepo($)
436 if (!isSameRepo(session, target)) {
437 $.ui.toast(
438 `This session works in ${session ?? 'a folder with no GitHub repository'}, not ${target}. Open Claude Code in ${target}'s folder to hand Claude its issues.`,
439 { timeoutMs: 10_000 },
440 )
441 return
442 }
443
444 await setActive($, { number: issue.number, state: 'queued' })
445 let outcome: 'started' | 'dropped' | undefined
446 const submitted = $.prompt.submit({ text: workPrompt(target, issue) }).then(
447 result => {
448 outcome = result.drop === undefined ? 'started' : 'dropped'
449 },
450 () => {
451 outcome = 'dropped'
452 },
453 )
454 await Promise.race([submitted, $.clock.sleep(500)])
455 if (outcome === undefined) {
456 $.ui.toast(`#${issue.number} is queued: Claude starts on it once it finishes the current task.`, { timeoutMs: 8_000 })
457 }
458 await submitted
459 if ((await read($, active))?.number !== issue.number) return
460 if (outcome === 'dropped') {
461 await setActive($, null)
462 $.ui.toast(`#${issue.number} was not handed to Claude.`)
463 return
464 }
465 await setActive($, { number: issue.number, state: 'working' })
466}
467
468export const register: Register = on => {
469 on('session.start', async ($, e, next) => {
470 await $.command.register({
471 name: 'issues',
472 description: "Show GitHub issues in a side pane: this folder's repository, or pick or name one",
473 argumentHint: '[owner/name | .]',
474 })
475 const started = await next(e)
476 // Versions before 0.1.0's release pinned a status line; take down any left from them.
477 $.ui.status(undefined)
478
479 $.clock.every(REFRESH_MS, () => tick($))
480 if ((await $.store.get('isOpen')) === true) {
481 void (async () => {
482 await openPane($)
483 await refresh($)
484 })()
485 }
486
487 return started
488 })
489
490 on('command.run', { command: 'issues' }, async ($, e) => {
491 let arg = e.args.trim()
492 // Started in a folder whose repository is on GitHub, /issues alone shows its issues; elsewhere, the picker.
493 if (arg === '' && (await detectRepo($)) !== null) arg = '.'
494 if (arg === '') {
495 await openPane($)
496 await showRepos($)
497
498 return { text: 'Choose a repository in the Issues pane.' }
499 }
500 if (arg !== '.' && !isRepoName(arg)) {
501 return { text: `"${arg}" is not a repository. Use /issues owner/name, /issues . for this session's repository, or /issues to pick one.` }
502 }
503
504 await setPinned($, arg === '.' ? null : arg)
505 await setView($, 'issues')
506 await openPane($)
507 await refresh($)
508
509 const shown = (await read($, page)).repo
510
511 return { text: shown === null ? 'Choose a repository in the Issues pane.' : `Showing ${shown} issues.` }
512 })
513
514 on('turn.complete', async ($, e, next) => {
515 const done = await next(e)
516 if (e.agentId === undefined && (await isOpen($))) void refresh($, true)
517
518 return done
519 })
520
521 on('ui.close', async ($, e, next) => {
522 if (e.id === PANE && e.origin.kind === 'person') await $.store.set('isOpen', false)
523
524 return next(e)
525 })
526
527 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
528 const table = $.ui.resolve(e)
529 const { Box, Button, Link, Markdown, Text } = table
530 const Input = 'Input' in table ? table.Input : undefined
531 const Select = 'Select' in table ? table.Select : undefined
532 // The terminal's table answers Svg with an element that draws nothing, so ask the surface.
533 const Svg = e.surface !== 'terminal' && 'Svg' in table ? table.Svg : undefined
534 const now = await $.clock.now()
535 const busy = (text: string) => (
536 <Box flexDirection="row" columnGap={1} alignItems="center">
537 {Svg === undefined ? (
538 <Text color="claude">{spinnerFrame(now)}</Text>
539 ) : (
540 <Svg source={SPINNER_SVG} alt="Loading" width={14} height={14} isInteractive />
541 )}
542 <Text dimColor>{text}</Text>
543 </Box>
544 )
545
546 if ((await read($, view)) === 'repos') {
547 const { here, repos: choices, load: status } = await read($, picker)
548 const shown = (await read($, page)).repo
549 const typed = await read($, query)
550 const sections = byOwner(choices, here, typed)
551 const isSessionShown = here !== null && matchesQuery(here, typed)
552 const isEmpty = sections.length === 0 && !isSessionShown
553
554 const row = (name: string, label: string) => (
555 <Box key={`repo:${name}`} flexDirection="row" columnGap={2}>
556 <Button
557 key={pickKey(name)}
558 label={name === shown ? `● ${label}` : label}
559 plain
560 onPress={() => void chooseRepo($, name)}
561 />
562 <Text dimColor wrap="truncate-end">
563 {repoMeta(
564 choices.find(one => one.name === name),
565 now,
566 )}
567 </Text>
568 </Box>
569 )
570
571 return (
572 <Box flexDirection="column">
573 <Box flexDirection="row" justifyContent="space-between">
574 <Text bold>Choose a repository</Text>
575 {shown !== null && <Button key="back" label="Back" plain onPress={() => void backToIssues($)} />}
576 </Box>
577 {Input !== undefined && (
578 <Box marginTop={1}>
579 <Input
580 key="repo-filter"
581 placeholder="Filter, or type owner/name"
582 value={typed}
583 submitLabel="open"
584 onInput={text => void setQuery($, text)}
585 onSubmit={text => void submitQuery($, text)}
586 />
587 </Box>
588 )}
589 {status.kind !== 'ready' && (
590 <Box marginTop={1}>
591 {status.kind === 'error' ? (
592 <Text dimColor wrap="wrap">
593 {status.message}
594 </Text>
595 ) : (
596 busy(choices.length > 0 ? 'Refreshing…' : 'Loading repositories…')
597 )}
598 </Box>
599 )}
600 {isSessionShown && (
601 <Box flexDirection="column" marginTop={1}>
602 <Text dimColor bold>
603 This session
604 </Text>
605 {row(here, here)}
606 </Box>
607 )}
608 {sections.map(section => (
609 <Box key={`owner:${section.owner}`} flexDirection="column" marginTop={1}>
610 <Text dimColor bold>
611 {section.owner}
612 </Text>
613 {section.repos.map(choice => row(choice.name, choice.name.slice(section.owner.length + 1)))}
614 </Box>
615 ))}
616 {status.kind === 'ready' && isEmpty && (
617 <Box marginTop={1}>
618 <Text dimColor wrap="wrap">
619 {isRepoName(typed.trim())
620 ? `Press Enter to open ${typed.trim()}.`
621 : typed === ''
622 ? 'No repositories found. Run /issues owner/name.'
623 : 'No match. Type owner/name and press Enter to open any repository.'}
624 </Text>
625 </Box>
626 )}
627 </Box>
628 )
629 }
630
631 const { repo: shown, scope, load: status, issues: list, total, labels, assigned } = await read($, page)
632 const opened = await read($, open)
633 const working = await read($, active)
634 const draft = await read($, searchDraft)
635 const noun = FILTERS.find(one => one.id === scope.filter)?.noun ?? scope.filter
636 const counted = `${total > list.length ? `${list.length} of ${total}` : list.length} ${noun}`
637 const narrowing = [
638 scope.search === '' ? '' : `matching "${scope.search}"`,
639 scope.label === '' ? '' : `label ${scope.label}`,
640 ].filter(Boolean)
641 const labelChoices = labelOptions(labels, list, scope.label)
642 // A card's inside: the pane's width less its border and padding.
643 const rule = Math.max(1, e.props.bodyColumns - 4)
644 const [owner, name] = shown === null ? [null, null] : (shown.split('/') as [string, string])
645 // Labels are pills in one SVG where the surface draws SVG; on the terminal, dots and names.
646 // A cell is about 7.5 CSS pixels wide on the surfaces that draw SVG.
647 const pillsWidth = Math.max(160, Math.min(360, Math.floor(rule * 7.5)))
648 const chipsOf = (shownLabels: readonly IssueLabel[]) => {
649 if (Svg === undefined) {
650 return shownLabels.map(one => (
651 <Box flexDirection="row">
652 <Text color={labelDot(one.color)}>●</Text>
653 <Text>{` ${one.name}`}</Text>
654 </Box>
655 ))
656 }
657 const pills = labelPills(shownLabels, pillsWidth)
658
659 return [
660 <Svg
661 source={pills.source}
662 alt={`Labels: ${shownLabels.map(one => one.name).join(', ')}`}
663 width={pills.width}
664 height={pills.height}
665 />,
666 ]
667 }
668
669 const top = (
670 <Box flexDirection="column">
671 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
672 <Box flexDirection="row" flexShrink={1}>
673 {owner === null ? (
674 <Text bold>No repository</Text>
675 ) : (
676 <Text dimColor wrap="truncate-end">{`${owner} / `}</Text>
677 )}
678 {name !== null && (
679 <Text bold wrap="truncate-end">
680 {name}
681 </Text>
682 )}
683 </Box>
684 <Box flexDirection="row" columnGap={2} flexShrink={0}>
685 <Button key="switch" label="Switch repo" plain onPress={() => void showRepos($)} />
686 <Button key="refresh" label="Refresh" plain onPress={() => void rescope($, {})} />
687 </Box>
688 </Box>
689 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
690 {FILTERS.map(one => (
691 <Button
692 key={`filter:${one.id}`}
693 label={one.id === 'assigned' && assigned > 0 ? `${one.label} · ${assigned}` : one.label}
694 variant={one.id === scope.filter ? 'primary' : 'secondary'}
695 onPress={() => void rescope($, { filter: one.id as IssueFilter })}
696 />
697 ))}
698 </Box>
699 {(Input !== undefined || Select !== undefined) && (
700 <Box flexDirection="column" marginTop={1} rowGap={1}>
701 {Input !== undefined && (
702 <Input
703 key="issue-search"
704 placeholder="Search issues"
705 value={draft}
706 submitLabel="Search"
707 onInput={text => void setSearchDraft($, text)}
708 onSubmit={text => void submitSearch($, text)}
709 />
710 )}
711 {Select !== undefined && labelChoices.length > 1 && (
712 <Select
713 key="label-filter"
714 label="Label"
715 value={scope.label}
716 options={labelChoices}
717 onSelect={value => void rescope($, { label: value })}
718 />
719 )}
720 </Box>
721 )}
722 <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginTop={1}>
723 {status.kind === 'loading' || status.kind === 'empty' ? (
724 busy(list.length > 0 ? `${counted} · refreshing…` : 'Loading issues…')
725 ) : (
726 <Text dimColor wrap="wrap">
727 {status.kind === 'error' ? status.message : list.length > 0 ? counted : `No issues ${noun}.`}
728 </Text>
729 )}
730 {narrowing.length > 0 && (
731 <Box flexDirection="row" columnGap={2}>
732 <Text dimColor wrap="truncate-end">
733 {narrowing.join(', ')}
734 </Text>
735 <Button key="clear-filters" label="Clear" plain onPress={() => void clearSearch($)} />
736 </Box>
737 )}
738 </Box>
739 </Box>
740 )
741
742 const cards = list.map(issue => {
743 const isOpenHere = opened?.number === issue.number
744 const handed = working?.number === issue.number ? working.state : null
745 const isWorking = handed !== null
746 const shownLabels = chips(issue.labels)
747 const about = meta(issue, now)
748 const pr = issue.pr === null ? null : { ...issue.pr, ...prBadge(issue.pr) }
749
750 return (
751 <Box
752 key={`issue:${issue.number}`}
753 flexDirection="column"
754 marginTop={1}
755 paddingX={1}
756 borderStyle="bold"
757 borderColor={isWorking ? 'claude' : CARD_BORDER}
758 >
759 <Text bold wrap="wrap" {...(isWorking ? { color: 'claude' } : {})}>
760 {issue.title}
761 </Text>
762 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
763 <Link href={issue.url} label={`#${issue.number}`} />
764 {about !== '' && <Text dimColor>{`· ${about}`}</Text>}
765 {pr !== null && (
766 <Box key={`pr:${issue.number}`} flexDirection="row" columnGap={1}>
767 <Text dimColor>·</Text>
768 <Link href={pr.url} label={`PR #${pr.number}`} />
769 <Text color={pr.color}>{pr.text}</Text>
770 </Box>
771 )}
772 </Box>
773 {shownLabels.shown.length > 0 && (
774 <Box
775 key={`labels:${issue.number}`}
776 flexDirection="row"
777 flexWrap="wrap"
778 alignItems="center"
779 columnGap={1}
780 marginTop={1}
781 >
782 {chipsOf(shownLabels.shown)}
783 {shownLabels.more > 0 && <Text dimColor>{`+${shownLabels.more}`}</Text>}
784 </Box>
785 )}
786 <Box flexDirection="row" columnGap={3} marginTop={1}>
787 <Button
788 key={`work:${issue.number}`}
789 label={handed === 'queued' ? '◷ Queued' : handed === 'working' ? '● Working on it' : 'Work on it'}
790 plain
791 onPress={() => void workOn($, issue)}
792 />
793 <Button
794 key={`details:${issue.number}`}
795 label={isOpenHere ? 'Hide details' : 'Details'}
796 plain
797 onPress={() => void toggleDetail($, issue.number)}
798 />
799 </Box>
800 {isOpenHere && (
801 <Box flexDirection="column" marginTop={1}>
802 <Text dimColor wrap="truncate-end">
803 {'─'.repeat(rule)}
804 </Text>
805 {opened.detail !== null ? (
806 <Box flexDirection="column">
807 <Markdown key={`body:${issue.number}`} text={excerpt(opened.detail.body)} />
808 <Box flexDirection="row" columnGap={1} marginTop={1}>
809 <Text dimColor>
810 {opened.detail.comments === 1 ? '1 comment' : `${opened.detail.comments} comments`}
811 </Text>
812 <Text dimColor>·</Text>
813 <Link href={issue.url} label="View on GitHub" />
814 </Box>
815 </Box>
816 ) : (
817 busy('Loading…')
818 )}
819 </Box>
820 )}
821 </Box>
822 )
823 })
824 // The engine refuses a whole tree past its size bound, so cards stop short of it.
825 const fitted = fitCards(top, cards)
826
827 return (
828 <Box flexDirection="column">
829 {top}
830 {fitted.kept}
831 {fitted.cut > 0 && (
832 <Box marginTop={1}>
833 <Text dimColor wrap="wrap">
834 {`${fitted.cut} more didn't fit in the pane. Narrow the list with a search or a label.`}
835 </Text>
836 </Box>
837 )}
838 </Box>
839 )
840 })
841}
842hooks/lib.ts 590 lines1import type { Issue, IssueDetail, IssueFilter, IssueLabel, LinkedPr, RepoChoice } from '../types'
2
3export const LIMIT = 40
4export const REPO_LIMIT = 30
5
6export const FILTERS: readonly { id: IssueFilter; label: string; noun: string }[] = [
7 { id: 'open', label: 'Open', noun: 'open' },
8 { id: 'assigned', label: 'Assigned', noun: 'assigned to you' },
9 { id: 'created', label: 'Created', noun: 'created by you' },
10 { id: 'closed', label: 'Closed', noun: 'closed' },
11]
12
13const REMOTE = /github\.com[:/]+([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/
14const NAME = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/
15
16/** `owner/name` from a git remote URL, or null when it is not GitHub's. */
17export function parseRemote(remote: string): string | null {
18 const match = REMOTE.exec(remote.trim())
19
20 return match ? `${match[1]}/${match[2]}` : null
21}
22
23/**
24 * The picker Button key for `name`: letters, digits and `-` as they are, any
25 * other character as `_` and its hex code. The desktop rewrites a key's `/`,
26 * and the engine answers a press whose key differs from the drawn one with
27 * nothing, so a key holding `/` could never be pressed there.
28 */
29export function pickKey(name: string): string {
30 return `pick:${name.replace(/[^A-Za-z0-9-]/g, char => `_${char.charCodeAt(0).toString(16)}`)}`
31}
32
33/** Whether `text` spells a repository as `owner/name`. */
34export function isRepoName(text: string): boolean {
35 return NAME.test(text)
36}
37
38const ISSUE_FIELDS =
39 'number title url state updatedAt author { login } ' +
40 'assignees(first: 5) { nodes { login } } ' +
41 'labels(first: 10) { nodes { name color } } ' +
42 'comments { totalCount } ' +
43 'closedByPullRequestsReferences(first: 5, includeClosedPrs: true) { nodes { number url state isDraft } }'
44
45const MINE_FIELD =
46 'mine: search(query: $mine, type: ISSUE, first: 100) { issueCount nodes { ... on Issue { number title url } } }'
47
48const ISSUES_QUERY =
49 'query($q: String!, $mine: String!, $owner: String!, $name: String!) { ' +
50 `list: search(query: $q, type: ISSUE, first: ${LIMIT}) { issueCount nodes { ... on Issue { ${ISSUE_FIELDS} } } } ` +
51 `${MINE_FIELD} ` +
52 'repository(owner: $owner, name: $name) { labels(first: 100, orderBy: {field: NAME, direction: ASC}) { nodes { name color } } } }'
53
54const MINE_QUERY = `query($mine: String!) { ${MINE_FIELD} }`
55
56/** The search that lists `repo`'s issues under `filter`, a label and typed text. */
57export function searchQuery(repo: string, filter: IssueFilter, text = '', label = ''): string {
58 const state = {
59 open: 'is:open',
60 assigned: 'is:open assignee:@me',
61 created: 'is:open author:@me',
62 closed: 'is:closed',
63 }[filter]
64 const parts = [`repo:${repo}`, 'is:issue', state]
65 if (label !== '') parts.push(`label:"${label.replace(/"/g, '')}"`)
66 if (text.trim() !== '') parts.push(text.trim())
67 parts.push('sort:updated-desc')
68
69 return parts.join(' ')
70}
71
72/** The search for `repo`'s open issues assigned to the person. */
73export function mineQuery(repo: string): string {
74 return `repo:${repo} is:issue is:open assignee:@me`
75}
76
77/** The `gh` arguments for one request: the list, the person's assigned issues, the labels. */
78export function issuesArgs(repo: string, filter: IssueFilter, text = '', label = ''): string[] {
79 const [owner = '', name = ''] = repo.split('/')
80
81 return [
82 'api', 'graphql',
83 '-f', `query=${ISSUES_QUERY}`,
84 '-f', `q=${searchQuery(repo, filter, text, label)}`,
85 '-f', `mine=${mineQuery(repo)}`,
86 '-f', `owner=${owner}`,
87 '-f', `name=${name}`,
88 ]
89}
90
91/** The `gh` arguments that read only the person's assigned issues, for the background check. */
92export function mineArgs(repo: string): string[] {
93 return ['api', 'graphql', '-f', `query=${MINE_QUERY}`, '-f', `mine=${mineQuery(repo)}`]
94}
95
96/**
97 * The `gh` arguments that list the repositories the person can reach, most
98 * recently pushed first: their own, ones they collaborate on, and their orgs'.
99 */
100export function reposArgs(): string[] {
101 const affiliations = '[OWNER, COLLABORATOR, ORGANIZATION_MEMBER]'
102 const query =
103 `query { viewer { repositories(first: ${REPO_LIMIT}, ` +
104 'orderBy: {field: PUSHED_AT, direction: DESC}, ' +
105 `affiliations: ${affiliations}, ownerAffiliations: ${affiliations}) ` +
106 '{ nodes { nameWithOwner isPrivate isArchived hasIssuesEnabled pushedAt ' +
107 'issues(states: OPEN) { totalCount } } } } }'
108
109 return ['api', 'graphql', '-f', `query=${query}`]
110}
111
112type RawRepo = {
113 nameWithOwner: string
114 isPrivate?: boolean
115 isArchived?: boolean
116 hasIssuesEnabled?: boolean
117 pushedAt?: string
118 issues?: { totalCount?: number }
119}
120
121/** The repositories worth picking: not archived, with issues turned on. */
122export function parseRepos(stdout: string): RepoChoice[] {
123 const raw = JSON.parse(stdout) as { data?: { viewer?: { repositories?: { nodes?: RawRepo[] } } } }
124 const nodes = raw.data?.viewer?.repositories?.nodes ?? []
125
126 return nodes
127 .filter(node => node.isArchived !== true && node.hasIssuesEnabled !== false)
128 .map(node => ({
129 name: node.nameWithOwner,
130 isPrivate: node.isPrivate === true,
131 openIssues: node.issues?.totalCount ?? 0,
132 pushedAt: node.pushedAt ?? '',
133 }))
134}
135
136/** The dim text beside a repository in the picker: open issues, private, last push. */
137export function repoMeta(choice: RepoChoice | undefined, now: number): string {
138 if (choice === undefined) return ''
139
140 return [
141 `${choice.openIssues} open`,
142 choice.isPrivate ? 'private' : '',
143 choice.pushedAt === '' ? '' : age(choice.pushedAt, now),
144 ]
145 .filter(Boolean)
146 .join(' · ')
147}
148
149/** Whether `name` matches what was typed in the picker's filter. */
150export function matchesQuery(name: string, query: string): boolean {
151 const words = query.toLowerCase().split(/\s+/).filter(Boolean)
152
153 return words.every(word => name.toLowerCase().includes(word))
154}
155
156/**
157 * The picker's sections: one per owner, owners in the order their newest push
158 * comes, leaving out `exclude` (the session's repository, listed on its own).
159 */
160export function byOwner(
161 choices: readonly RepoChoice[],
162 exclude: string | null,
163 query: string,
164): { owner: string; repos: RepoChoice[] }[] {
165 const sections = new Map<string, RepoChoice[]>()
166 for (const choice of choices) {
167 if (choice.name === exclude || !matchesQuery(choice.name, query)) continue
168 const owner = choice.name.split('/')[0] ?? choice.name
169 sections.set(owner, [...(sections.get(owner) ?? []), choice])
170 }
171
172 return [...sections].map(([owner, repos]) => ({ owner, repos }))
173}
174
175/** The labels drawn as chips, and how many more there are. */
176export function chips(labels: readonly IssueLabel[], max = 3): { shown: IssueLabel[]; more: number } {
177 return { shown: labels.slice(0, max), more: Math.max(0, labels.length - max) }
178}
179
180type Rgb = [number, number, number]
181
182function rgbOf(color: string): Rgb | null {
183 if (!/^[0-9a-fA-F]{6}$/.test(color)) return null
184 const value = Number.parseInt(color, 16)
185
186 return [(value >> 16) & 255, (value >> 8) & 255, value & 255]
187}
188
189function hexOf([red, green, blue]: Rgb): string {
190 return `#${[red, green, blue].map(channel => Math.round(channel).toString(16).padStart(2, '0')).join('')}`
191}
192
193/** `rgb` with its HSL lightness moved into [min, max], hue and saturation kept. */
194function withLightness([red, green, blue]: Rgb, min: number, max: number): string {
195 const [r, g, b] = [red / 255, green / 255, blue / 255]
196 const high = Math.max(r, g, b)
197 const low = Math.min(r, g, b)
198 const lightness = (high + low) / 2
199 const target = Math.min(max, Math.max(min, lightness))
200 if (target === lightness) return hexOf([red, green, blue])
201 const delta = high - low
202 const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1))
203 let hue = 0
204 if (delta !== 0) {
205 if (high === r) hue = ((g - b) / delta + 6) % 6
206 else if (high === g) hue = (b - r) / delta + 2
207 else hue = (r - g) / delta + 4
208 }
209 const chroma = (1 - Math.abs(2 * target - 1)) * saturation
210 const second = chroma * (1 - Math.abs((hue % 2) - 1))
211 const base = target - chroma / 2
212 const [p, q, t] =
213 hue < 1 ? [chroma, second, 0]
214 : hue < 2 ? [second, chroma, 0]
215 : hue < 3 ? [0, chroma, second]
216 : hue < 4 ? [0, second, chroma]
217 : hue < 5 ? [second, 0, chroma]
218 : [chroma, 0, second]
219
220 return hexOf([(p + base) * 255, (q + base) * 255, (t + base) * 255])
221}
222
223/** A label's text color on its tinted pill: dark enough on a light pane, light enough on a dark one. */
224export function labelInk(color: string): { light: string; dark: string } {
225 const rgb = rgbOf(color) ?? [128, 128, 128]
226
227 return { light: withLightness(rgb, 0, 0.32), dark: withLightness(rgb, 0.75, 1) }
228}
229
230/** About how wide `text` sets at 11px in a system sans-serif, in CSS pixels. */
231export function textWidth(text: string): number {
232 let ems = 0
233 for (const char of text) {
234 if ('il.,:;|!\''.includes(char)) ems += 0.27
235 else if ('fjrt -/()[]'.includes(char)) ems += 0.36
236 else if ('mwMW'.includes(char)) ems += 0.86
237 else if (char >= 'A' && char <= 'Z') ems += 0.66
238 else ems += 0.56
239 }
240
241 return Math.ceil(ems * 11)
242}
243
244const escapeXml = (text: string) =>
245 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, ''')
246
247/**
248 * A card's labels as GitHub draws them, in one small SVG: 11px text on pills
249 * tinted with each label's color, wrapped to rows no wider than `maxWidth`
250 * pixels, the ink switching with the light or dark color scheme. One SVG per
251 * card, not per label: the engine bounds a pane's whole tree, and the shared
252 * markup is most of a pill's cost.
253 */
254export function labelPills(
255 labels: readonly IssueLabel[],
256 maxWidth: number,
257): { source: string; width: number; height: number } {
258 const pillHeight = 20
259 const gap = 6
260 const light: string[] = []
261 const dark: string[] = []
262 const shapes: string[] = []
263 let x = 0
264 let y = 0
265 let width = 0
266 labels.forEach((label, index) => {
267 const text = textWidth(label.name)
268 const pill = text + 16
269 if (x > 0 && x + pill > maxWidth) {
270 x = 0
271 y += pillHeight + 4
272 }
273 const fill = rgbOf(label.color) === null ? '#808080' : `#${label.color.toLowerCase()}`
274 const ink = labelInk(label.color)
275 light.push(`.c${index}{fill:${ink.light}}`)
276 dark.push(`.c${index}{fill:${ink.dark}}`)
277 shapes.push(
278 `<rect x='${x + 0.5}' y='${y + 0.5}' width='${pill - 1}' height='${pillHeight - 1}' rx='9.5' fill='${fill}' stroke='${fill}'/>` +
279 `<text class='c${index}' x='${x + 8}' y='${y + 14}' textLength='${text}' lengthAdjust='spacingAndGlyphs'>${escapeXml(label.name)}</text>`,
280 )
281 width = Math.max(width, x + pill)
282 x += pill + gap
283 })
284 const height = y + pillHeight
285 // Single quotes: the source crosses as JSON, where each double quote costs an escape.
286 const source =
287 `<svg xmlns='http://www.w3.org/2000/svg' width='${width}' height='${height}'>` +
288 `<style>rect{fill-opacity:.18;stroke-opacity:.55}text{font:500 11px system-ui}${light.join('')}` +
289 `@media(prefers-color-scheme:dark){${dark.join('')}}</style>${shapes.join('')}</svg>`
290
291 return { source, width, height }
292}
293
294/** The terminal's spinner, one frame per redraw. */
295export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
296
297/** How often the terminal's spinner moves, in milliseconds. */
298export const SPINNER_MS = 100
299
300export function spinnerFrame(now: number): string {
301 return SPINNER_FRAMES[Math.floor(now / SPINNER_MS) % SPINNER_FRAMES.length] ?? '⠋'
302}
303
304/** The spinner surfaces that draw SVG show: it turns by itself, so the pane never redraws for it. */
305export const SPINNER_SVG =
306 '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">' +
307 '<style>circle{fill:none;stroke-width:2}.t{stroke:#d0d7de}.a{stroke:#57606a;stroke-linecap:round;' +
308 'stroke-dasharray:9 26;transform-origin:7px 7px;animation:s .8s linear infinite}' +
309 '@media(prefers-color-scheme:dark){.t{stroke:#3d444d}.a{stroke:#9198a1}}' +
310 '@keyframes s{to{transform:rotate(360deg)}}</style>' +
311 '<circle class="t" cx="7" cy="7" r="5.5"/><circle class="a" cx="7" cy="7" r="5.5"/></svg>'
312
313/** How many options a `Select` may hold: the engine refuses the whole tree past this. */
314export const SELECT_LIMIT = 64
315
316/**
317 * The label picker's options: "Any label", the chosen label, then the labels
318 * the loaded issues use most, then the rest by name, `SELECT_LIMIT` in all.
319 */
320export function labelOptions(
321 labels: readonly IssueLabel[],
322 issues: readonly Pick<Issue, 'labels'>[],
323 current: string,
324): { key: string; value: string; label: string }[] {
325 const uses = new Map<string, number>()
326 for (const issue of issues) {
327 for (const label of issue.labels) uses.set(label.name, (uses.get(label.name) ?? 0) + 1)
328 }
329 const names = [...new Set([...labels.map(label => label.name), ...uses.keys()])]
330 const ranked = names
331 .filter(name => name !== current)
332 .sort((a, b) => (uses.get(b) ?? 0) - (uses.get(a) ?? 0) || a.localeCompare(b))
333 const picked = [...(current === '' ? [] : [current]), ...ranked].slice(0, SELECT_LIMIT - 1)
334
335 return [
336 { key: 'label:any', value: '', label: 'Any label' },
337 ...picked.map(name => ({ key: `label:${name}`, value: name, label: name })),
338 ]
339}
340
341/**
342 * A label's color for its dot: GitHub's `rrggbb`, darkened when it is so pale
343 * (a light cyan, a yellow) that it would vanish on a light pane; gray when unset.
344 */
345export function labelDot(color: string): string {
346 if (!/^[0-9a-fA-F]{6}$/.test(color)) return '#808080'
347 const value = Number.parseInt(color, 16)
348 const channels = [(value >> 16) & 255, (value >> 8) & 255, value & 255]
349 const [red = 0, green = 0, blue = 0] = channels
350 if (0.299 * red + 0.587 * green + 0.114 * blue <= 186) return `#${color.toLowerCase()}`
351
352 return `#${channels.map(channel => Math.round(channel * 0.7).toString(16).padStart(2, '0')).join('')}`
353}
354
355/** The `gh` arguments that read one issue's body and comments. */
356export function viewArgs(repo: string, number: number): string[] {
357 return ['issue', 'view', String(number), '-R', repo, '--json', 'number,body,comments']
358}
359
360type Nodes<T> = { nodes?: (T | null)[] }
361
362type RawPr = { number: number; url: string; state: string; isDraft?: boolean }
363
364type RawIssue = {
365 number?: number
366 title?: string
367 url?: string
368 state?: string
369 updatedAt?: string
370 author?: { login?: string } | null
371 assignees?: Nodes<{ login?: string }>
372 labels?: Nodes<{ name?: string; color?: string }>
373 comments?: { totalCount?: number }
374 closedByPullRequestsReferences?: Nodes<RawPr>
375}
376
377type RawSearch = { issueCount?: number; nodes?: (RawIssue | null)[] }
378
379export type Assigned = { total: number; issues: { number: number; title: string; url: string }[] }
380
381export type IssuesPage = {
382 issues: Issue[]
383 total: number
384 assigned: Assigned
385 labels: IssueLabel[]
386}
387
388function nodes<T>(list: Nodes<T> | undefined): T[] {
389 return (list?.nodes ?? []).filter((node): node is T => node !== null && node !== undefined)
390}
391
392/** The linked pull request worth showing: open, then draft, then merged, then closed. */
393export function pickPr(prs: readonly RawPr[]): LinkedPr | null {
394 const linked = prs.map(pr => ({
395 number: pr.number,
396 url: pr.url,
397 state: (pr.state === 'OPEN' && pr.isDraft === true ? 'DRAFT' : pr.state) as LinkedPr['state'],
398 }))
399 for (const state of ['OPEN', 'DRAFT', 'MERGED', 'CLOSED'] as const) {
400 const found = linked.find(pr => pr.state === state)
401 if (found !== undefined) return found
402 }
403
404 return null
405}
406
407/** A linked pull request's state, as words and a color. */
408export function prBadge(pr: LinkedPr): { text: string; color: string } {
409 switch (pr.state) {
410 case 'OPEN':
411 return { text: 'open', color: 'success' }
412 case 'DRAFT':
413 return { text: 'draft', color: '#808080' }
414 case 'MERGED':
415 return { text: 'merged', color: '#8250df' }
416 case 'CLOSED':
417 return { text: 'closed', color: '#cf222e' }
418 }
419}
420
421function toIssue(raw: RawIssue): Issue | null {
422 if (raw.number === undefined || raw.title === undefined || raw.url === undefined) return null
423
424 return {
425 number: raw.number,
426 title: raw.title,
427 url: raw.url,
428 state: raw.state ?? 'OPEN',
429 labels: nodes(raw.labels)
430 .filter(label => label.name)
431 .map(label => ({ name: label.name ?? '', color: label.color ?? '' })),
432 assignees: nodes(raw.assignees).map(person => person.login ?? '').filter(Boolean),
433 author: raw.author?.login ?? 'ghost',
434 updatedAt: raw.updatedAt ?? '',
435 comments: raw.comments?.totalCount ?? 0,
436 pr: pickPr(nodes(raw.closedByPullRequestsReferences)),
437 }
438}
439
440function toAssigned(raw: RawSearch | undefined): Assigned {
441 return {
442 total: raw?.issueCount ?? 0,
443 issues: (raw?.nodes ?? []).flatMap(node =>
444 node?.number !== undefined && node.title !== undefined && node.url !== undefined
445 ? [{ number: node.number, title: node.title, url: node.url }]
446 : [],
447 ),
448 }
449}
450
451/** What the issues request answered: the page, how many match, the person's assigned issues, the labels. */
452export function parseIssuesPage(stdout: string): IssuesPage {
453 const raw = JSON.parse(stdout) as {
454 data?: { list?: RawSearch; mine?: RawSearch; repository?: { labels?: Nodes<{ name?: string; color?: string }> } | null }
455 }
456 const list = raw.data?.list
457
458 return {
459 issues: (list?.nodes ?? []).flatMap(node => {
460 const issue = node === null ? null : toIssue(node)
461
462 return issue === null ? [] : [issue]
463 }),
464 total: list?.issueCount ?? 0,
465 assigned: toAssigned(raw.data?.mine),
466 labels: nodes(raw.data?.repository?.labels ?? undefined)
467 .filter(label => label.name)
468 .map(label => ({ name: label.name ?? '', color: label.color ?? '' })),
469 }
470}
471
472/** What the background check answered: the person's assigned issues. */
473export function parseAssigned(stdout: string): Assigned {
474 const raw = JSON.parse(stdout) as { data?: { mine?: RawSearch } }
475
476 return toAssigned(raw.data?.mine)
477}
478
479/** Issues assigned since `seen` was recorded; none on the first look at a repository. */
480export function newlyAssigned(assigned: Assigned, seen: unknown): Assigned['issues'] {
481 if (!Array.isArray(seen)) return []
482
483 return assigned.issues.filter(issue => !seen.includes(issue.number))
484}
485
486/** The toast for newly assigned issues. */
487export function assignedToast(repo: string, fresh: Assigned['issues']): string | undefined {
488 const [first] = fresh
489 if (first === undefined) return undefined
490 if (fresh.length === 1) return `Assigned to you: #${first.number} ${first.title}`
491
492 return `${fresh.length} issues newly assigned to you in ${repo}`
493}
494
495export function parseDetail(stdout: string): IssueDetail {
496 const raw = JSON.parse(stdout) as { number: number; body?: string; comments?: unknown[] }
497
498 return { number: raw.number, body: raw.body ?? '', comments: raw.comments?.length ?? 0 }
499}
500
501/** "just now", "5m", "3h", "2d", "4w", "8mo" or "2y" between `iso` and `now`. */
502export function age(iso: string, now: number): string {
503 const then = Date.parse(iso)
504 if (Number.isNaN(then)) return ''
505
506 const minutes = Math.max(0, Math.floor((now - then) / 60_000))
507 if (minutes < 1) return 'just now'
508 if (minutes < 60) return `${minutes}m`
509
510 const hours = Math.floor(minutes / 60)
511 if (hours < 24) return `${hours}h`
512
513 const days = Math.floor(hours / 24)
514 if (days < 14) return `${days}d`
515 if (days < 60) return `${Math.floor(days / 7)}w`
516 if (days < 365) return `${Math.floor(days / 30)}mo`
517
518 return `${Math.floor(days / 365)}y`
519}
520
521/** The dim text after an issue's number: assignees, when it last changed, comments. */
522export function meta(issue: Issue, now: number): string {
523 const parts = [
524 issue.assignees.map(login => `@${login}`).join(' '),
525 age(issue.updatedAt, now),
526 issue.comments === 0 ? '' : issue.comments === 1 ? '1 comment' : `${issue.comments} comments`,
527 ]
528
529 return parts.filter(Boolean).join(' · ')
530}
531
532/**
533 * What the pane may spend of the engine's bound on a drawn tree (100,000
534 * characters serialized, past which it refuses the whole tree and draws nothing).
535 */
536export const TREE_BUDGET = 90_000
537
538function sizeOf(node: unknown): number {
539 try {
540 return JSON.stringify(node)?.length ?? 0
541 } catch {
542 return Number.POSITIVE_INFINITY
543 }
544}
545
546/** The cards that fit beside `top` within `TREE_BUDGET`, in order, and how many were cut. */
547export function fitCards<T>(top: unknown, cards: readonly T[], budget = TREE_BUDGET): { kept: T[]; cut: number } {
548 let used = sizeOf(top)
549 const kept: T[] = []
550 for (const card of cards) {
551 used += sizeOf(card) + 1
552 if (used > budget) break
553 kept.push(card)
554 }
555
556 return { kept, cut: cards.length - kept.length }
557}
558
559/** The issue body as the pane shows it: trimmed, and cut when long. */
560export function excerpt(body: string, max = 1500): string {
561 const text = body.replace(/<!--[\s\S]*?-->/g, '').trim()
562 if (text === '') return '_No description._'
563 if (text.length <= max) return text
564
565 return `${text.slice(0, max).trimEnd()}…`
566}
567
568/**
569 * Whether a session in `session` may take on `target`'s issues: the same
570 * repository, or a fork of it (same name, another owner).
571 */
572export function isSameRepo(session: string | null, target: string): boolean {
573 if (session === null) return false
574 if (session.toLowerCase() === target.toLowerCase()) return true
575
576 return (session.split('/')[1] ?? '').toLowerCase() === (target.split('/')[1] ?? '').toLowerCase()
577}
578
579/** The prompt "Work on it" submits. */
580export function workPrompt(repo: string, issue: Pick<Issue, 'number' | 'title' | 'url'>): string {
581 return [
582 `Work on GitHub issue #${issue.number} in ${repo}: "${issue.title}"`,
583 issue.url,
584 '',
585 `Read the issue and its comments first (\`gh issue view ${issue.number} -R ${repo} --comments\`), ` +
586 'then outline your plan and implement it. ' +
587 `Reference #${issue.number} in the commit message and in the pull request.`,
588 ].join('\n')
589}
590types/index.d.ts 89 lines1export type IssueFilter = 'open' | 'assigned' | 'created' | 'closed'
2
3export type IssueLabel = { name: string; color: string }
4
5export type LinkedPr = {
6 number: number
7 url: string
8 state: 'OPEN' | 'DRAFT' | 'MERGED' | 'CLOSED'
9}
10
11export type Issue = {
12 number: number
13 title: string
14 url: string
15 state: string
16 labels: IssueLabel[]
17 assignees: string[]
18 author: string
19 updatedAt: string
20 comments: number
21 pr: LinkedPr | null
22}
23
24export type IssueDetail = {
25 number: number
26 body: string
27 comments: number
28}
29
30export type IssueLoad =
31 | { kind: 'empty' }
32 | { kind: 'loading' }
33 | { kind: 'ready' }
34 | { kind: 'error'; message: string }
35
36export type IssueView = 'issues' | 'repos'
37
38export type RepoChoice = {
39 name: string
40 isPrivate: boolean
41 openIssues: number
42 pushedAt: string
43}
44
45/** What narrows the list: the tab, the search typed, the label picked. */
46export type IssueScope = { filter: IssueFilter; search: string; label: string }
47
48/**
49 * Everything the issue list draws, as one value: a change of tab and its
50 * results are one write each, so one redraw each.
51 */
52export type IssuesPane = {
53 repo: string | null
54 scope: IssueScope
55 load: IssueLoad
56 issues: Issue[]
57 total: number
58 labels: IssueLabel[]
59 assigned: number
60}
61
62/** Everything the repository picker draws, as one value. */
63export type RepoPicker = {
64 load: IssueLoad
65 repos: RepoChoice[]
66 here: string | null
67}
68
69/** The issue handed to Claude: queued until Claude's turn on it starts, then working. */
70export type ActiveIssue = { number: number; state: 'queued' | 'working' }
71
72/** The issue whose details are open, and its details once loaded. */
73export type OpenIssue = { number: number; detail: IssueDetail | null }
74
75declare module 'claude-code' {
76 interface PluginState {
77 'github-issues': {
78 view: IssueView
79 query: string
80 picker: RepoPicker
81 pinned: string | null
82 page: IssuesPane
83 searchDraft: string
84 open: OpenIssue | null
85 active: ActiveIssue | null
86 }
87 }
88}
89