SLOPSHOPPER

github-issues

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

newpanecommandtoaststatusprocess
★ 1v0.1.0MITupdated 2026-10-06MarcoCarnevali/claude-code-mods/github-issues
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · github-issues
│ ┃ Issues ✕ › fix the failing auth test and add an audit log call │ ┃ acme / app Switch repo Refresh │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Open ] [ Assigned ] [ Created ] [ Closed ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Search issues ⏎ Search ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ gh answered with something that is not JSON. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /issues │ ⎿ github-issues: Showing acme/app issues. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Issues
acme / app Switch repo Refresh [ Open ] [ Assigned ] [ Created ] [ Closed ] Search issues ⏎ Search gh answered with something that is not JSON.
README

github-issues

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>

Watch the demo on X

  • Tabs: Open, Assigned (with your count), Created, Closed
  • Search and labels: a search box (Enter runs a GitHub search) and a label picker. Clear resets both.
  • Cards: title, #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 colors
  • Details: shows the issue's description inside its card
  • Work on it: sends Claude a prompt to read the issue and its comments, plan, and implement it. Its card gets an accent border.
  • Assignment alerts: a toast when an issue is newly assigned to you, and your count on the Assigned tab

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

Usage

Command
/issuesStarted 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/nameShow 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.

Assignment alerts

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.

Requirements

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.

Install

/plugin marketplace add MarcoCarnevali/claude-code-mods
/plugin install github-issues@marco-mods
/reload-plugins
Source 3 files
hooks/register.tsx 842 lines
1import { 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}
842
hooks/lib.ts 590 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/'/g, '&apos;')
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}
590
types/index.d.ts 89 lines
1export 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