SLOPSHOPPER

issue-tracker

A pane beside the transcript with the GitHub issues of the project the session works in

newpaneguardcommandpromptprocess
v0.2.0no licenseupdated 2026-10-08mrtsvr-techloop/claude-issue-tracker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · issue-tracker
│ ┃ issues ✕ › fix the failing auth test and add an audit log call │ ┃ Select a project │ ┃ No git project found. /issues attach <path ⏺ Read(src/auth.ts) │ ┃ or owner/name> ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /issues │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · issues
Select a project No git project found. /issues attach <path or owner/name>
README

issue-tracker

A Claude Code mod that shows the GitHub issues of the project the session works in, in a pane beside the transcript.

Each issue is a card: its number, its title, its status with the color the project gives it, and a bar for how far it is. The pane docks at the right in a fullscreen terminal at least 110 columns wide.

Install

At the prompt of a Claude Code terminal session:

/plugin install issue-tracker --marketplace mrtsvr-techloop/claude-issue-tracker

Answer y to add the marketplace, then pick a scope.

To run it from a clone instead: claude --plugin-dir <path to the clone>.

GitHub access

The mod asks for no login. It uses what the machine already holds, in this order:

  1. the gh CLI, with whatever account it is logged in as;
  2. a token in GH_TOKEN or GITHUB_TOKEN, else the one git's credential helper stores.

With neither, nothing is fetched and no pane opens. SSH keys alone reach the code, not the issues API.

The project

The mod attaches to the repository the session starts in. Started outside a repository, it offers the working copies in the folders beside it and attaches to the first one the session touches.

The button at the top left names the project shown; a click leads to the project selection.

The list

  • Search: narrows the loaded issues as you type; Enter asks GitHub.
  • Filters: Open, Closed, All, Assigned (to the account in use), Pinned, and Status, which steps through the statuses in the list. A filter is blue while on.
  • Lazy loading: 20 issues at a time; the next page loads when the scroll nears the end.
  • Refresh: every minute while the pane is open, and at the end of each turn.
  • Pin: the dot at the left of a card. A pinned issue leads the list, has a dark blue header and passes every filter but the search text. Pins are kept per repository across sessions.
  • Working: the issues the session names in a prompt, a command or its branch (feat/242-x) come right after the pinned ones, marked working.
  • Status: click it to change it. An issue on a project board takes the board's statuses; any other is opened, closed or closed as not planned. The change is written to GitHub.

Progress

The bar of a card comes from the first of these the issue has:

  1. closed: 100%;
  2. sub-issues: completed over total;
  3. task-list items in the body (- [ ], - [x]): checked over total, shown as criteria.

An issue with none of them has no bar.

The issue

A click on a title opens the issue in the pane: its status, assignees, labels in their GitHub colors, its progress, a link that opens it on GitHub, and its text. Back returns to the list; the project button leaves at once.

Commands

  • /issues opens the pane and refreshes it.
  • /issues attach <path or owner/name> shows another repository.
  • /issues close closes the pane.

Mod Signals

The mod follows Mod Signals, a standard for mods to hear each other without knowing each other. It accepts open, close and toggle, and emits opened, closed and notify. Another mod, such as a dock of buttons, can open and close it with nothing added here.

The standard travels in hooks/mod-signals, a git subtree of its repository: the files are committed here and are not edited here. A newer version is pulled with

git subtree pull --prefix hooks/mod-signals git@github.com:mrtsvr-techloop/mod-signals.git main --squash

Develop

claude plugin validate .
claude plugin test .

Architecture: ports and adapters. hooks/model.ts is the pure issue model. hooks/ports.ts names what the adapters need of the machine (processes, HTTP, environment, files); hooks/github.ts and hooks/project.ts are the adapters over those ports, for GitHub's GraphQL API and for git. hooks/register.tsx binds the ports to Claude Code, holds the hooks on its events and draws the pane. types/index.d.ts is the state contract.

TypeScript, as Claude Code mods are.

Source 7 files
hooks/register.tsx 781 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Feed, Filter, Issue, Repo } from '../types'
5import { connect, fetchPage, setStatus } from './github'
6import type { Choice } from './model'
7import {
8  branchRef,
9  choicesOf,
10  chosen,
11  footerOf,
12  headerOf,
13  inkOn,
14  isSameRepo,
15  merged,
16  parseSlug,
17  refsIn,
18  slugOf,
19  statusesOf,
20  visible,
21} from './model'
22import type { Ports } from './ports'
23import { branchOf, dirOf, locate, locateChildren } from './project'
24import { ROLL_CALL_MS, readSignal, signalOf } from './mod-signals/kit/signals'
25import type { Announce, SignalKind } from './mod-signals/kit/signals'
26
27const PANE = 'issues'
28const COMMAND = 'issues'
29/** Issues asked of GitHub at a time: the next page comes when the list's end is near. */
30const PAGE = 20
31/** The most a refresh asks again: the newest changes of the loaded list. */
32const MAX_REFRESH = 50
33const REFRESH_MS = 60_000
34const PANE_COLUMNS = 46
35/** Rows from the list's end at which a scroll loads the next page. */
36const NEAR_END_ROWS = 6
37/** The pin, one cell, set against the header it belongs to. */
38const PIN_COLUMNS = 1
39const PINNED = '●'
40const LOOSE = '○'
41/** Where a card's lines under the header start: under its number. */
42const INDENT = 2
43const HEADER = { backgroundColor: '#3a3a3a', color: '#e4e4e4' }
44/** A pinned card's header: dark, as a Button draws its label light and takes no color. */
45const PINNED_HEADER = { backgroundColor: '#0d2f6b', color: '#e4e4e4' }
46/** The issue number's own block, white on every card. */
47const NUMBER = { backgroundColor: '#ffffff', color: '#000000' }
48const BAR_COLUMNS = 10
49/** The block behind the search field. */
50const SEARCH_FIELD = '#30363d'
51/** The lower and the upper half of a cell: the field's edge, with half a row left empty beyond it. */
52const HALF_BELOW = '▄'
53const HALF_ABOVE = '▀'
54/**
55 * A filter's block: blue while it narrows the list, grey while it does not.
56 * Both dark enough for the label, whose color a Button does not let a mod set.
57 */
58const FILTER_ON = '#1f6feb'
59const FILTER_OFF = '#3a3a3a'
60/** Cells the project button leaves on its row: its brackets and the Back beside it. */
61const PROJECT_ROOM = 10
62
63const EMPTY_FEED: Feed = {
64  issues: [],
65  cursor: null,
66  hasMore: false,
67  total: 0,
68  isLoading: false,
69  error: null,
70  viewer: '',
71  query: '',
72}
73const NO_FILTER: Filter = { state: 'open', status: '', isMine: false, isPinnedOnly: false }
74const STATES: readonly { state: Filter['state']; label: string }[] = [
75  { state: 'open', label: 'Open' },
76  { state: 'closed', label: 'Closed' },
77  { state: 'all', label: 'All' },
78]
79
80const repo = atom({ plugin: 'issue-tracker', key: 'repo' } as const, null)
81const candidates = atom({ plugin: 'issue-tracker', key: 'candidates' } as const, [])
82const access = atom({ plugin: 'issue-tracker', key: 'access' } as const, 'unknown')
83const feed = atom({ plugin: 'issue-tracker', key: 'feed' } as const, EMPTY_FEED)
84const search = atom({ plugin: 'issue-tracker', key: 'search' } as const, '')
85const filter = atom({ plugin: 'issue-tracker', key: 'filter' } as const, NO_FILTER)
86const pinned = atom({ plugin: 'issue-tracker', key: 'pinned' } as const, [])
87const working = atom({ plugin: 'issue-tracker', key: 'working' } as const, [])
88const isChoosing = atom({ plugin: 'issue-tracker', key: 'isChoosing' } as const, false)
89const editing = atom({ plugin: 'issue-tracker', key: 'editing' } as const, null)
90const viewing = atom({ plugin: 'issue-tracker', key: 'viewing' } as const, null)
91
92/** The machine as the adapters reach it: processes, HTTP, environment, files. */
93const portsOf = ($: EngineInterface): Ports => ({
94  run: (argv, init) => $.process.run(argv, init),
95  fetch: (url, init) => $.http.fetch(url, init),
96  envToken: async () => (await $.env.get('GH_TOKEN')) ?? (await $.env.get('GITHUB_TOKEN')),
97  list: dir => $.fs.list(dir),
98  exists: path => $.fs.exists(path),
99})
100
101const pinsKey = (one: Repo): string => `pins:${one.host}/${slugOf(one)}`.toLowerCase()
102
103const asNumbers = (value: unknown): number[] =>
104  Array.isArray(value) ? value.filter((one): one is number => typeof one === 'number') : []
105
106const isPaneOpen = async ($: EngineInterface): Promise<boolean> =>
107  (await $.ui.panes()).some(pane => pane.id === PANE)
108
109/** The mod's name as the engine gives it: the `to` of the commands it obeys. */
110const MOD = 'issue-tracker'
111const SELF: Announce = { title: 'Issues', accepts: ['open', 'close', 'toggle'], emits: ['opened', 'closed', 'notify'] }
112
113const signal = atom({ plugin: 'issue-tracker', key: 'signal' } as const, null)
114const sent = { count: 0, answeredAt: 0 }
115
116/** One signal to whoever listens; a mod above that refuses the write stops nothing here. */
117const emit = async (
118  $: EngineInterface,
119  kind: SignalKind,
120  name: string,
121  data?: Record<string, unknown>,
122  tags?: string[],
123): Promise<void> => {
124  sent.count += 1
125
126  try {
127    await update($, signal, () => signalOf(kind, name, sent.count, Date.now(), { ...(data === undefined ? {} : { data }), ...(tags === undefined ? {} : { tags }) }))
128  } catch {
129    // The mod goes on without the signal.
130  }
131}
132
133/** Opens the pane, and says so where it was closed. */
134const openPane = async ($: EngineInterface): Promise<void> => {
135  const wasOpen = await isPaneOpen($)
136  await $.ui.open({ id: PANE, title: 'Issues', columns: PANE_COLUMNS })
137
138  if (!wasOpen) {
139    await emit($, 'event', 'opened')
140  }
141}
142
143/** Closes the pane, and says so where it was open; answers whether it is open still. */
144const closePane = async ($: EngineInterface): Promise<boolean> => {
145  const wasOpen = await isPaneOpen($)
146  await $.ui.close({ id: PANE })
147  const isOpen = await isPaneOpen($)
148
149  if (wasOpen && !isOpen) {
150    await emit($, 'event', 'closed')
151  }
152
153  return isOpen
154}
155
156/** One load at a time: a second ask while one runs is dropped, the next tick asks again. */
157let isBusy = false
158
159/**
160 * Loads issues for the attached repository: `reset` the first page anew,
161 * `more` the page after the last, `refresh` the newest changes over the list.
162 */
163const load = async ($: EngineInterface, mode: 'reset' | 'more' | 'refresh'): Promise<void> => {
164  const attached = await read($, repo)
165  const held = await read($, feed)
166
167  if (attached === null || isBusy || (await read($, access)) !== 'granted' || (mode === 'more' && !held.hasMore)) {
168    return
169  }
170
171  isBusy = true
172
173  try {
174    const text = mode === 'reset' ? await read($, search) : held.query
175    const marks = [...new Set([...(await read($, pinned)), ...(await read($, working))])]
176    const known = new Set(mode === 'reset' ? [] : held.issues.map(issue => issue.number))
177    await update($, feed, one => ({ ...one, isLoading: true }))
178    const page = await fetchPage(portsOf($), attached, {
179      first: mode === 'refresh' ? Math.min(MAX_REFRESH, Math.max(PAGE, held.issues.length)) : PAGE,
180      after: mode === 'more' ? held.cursor : null,
181      state: (await read($, filter)).state,
182      search: text,
183      numbers: marks.filter(number => !known.has(number)),
184    })
185
186    if (!isSameRepo(attached, await read($, repo))) {
187      return
188    }
189
190    if ('error' in page) {
191      await update($, feed, one => ({ ...one, isLoading: false, error: page.error }))
192
193      return
194    }
195
196    const fresh = [...page.issues, ...page.named]
197    await update($, feed, one => ({
198      issues: mode === 'reset' ? merged([], fresh) : merged(one.issues, fresh),
199      // A refresh re-reads the head of the list: where the next page starts stays.
200      cursor: mode === 'refresh' && one.issues.length > 0 ? one.cursor : page.cursor,
201      hasMore: mode === 'refresh' && one.issues.length > 0 ? one.hasMore : page.hasMore,
202      total: page.total,
203      isLoading: false,
204      error: null,
205      viewer: page.viewer,
206      query: text,
207    }))
208    // A number the session named that is no issue of this repository is let go.
209    const real = new Set((await read($, feed)).issues.map(issue => issue.number))
210    await update($, working, list => list.filter(number => real.has(number)))
211  } finally {
212    isBusy = false
213  }
214}
215
216const remember = async ($: EngineInterface, found: Repo): Promise<void> => {
217  await update($, candidates, list => (list.some(one => isSameRepo(one, found)) ? list : [...list, found]))
218}
219
220/**
221 * Makes `found` the repository shown, when this machine reaches its issues;
222 * without that access nothing is fetched and no pane opens.
223 */
224const attach = async ($: EngineInterface, found: Repo, isAsked: boolean): Promise<boolean> => {
225  await remember($, found)
226  const isReached = (await connect(portsOf($), found)) !== undefined
227
228  if (!isReached) {
229    if ((await read($, repo)) === null) {
230      await update($, access, () => 'none')
231    }
232
233    return false
234  }
235
236  const branch = branchRef(await branchOf(portsOf($), found))
237  await update($, repo, () => found)
238  await update($, access, () => 'granted')
239  await update($, isChoosing, () => false)
240  await update($, editing, () => null)
241  await update($, viewing, () => null)
242  await update($, feed, () => EMPTY_FEED)
243  await update($, search, () => '')
244  const stored = asNumbers(await $.store.get(pinsKey(found)))
245  await update($, pinned, () => stored)
246  await update($, working, () => (branch === undefined ? [] : [branch]))
247  await load($, 'reset')
248
249  // Unasked, the surface seats the pane only where it has the room for it.
250  if (isAsked || !(await isPaneOpen($))) {
251    await openPane($)
252  }
253
254  return true
255}
256
257/** Notes the issues a text names and the repository a path sits in. */
258const notice = async ($: EngineInterface, text: string, dir: string | undefined): Promise<void> => {
259  if (dir !== undefined) {
260    const found = await locate(portsOf($), dir)
261
262    if (found) {
263      await ((await read($, repo)) === null ? attach($, found, false) : remember($, found))
264    }
265  }
266
267  const attached = await read($, repo)
268
269  if (attached === null) {
270    return
271  }
272
273  const held = await read($, working)
274  const named = refsIn(text, attached).filter(number => !held.includes(number))
275
276  if (named.length > 0) {
277    await update($, working, list => [...new Set([...list, ...named])])
278    await load($, 'refresh')
279  }
280}
281
282const togglePin = async ($: EngineInterface, number: number): Promise<void> => {
283  const attached = await read($, repo)
284  await update($, pinned, list =>
285    list.includes(number) ? list.filter(one => one !== number) : [...list, number],
286  )
287
288  if (attached !== null) {
289    await $.store.set(pinsKey(attached), await read($, pinned))
290  }
291}
292
293/** Shows one issue in full in place of the list. */
294const openIssue = async ($: EngineInterface, number: number): Promise<void> => {
295  await update($, editing, () => null)
296  await update($, viewing, () => number)
297}
298
299const setFilter = async ($: EngineInterface, change: Partial<Filter>): Promise<void> => {
300  const before = await read($, filter)
301  await update($, filter, one => ({ ...one, ...change }))
302
303  // The state is asked of the server: the list starts over. The rest filters what is loaded.
304  if (change.state !== undefined && change.state !== before.state) {
305    await load($, 'reset')
306  }
307}
308
309/** The person's pick of a status: shown at once, written to GitHub, then read back. */
310const changeStatus = async ($: EngineInterface, issue: Issue, choice: Choice): Promise<void> => {
311  const attached = await read($, repo)
312  await update($, editing, () => null)
313
314  if (attached === null) {
315    return
316  }
317
318  await update($, feed, one => ({
319    ...one,
320    issues: one.issues.map(held => (held.number === issue.number ? chosen(held, choice) : held)),
321  }))
322  const failure = await setStatus(portsOf($), attached, issue, choice)
323  await load($, 'refresh')
324
325  if (failure !== null) {
326    await update($, feed, one => ({ ...one, error: failure }))
327  }
328}
329
330const hasProject = async ($: EngineInterface): Promise<boolean> =>
331  (await read($, repo)) !== null || (await read($, candidates)).length > 0
332
333/** Opens the pane on fresh issues; false, and nothing opened, with no project to show. */
334const show = async ($: EngineInterface): Promise<boolean> => {
335  if (!(await hasProject($))) {
336    return false
337  }
338
339  await load($, 'refresh')
340  await openPane($)
341
342  return true
343}
344
345/** A command another mod sent: what the person could do with `/issues`, and no more. */
346const obey = async ($: EngineInterface, name: string): Promise<void> => {
347  if (name === 'close') {
348    await $.issues.close()
349
350    return
351  }
352
353  if (name !== 'open' && name !== 'toggle') {
354    return
355  }
356
357  const wasOpen = await $.issues.isOpen()
358  const isOpen = name === 'open' ? await $.issues.open() : await $.issues.toggle()
359
360  if (!wasOpen && !isOpen) {
361    await emit($, 'event', 'notify', { text: 'No project yet. Use /issues attach <path or owner/name>.' }, ['warning'])
362  }
363}
364
365export const register: Register = on => {
366  // Mod Signals: every signal, whoever writes it. The write goes on first and
367  // untouched; the mod answers a roll-call and obeys the commands sent to it.
368  on('state.set', { key: 'signal' }, async ($, e, next) => {
369    const done = await next(e)
370    const heard = done.value?.isSet === true ? readSignal(e.value) : null
371
372    if (heard?.kind === 'event' && heard.name === 'roll-call' && Date.now() - sent.answeredAt >= ROLL_CALL_MS) {
373      sent.answeredAt = Date.now()
374      await emit($, 'announce', 'announce', SELF)
375    }
376
377    if (heard?.kind === 'command' && heard.to === MOD) {
378      await obey($, heard.name)
379    }
380
381    return done
382  }).catch((_, e, next) => next(e))
383
384  on('session.start', async ($, e, next) => {
385    await $.command.register({
386      name: COMMAND,
387      description: 'Show the project issues pane; "attach <path or owner/name>", "toggle" or "close"',
388      argumentHint: '[attach <path|owner/name> | toggle | close]',
389    })
390    await emit($, 'announce', 'announce', SELF)
391
392    try {
393      const cwd = await $.session.cwd()
394      const here = await locate(portsOf($), cwd)
395
396      if (here) {
397        await attach($, here, false)
398      } else {
399        // Launched outside a project: the working copies beside it are offered,
400        // and the first one the session touches is attached.
401        for (const child of await locateChildren(portsOf($), cwd)) {
402          await remember($, child)
403        }
404      }
405    } catch {
406      // No project yet: the tool calls of the session may name one.
407    }
408
409    $.clock.every(REFRESH_MS, () => {
410      void isPaneOpen($).then(isOpen => (isOpen ? load($, 'refresh') : undefined))
411    })
412
413    return next(e)
414  })
415
416  on('command.run', { command: COMMAND }, async ($, e) => {
417    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
418    const target = rest.join(' ')
419
420    // The command is one caller of the mod's API among others.
421    if (verb === 'close') {
422      await $.issues.close()
423
424      return { text: 'Issue pane closed.' }
425    }
426
427    if (verb === 'toggle') {
428      return { text: (await $.issues.toggle()) ? 'Issue pane opened.' : 'Issue pane closed.' }
429    }
430
431    if (verb === 'attach' && target.length > 0) {
432      const named = parseSlug(target)
433      const found = named ? { ...named, root: '' } : await locate(portsOf($), target)
434
435      if (!found) {
436        return { text: `No GitHub repository found at ${target}.` }
437      }
438
439      return (await attach($, found, true))
440        ? { text: `Issues of ${slugOf(found)} attached.` }
441        : { text: `This machine has no GitHub access to the issues of ${slugOf(found)}.` }
442    }
443
444    return { text: (await $.issues.open()) ? 'Issue pane opened.' : 'No project yet. Use "/issues attach <path or owner/name>".' }
445  })
446
447  // The mod's API: `$.issues` for any other mod, the pane driven with no command typed.
448  on('engine.create', async ($, e, next) => ({
449    ...(await next(e)),
450    // Each is answered by its hook below; alone, a method says the pane is closed.
451    issues: { open: async () => false, close: async () => false, toggle: async () => false, isOpen: async () => false },
452  }))
453
454  on('issues.isOpen', async $ => ({ value: await isPaneOpen($) }))
455
456  on('issues.open', async $ => ({ value: await show($) }))
457
458  on('issues.close', async $ => ({ value: await closePane($) }))
459
460  on('issues.toggle', async $ => {
461    return { value: (await isPaneOpen($)) ? await closePane($) : await show($) }
462  })
463
464  on('prompt.submit', async ($, e, next) => {
465    try {
466      await notice($, e.text, undefined)
467    } catch {
468      // The prompt goes on whatever the pane could not learn from it.
469    }
470
471    return next(e)
472  })
473
474  on('tool.call', async ($, e, next) => {
475    const ran = await next(e)
476
477    try {
478      const input: Record<string, unknown> = { ...e }
479      await notice($, typeof input.command === 'string' ? input.command : '', dirOf(input))
480    } catch {
481      // The call's answer stands whatever the pane could not learn from it.
482    }
483
484    return ran
485  })
486
487  on('turn.complete', async ($, e, next) => {
488    try {
489      if (await isPaneOpen($)) {
490        await load($, 'refresh')
491      }
492    } catch {
493      // The turn ends whatever the refresh met.
494    }
495
496    return next(e)
497  })
498
499  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
500    const moved = await next(e)
501
502    // An issue read in full is no list: its end asks for nothing.
503    if ((await read($, viewing)) === null && e.offset + e.bodyRows >= e.contentRows - NEAR_END_ROWS) {
504      await load($, 'more')
505    }
506
507    return moved
508  })
509
510  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
511    const elements = $.ui.resolve(e)
512    const { Box, Button, Link, Markdown, Text } = elements
513    // A surface with no text field lists and filters without the search.
514    const Input = 'Input' in elements ? elements.Input : undefined
515    const attached = await read($, repo)
516    const width = e.props.bodyColumns
517
518    if (attached === null || (await read($, isChoosing))) {
519      const found = await read($, candidates)
520
521      return (
522        <Box flexDirection="column">
523          <Text bold>Select a project</Text>
524          {(await read($, access)) === 'none' && (
525            <Text dimColor>No GitHub access on this machine for its issues.</Text>
526          )}
527          {found.length === 0 && <Text dimColor>{'No git project found. /issues attach <path or owner/name>'}</Text>}
528          {found.map(one => (
529            <Button
530              key={`repo-${slugOf(one)}`}
531              plain
532              label={`${isSameRepo(one, attached) ? '● ' : '  '}${slugOf(one)}`}
533              onPress={() => (isSameRepo(one, attached) ? update($, isChoosing, () => false) : attach($, one, true))}
534            />
535          ))}
536          {attached !== null && (
537            <Box marginTop={1}>
538              <Button key="cancel" plain dimColor label="Cancel" onPress={() => update($, isChoosing, () => false)} />
539            </Box>
540          )}
541        </Box>
542      )
543    }
544
545    const held = await read($, feed)
546    const marks = { pinned: await read($, pinned), working: await read($, working) }
547
548    const view = { ...marks, search: await read($, search), filter: await read($, filter), viewer: held.viewer }
549    const shown = visible(held.issues, view)
550    const statuses = statusesOf(held.issues)
551    const nextStatus = statuses[statuses.indexOf(view.filter.status) + 1] ?? ''
552    const open = await read($, editing)
553    const cardColumns = Math.max(16, width - PIN_COLUMNS)
554    const slug = slugOf(attached)
555    const isNarrowed =
556      view.search.trim() !== held.query.trim() ||
557      view.filter.status.length > 0 ||
558      view.filter.isMine ||
559      view.filter.isPinnedOnly
560
561    const viewed = await read($, viewing)
562    const detail = viewed === null ? undefined : held.issues.find(issue => issue.number === viewed)
563    // Leads both views, so the selection is one press away from either.
564    const project = (
565      <Button
566        key="project"
567        variant="primary"
568        label={slug.length > width - PROJECT_ROOM ? `…${slug.slice(PROJECT_ROOM - width + 1)}` : slug}
569        onPress={() => update($, isChoosing, () => true)}
570      />
571    )
572
573    if (detail) {
574      const footer = detail.progress === null ? null : footerOf(detail.progress, BAR_COLUMNS)
575
576      return (
577        <Box flexDirection="column">
578          <Box columnGap={1}>
579            {project}
580            <Button key="back" variant="secondary" label="‹ Back" onPress={() => update($, viewing, () => null)} />
581          </Box>
582          <Box marginTop={1}>
583            <Text {...NUMBER} bold>{` #${detail.number} `}</Text>
584            <Text color={detail.status.color}>{' ●'}</Text>
585            <Text>{` ${detail.status.name}`}</Text>
586          </Box>
587          <Text bold>{detail.title}</Text>
588          <Box marginTop={1} flexWrap="wrap" columnGap={1}>
589            <Text dimColor>Assignees</Text>
590            {detail.assignees.length === 0 && <Text dimColor>none</Text>}
591            {detail.assignees.map(login => (
592              <Text>{`@${login}`}</Text>
593            ))}
594          </Box>
595          <Box flexWrap="wrap" columnGap={1}>
596            <Text dimColor>Labels</Text>
597            {detail.labels.length === 0 && <Text dimColor>none</Text>}
598            {detail.labels.map(label => (
599              <Text backgroundColor={label.color} color={inkOn(label.color)}>{` ${label.name} `}</Text>
600            ))}
601          </Box>
602          {footer !== null && (
603            <Box>
604              <Text color="success">{footer.filled}</Text>
605              <Text dimColor>
606                {footer.rest}
607                {footer.label}
608              </Text>
609            </Box>
610          )}
611          <Box marginTop={1}>
612            <Link href={detail.url} label="[ GitHub ↗ ]" />
613          </Box>
614          <Box marginTop={1}>
615            {detail.body.length > 0 ? <Markdown text={detail.body} /> : <Text dimColor>No description</Text>}
616          </Box>
617        </Box>
618      )
619    }
620
621    return (
622      <Box flexDirection="column">
623        {/* The body's first row, at its left: the frame's own row clips what a mod draws on it. */}
624        <Box>{project}</Box>
625        {/* Half a row each side of the field: a whole empty row is the least a margin gives, and reads as too much. */}
626        {Input && <Text color={SEARCH_FIELD}>{HALF_BELOW.repeat(width)}</Text>}
627        {Input && (
628          <Box backgroundColor={SEARCH_FIELD} paddingX={1}>
629          <Input
630            key="search"
631            placeholder="Search issues…"
632            value={view.search}
633            onInput={async (value: string) => {
634              await update($, search, () => value)
635
636              if (value.trim().length === 0 && (await read($, feed)).query.length > 0) {
637                await load($, 'reset')
638              }
639            }}
640            onSubmit={async (value: string) => {
641              await update($, search, () => value)
642              await load($, 'reset')
643            }}
644          />
645          </Box>
646        )}
647        {Input && <Text color={SEARCH_FIELD}>{HALF_ABOVE.repeat(width)}</Text>}
648        <Box flexWrap="wrap" columnGap={1}>
649          {[
650            ...STATES.map(one => ({
651              key: `state-${one.state}`,
652              label: one.label,
653              isOn: view.filter.state === one.state,
654              press: () => setFilter($, { state: one.state }),
655            })),
656            { key: 'mine', label: 'Assigned', isOn: view.filter.isMine, press: () => setFilter($, { isMine: !view.filter.isMine }) },
657            {
658              key: 'pinned-only',
659              label: 'Pinned',
660              isOn: view.filter.isPinnedOnly,
661              press: () => setFilter($, { isPinnedOnly: !view.filter.isPinnedOnly }),
662            },
663            {
664              key: 'status',
665              label: `Status: ${view.filter.status.length > 0 ? view.filter.status : 'any'}`,
666              isOn: view.filter.status.length > 0,
667              press: () => setFilter($, { status: nextStatus }),
668            },
669          ].map(chip => (
670            // The block says whether the filter is on; a Button takes no color of its own.
671            <Box backgroundColor={chip.isOn ? FILTER_ON : FILTER_OFF}>
672              <Button key={chip.key} plain label={chip.label} onPress={chip.press} />
673            </Box>
674          ))}
675        </Box>
676        {held.error !== null && <Text color="error">{held.error}</Text>}
677        <Text dimColor>
678          {isNarrowed ? `${shown.length} of ${held.total} issues` : `${held.total} issues`}
679          {held.isLoading ? ' · loading…' : ''}
680        </Text>
681        {shown.map(issue => {
682          const isPinned = marks.pinned.includes(issue.number)
683          const style = isPinned ? PINNED_HEADER : HEADER
684          const header = headerOf(issue, cardColumns)
685          const footer = issue.progress === null ? null : footerOf(issue.progress, BAR_COLUMNS)
686          // A title on two lines is one control: the pointer on either line lights both.
687          const lit = { scope: `title-${issue.number}`, inverse: true }
688
689          return (
690            <Box flexDirection="column" marginTop={1}>
691              <Box>
692                <Button
693                  key={`pin-${issue.number}`}
694                  plain
695                  dimColor={!isPinned}
696                  label={isPinned ? PINNED : LOOSE}
697                  onPress={() => togglePin($, issue.number)}
698                />
699                <Text {...NUMBER} bold>
700                  {header.number}
701                </Text>
702                <Text {...style}> </Text>
703                {/* A Button takes no background: the Box around it carries the header's across it. */}
704                <Box backgroundColor={style.backgroundColor}>
705                  <Button key={`open-${issue.number}`} plain hover={lit} label={header.title} onPress={() => openIssue($, issue.number)} />
706                </Box>
707                <Text {...style}>{' '.repeat(header.gap + 1)}</Text>
708                <Box backgroundColor={style.backgroundColor}>
709                  <Button
710                    key={`status-${issue.number}`}
711                    plain
712                    dimColor={open !== issue.number}
713                    label={header.status}
714                    onPress={() => update($, editing, held => (held === issue.number ? null : issue.number))}
715                  />
716                </Box>
717                <Text {...style}> </Text>
718                <Text backgroundColor={style.backgroundColor} color={issue.status.color}>
719                  ●
720                </Text>
721                <Text {...style}> </Text>
722              </Box>
723              {header.below !== null && (
724                <Box marginLeft={PIN_COLUMNS}>
725                  <Text {...style}>{' '.repeat(header.number.length + 1)}</Text>
726                  <Box backgroundColor={style.backgroundColor}>
727                    <Button
728                      key={`open-${issue.number}-more`}
729                      plain
730                      hover={lit}
731                      label={header.below.title}
732                      onPress={() => openIssue($, issue.number)}
733                    />
734                  </Box>
735                  <Text {...style}>{' '.repeat(header.below.gap)}</Text>
736                </Box>
737              )}
738              {open === issue.number && (
739                <Box marginLeft={INDENT} flexWrap="wrap" columnGap={2}>
740                  {choicesOf(issue).map((choice, index) => (
741                    <Box>
742                      <Text color={choice.color}>● </Text>
743                      <Button
744                        key={`set-${issue.number}-${index}`}
745                        plain
746                        dimColor={choice.name === issue.status.name}
747                        label={choice.name}
748                        onPress={() => changeStatus($, issue, choice)}
749                      />
750                    </Box>
751                  ))}
752                </Box>
753              )}
754              {footer !== null && (
755                <Box marginLeft={INDENT}>
756                  <Text color="success">{footer.filled}</Text>
757                  <Text dimColor>
758                    {footer.rest}
759                    {footer.label}
760                    {marks.working.includes(issue.number) ? ' · working' : ''}
761                  </Text>
762                </Box>
763              )}
764              {footer === null && marks.working.includes(issue.number) && (
765                <Box marginLeft={INDENT}>
766                  <Text dimColor>working</Text>
767                </Box>
768              )}
769            </Box>
770          )
771        })}
772        {held.hasMore && (
773          <Box marginTop={1}>
774            <Button key="more" plain label="Load more…" onPress={() => load($, 'more')} />
775          </Box>
776        )}
777      </Box>
778    )
779  })
780}
781
hooks/github.ts 286 lines
1import type { Ports } from './ports'
2
3import type { Filter, Issue, Repo } from '../types'
4import type { Choice } from './model'
5import { issueOf, slugOf } from './model'
6
7type Bag = Record<string, unknown>
8type Reply = { data?: Bag | null; errors?: unknown[] }
9/** One way of reaching GitHub's GraphQL API with the access the machine holds. */
10type Transport = (body: string) => Promise<Reply | undefined>
11
12export type Page = {
13  issues: Issue[]
14  /** The issues asked for by number that exist. */
15  named: Issue[]
16  cursor: string | null
17  hasMore: boolean
18  total: number
19  viewer: string
20}
21
22export type PageRequest = {
23  first: number
24  after: string | null
25  state: Filter['state']
26  search: string
27  /** Issues to fetch by number beside the page: pinned ones, ones the session names. */
28  numbers: number[]
29}
30
31const TIMEOUT_MS = 20_000
32const NO_ACCESS = 'No GitHub access on this machine.'
33const MAX_NAMED = 20
34
35const isBag = (value: unknown): value is Bag =>
36  typeof value === 'object' && value !== null && !Array.isArray(value)
37
38const parse = (text: string): Reply | undefined => {
39  try {
40    const reply: unknown = JSON.parse(text)
41
42    return isBag(reply) ? reply : undefined
43  } catch {
44    return undefined
45  }
46}
47
48/** The `gh` CLI with whatever login or token it holds; it keeps the credential to itself. */
49const viaGh =
50  (io: Ports, host: string): Transport =>
51  async body => {
52    const ran = await io.run(['gh', 'api', 'graphql', '--hostname', host, '--input', '-'], {
53      stdin: body,
54      timeoutMs: TIMEOUT_MS,
55    })
56
57    // gh exits non-zero on a partial answer too: the answer is what counts.
58    return parse(ran.stdout)
59  }
60
61const endpointOf = (host: string): string =>
62  host === 'github.com' ? 'https://api.github.com/graphql' : `https://${host}/api/graphql`
63
64const viaToken =
65  (io: Ports, host: string, token: string): Transport =>
66  async body => {
67    const answer = await io.fetch(endpointOf(host), {
68      method: 'POST',
69      headers: {
70        Authorization: `Bearer ${token}`,
71        'Content-Type': 'application/json',
72        'User-Agent': 'issue-tracker-mod',
73      },
74      body,
75    })
76
77    return parse(answer.text)
78  }
79
80/**
81 * A token the machine already holds for the host: the environment's, else the
82 * one git's credential helper stores. Never asks: a helper that would prompt
83 * is told not to, and answers nothing.
84 */
85const tokenFor = async (io: Ports, host: string): Promise<string | undefined> => {
86  const fromEnv = await io.envToken()
87
88  if (host === 'github.com' && fromEnv) {
89    return fromEnv
90  }
91
92  const ran = await io.run(['git', 'credential', 'fill'], {
93    stdin: `protocol=https\nhost=${host}\n\n`,
94    env: { GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', GIT_ASKPASS: '' },
95    timeoutMs: TIMEOUT_MS,
96  })
97  const [, password] = /^password=(.+)$/m.exec(ran.stdout) ?? []
98
99  return ran.exitCode === 0 ? password?.trim() : undefined
100}
101
102const PROBE = 'query($owner:String!,$name:String!){repository(owner:$owner,name:$name){hasIssuesEnabled}}'
103
104const transports = new Map<string, Transport>()
105
106/**
107 * The first way this machine reaches the repository's issues, or undefined
108 * when it has none. SSH keys alone reach the code, not the issues API.
109 */
110export const connect = async (io: Ports, repo: Repo): Promise<Transport | undefined> => {
111  const key = `${repo.host}/${slugOf(repo)}`
112  const held = transports.get(key)
113
114  if (held) {
115    return held
116  }
117
118  const probe = JSON.stringify({ query: PROBE, variables: { owner: repo.owner, name: repo.name } })
119  const candidates: (() => Promise<Transport | undefined>)[] = [
120    async () => viaGh(io, repo.host),
121    async () => {
122      const token = await tokenFor(io, repo.host)
123
124      return token ? viaToken(io, repo.host, token) : undefined
125    },
126  ]
127
128  for (const candidate of candidates) {
129    try {
130      const transport = await candidate()
131      const repository = (await transport?.(probe))?.data?.repository
132
133      if (transport && isBag(repository) && repository.hasIssuesEnabled === true) {
134        transports.set(key, transport)
135
136        return transport
137      }
138    } catch {
139      // This way is not available on the machine: the next one is tried.
140    }
141  }
142
143  return undefined
144}
145
146/** The fields asked of an issue, fewer at each level a server refuses. */
147const fragmentAt = (level: number): string =>
148  [
149    'fragment I on Issue{id number title url state stateReason body updatedAt',
150    'assignees(first:5){nodes{login}} labels(first:10){nodes{name color}}',
151    level < 2 ? 'subIssuesSummary{total completed}' : '',
152    level < 1
153      ? 'projectItems(first:3){nodes{id project{id field(name:"Status"){... on ProjectV2SingleSelectField{id options{id name color}}}} fieldValueByName(name:"Status"){... on ProjectV2ItemFieldSingleSelectValue{name color}}}}'
154      : '',
155    '}',
156  ].join(' ')
157
158const STATES: Record<Filter['state'], string[] | null> = { open: ['OPEN'], closed: ['CLOSED'], all: null }
159const QUALIFIERS: Record<Filter['state'], string> = { open: ' is:open', closed: ' is:closed', all: '' }
160
161const queryFor = (request: PageRequest, level: number): string => {
162  const isSearch = request.search.trim().length > 0
163  const named = request.numbers.map(number => `n${number}:issue(number:${number}){...I}`).join(' ')
164  const page = 'totalCount pageInfo{hasNextPage endCursor} nodes{...I}'
165  const list = isSearch
166    ? `search(query:$q,type:ISSUE,first:$first,after:$after){issueCount pageInfo{hasNextPage endCursor} nodes{... on Issue{...I}}}`
167    : ''
168  const issues = isSearch
169    ? ''
170    : `issues(first:$first,after:$after,states:$states,orderBy:{field:UPDATED_AT,direction:DESC}){${page}}`
171  const variables = isSearch ? '$q:String!' : '$states:[IssueState!]'
172
173  return [
174    `query($owner:String!,$name:String!,$first:Int!,$after:String,${variables}){`,
175    'viewer{login}',
176    `repository(owner:$owner,name:$name){hasIssuesEnabled ${issues} ${named}}`,
177    list,
178    '}',
179    fragmentAt(level),
180  ].join(' ')
181}
182
183/** The field level a host took last, so a refused field is asked once. */
184const levels = new Map<string, number>()
185
186/** One page of the repository's issues, or the reason there is none. */
187export const fetchPage = async (
188  io: Ports,
189  repo: Repo,
190  asked: PageRequest,
191): Promise<Page | { error: string }> => {
192  const transport = await connect(io, repo)
193
194  if (!transport) {
195    return { error: NO_ACCESS }
196  }
197
198  const request = { ...asked, numbers: asked.numbers.slice(0, MAX_NAMED) }
199  const text = request.search.trim()
200  const variables = {
201    owner: repo.owner,
202    name: repo.name,
203    first: request.first,
204    after: request.after,
205    ...(text.length > 0
206      ? { q: `repo:${slugOf(repo)} is:issue sort:updated-desc${QUALIFIERS[request.state]} ${text}` }
207      : { states: STATES[request.state] }),
208  }
209
210  for (let level = levels.get(repo.host) ?? 0; level <= 2; level += 1) {
211    let reply: Reply | undefined
212
213    try {
214      reply = await transport(JSON.stringify({ query: queryFor(request, level), variables }))
215    } catch {
216      return { error: 'GitHub does not answer.' }
217    }
218
219    const data = reply?.data
220    const repository = isBag(data) ? data.repository : undefined
221
222    if (!isBag(data) || !isBag(repository)) {
223      // No data at all: the server refused a field of this level.
224      continue
225    }
226
227    levels.set(repo.host, level)
228    const list = text.length > 0 ? data.search : repository.issues
229    const pageInfo = isBag(list) && isBag(list.pageInfo) ? list.pageInfo : {}
230    const nodes = isBag(list) && Array.isArray(list.nodes) ? list.nodes : []
231
232    return {
233      issues: nodes.flatMap(node => issueOf(node) ?? []),
234      named: request.numbers.flatMap(number => issueOf(repository[`n${number}`]) ?? []),
235      cursor: typeof pageInfo.endCursor === 'string' ? pageInfo.endCursor : null,
236      hasMore: pageInfo.hasNextPage === true,
237      total: isBag(list) ? Number(list.totalCount ?? list.issueCount ?? nodes.length) : nodes.length,
238      viewer: isBag(data.viewer) && typeof data.viewer.login === 'string' ? data.viewer.login : '',
239    }
240  }
241
242  return { error: 'GitHub refused the request.' }
243}
244
245const MOVE =
246  'mutation($project:ID!,$item:ID!,$field:ID!,$option:String!){updateProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field,value:{singleSelectOptionId:$option}}){projectV2Item{id}}}'
247const CLOSE = 'mutation($id:ID!,$reason:IssueClosedStateReason){closeIssue(input:{issueId:$id,stateReason:$reason}){issue{id}}}'
248const REOPEN = 'mutation($id:ID!){reopenIssue(input:{issueId:$id}){issue{id}}}'
249
250/** Gives the issue the status chosen, on GitHub. Resolves the reason when it did not take. */
251export const setStatus = async (io: Ports, repo: Repo, issue: Issue, choice: Choice): Promise<string | null> => {
252  const transport = await connect(io, repo)
253
254  if (!transport) {
255    return NO_ACCESS
256  }
257
258  const { board } = issue
259  const body =
260    'optionId' in choice
261      ? board && {
262          query: MOVE,
263          variables: { project: board.projectId, item: board.itemId, field: board.fieldId, option: choice.optionId },
264        }
265      : choice.state === 'OPEN'
266        ? { query: REOPEN, variables: { id: issue.id } }
267        : { query: CLOSE, variables: { id: issue.id, reason: choice.state } }
268
269  if (!body) {
270    return 'This issue has no project status to change.'
271  }
272
273  try {
274    const reply = await transport(JSON.stringify(body))
275    const [failure] = reply?.errors ?? []
276
277    if (failure === undefined && isBag(reply?.data)) {
278      return null
279    }
280
281    return isBag(failure) && typeof failure.message === 'string' ? failure.message : 'GitHub refused the change.'
282  } catch {
283    return 'GitHub does not answer.'
284  }
285}
286
hooks/model.ts 316 lines
1import type { Board, Filter, Issue, Label, Progress, Repo, Status } from '../types'
2
3type Bag = Record<string, unknown>
4
5const isBag = (value: unknown): value is Bag =>
6  typeof value === 'object' && value !== null && !Array.isArray(value)
7
8const asText = (value: unknown): string => (typeof value === 'string' ? value : '')
9
10const asNodes = (value: unknown): Bag[] =>
11  isBag(value) && Array.isArray(value.nodes) ? value.nodes.filter(isBag) : []
12
13/** The colors a project gives its status options, as a dark or light terminal shows them. */
14const PROJECT_COLORS: Record<string, string> = {
15  GRAY: '#9198a1',
16  BLUE: '#4493f8',
17  GREEN: '#3fb950',
18  YELLOW: '#d29922',
19  ORANGE: '#db6d28',
20  RED: '#f85149',
21  PINK: '#db61a2',
22  PURPLE: '#ab7df8',
23}
24const OPEN: Status = { name: 'Open', color: '#3fb950' }
25const CLOSED: Status = { name: 'Closed', color: '#ab7df8' }
26const NOT_PLANNED: Status = { name: 'Not planned', color: '#9198a1' }
27
28/** `git@host:owner/name.git`, `ssh://git@host/owner/name` or `https://host/owner/name`. */
29export const parseRemote = (url: string): Omit<Repo, 'root'> | undefined => {
30  const match =
31    /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^:/]+)[:/](?:\d+\/)?([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(url.trim())
32  const [, host, owner, name] = match ?? []
33
34  return host && owner && name ? { host, owner, name } : undefined
35}
36
37/** `owner/name`, as the person types a repository they have no working copy of. */
38export const parseSlug = (text: string): Omit<Repo, 'root'> | undefined => {
39  const [, owner, name] = /^([\w.-]+)\/([\w.-]+)$/.exec(text.trim()) ?? []
40
41  return owner && name ? { host: 'github.com', owner, name } : undefined
42}
43
44export const slugOf = (repo: Repo): string => `${repo.owner}/${repo.name}`
45
46export const isSameRepo = (one: Repo | null, other: Repo | null): boolean =>
47  one !== null &&
48  other !== null &&
49  one.host === other.host &&
50  slugOf(one).toLowerCase() === slugOf(other).toLowerCase()
51
52const CHECKBOX = /^\s*(?:[-*+]|\d+\.)\s+\[([ xX])\]/
53
54/** The checked and total task-list items of a body: acceptance criteria, usually. */
55export const checklistOf = (body: string): { done: number; total: number } => {
56  const marks = body.split('\n').flatMap(line => CHECKBOX.exec(line)?.[1] ?? [])
57
58  return { done: marks.filter(mark => mark !== ' ').length, total: marks.length }
59}
60
61/**
62 * How far an issue is, by the first thing that defines it: its sub-issues, the
63 * task list in its body, or its being closed. Null when nothing does.
64 */
65export const progressOf = (sub: unknown, body: string, isOpen: boolean): Progress | null => {
66  const total = isBag(sub) && typeof sub.total === 'number' ? sub.total : 0
67  const completed = isBag(sub) && typeof sub.completed === 'number' ? sub.completed : 0
68
69  if (!isOpen) {
70    return { done: 1, total: 1, source: 'closed' }
71  }
72
73  if (total > 0) {
74    return { done: completed, total, source: 'sub-issues' }
75  }
76
77  const checklist = checklistOf(body)
78
79  return checklist.total > 0 ? { ...checklist, source: 'checklist' } : null
80}
81
82export const percentOf = (progress: Progress): number =>
83  progress.total === 0 ? 0 : Math.round((progress.done / progress.total) * 100)
84
85/** The status its project gives the issue, else GitHub's open or closed. */
86export const statusOf = (node: Bag): Status => {
87  for (const item of asNodes(node.projectItems)) {
88    const value = item.fieldValueByName
89    const name = isBag(value) ? asText(value.name) : ''
90
91    if (isBag(value) && name.length > 0) {
92      return { name, color: colorOf(value.color) }
93    }
94  }
95
96  if (node.state !== 'CLOSED') {
97    return OPEN
98  }
99
100  return node.stateReason === 'NOT_PLANNED' ? NOT_PLANNED : CLOSED
101}
102
103const colorOf = (name: unknown): string => PROJECT_COLORS[asText(name)] ?? PROJECT_COLORS.GRAY ?? ''
104
105/** The project item that carries the issue's status, with the statuses on offer. */
106export const boardOf = (node: Bag): Board | null => {
107  for (const item of asNodes(node.projectItems)) {
108    const project = item.project
109    const field = isBag(project) ? project.field : undefined
110    const options = isBag(field) && Array.isArray(field.options) ? field.options.filter(isBag) : []
111
112    if (isBag(project) && isBag(field) && options.length > 0) {
113      return {
114        projectId: asText(project.id),
115        itemId: asText(item.id),
116        fieldId: asText(field.id),
117        options: options.map(one => ({ id: asText(one.id), name: asText(one.name), color: colorOf(one.color) })),
118      }
119    }
120  }
121
122  return null
123}
124
125/** A status the person can give an issue: a project's option, or GitHub's open and closed. */
126export type Choice = Status & ({ optionId: string } | { state: 'OPEN' | 'COMPLETED' | 'NOT_PLANNED' })
127
128export const choicesOf = (issue: Issue): Choice[] =>
129  issue.board
130    ? issue.board.options.map(({ id, name, color }) => ({ name, color, optionId: id }))
131    : [
132        { ...OPEN, state: 'OPEN' },
133        { ...CLOSED, state: 'COMPLETED' },
134        { ...NOT_PLANNED, state: 'NOT_PLANNED' },
135      ]
136
137/** The issue as it reads once the choice is made, before GitHub confirms it. */
138export const chosen = (issue: Issue, choice: Choice): Issue => ({
139  ...issue,
140  status: { name: choice.name, color: choice.color },
141  isOpen: 'state' in choice ? choice.state === 'OPEN' : issue.isOpen,
142})
143
144const HEX = /^[0-9a-f]{6}$/i
145
146/** A label node with the color GitHub gives it, six hex digits and no hash. */
147const labelOf = (node: Bag): Label => {
148  const color = asText(node.color)
149
150  return { name: asText(node.name), color: HEX.test(color) ? `#${color.toLowerCase()}` : colorOf('GRAY') }
151}
152
153/** Above this luma a background takes black text, under it white. */
154const LIGHT_LUMA = 140
155
156/** The text color that reads on a `#rrggbb` background. */
157export const inkOn = (color: string): string => {
158  const [red = 0, green = 0, blue = 0] = [1, 3, 5].map(at => Number.parseInt(color.slice(at, at + 2), 16))
159
160  return 0.299 * red + 0.587 * green + 0.114 * blue > LIGHT_LUMA ? '#000000' : '#ffffff'
161}
162
163/** One issue node of a GraphQL answer, or undefined when it is no issue. */
164export const issueOf = (node: unknown): Issue | undefined => {
165  if (!isBag(node) || typeof node.number !== 'number') {
166    return undefined
167  }
168
169  const body = asText(node.body)
170  const isOpen = node.state !== 'CLOSED'
171
172  return {
173    id: asText(node.id),
174    number: node.number,
175    title: asText(node.title),
176    url: asText(node.url),
177    isOpen,
178    status: statusOf(node),
179    board: boardOf(node),
180    body: body.replace(/<!--[\s\S]*?-->/g, '').trim(),
181    labels: asNodes(node.labels).map(labelOf),
182    progress: progressOf(node.subIssuesSummary, body, isOpen),
183    assignees: asNodes(node.assignees).map(one => asText(one.login)),
184    updatedAt: asText(node.updatedAt),
185  }
186}
187
188/** The issue numbers a text names for this repository: `#12`, its URL, a `gh issue` call. */
189export const refsIn = (text: string, repo: Repo): number[] => {
190  const slug = slugOf(repo).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
191  const patterns = [
192    /(?:^|[\s(,;:])#(\d{1,6})(?![\w#])/g,
193    new RegExp(`${slug}/issues/(\\d{1,6})`, 'gi'),
194    /\bgh\s+issue\s+\w+\s+(\d{1,6})\b/g,
195  ]
196  const found = patterns.flatMap(pattern => [...text.matchAll(pattern)].map(match => Number(match[1])))
197
198  return [...new Set(found)].filter(number => number > 0)
199}
200
201/** The issue a branch is named for: `242-discount`, `feat/242_x`, `issue-242`. */
202export const branchRef = (branch: string): number | undefined => {
203  const [, lead, named] = /(?:^|\/)(\d{1,6})[-_]|issue[-_/]?(\d{1,6})/i.exec(branch) ?? []
204  const number = Number(lead ?? named)
205
206  return number > 0 ? number : undefined
207}
208
209/** Fresh issues laid over the ones held, newest change first. */
210export const merged = (held: Issue[], fresh: Issue[]): Issue[] => {
211  const byNumber = new Map([...held, ...fresh].map(issue => [issue.number, issue]))
212
213  return [...byNumber.values()].sort((one, other) => other.updatedAt.localeCompare(one.updatedAt))
214}
215
216type Marks = { pinned: number[]; working: number[] }
217
218/** Pinned first, then what the session works on, then the rest as held. */
219export const ordered = (issues: Issue[], marks: Marks): Issue[] => {
220  const rank = (issue: Issue): number =>
221    marks.pinned.includes(issue.number) ? 0 : marks.working.includes(issue.number) ? 1 : 2
222
223  return issues
224    .map((issue, index) => ({ issue, index }))
225    .sort((one, other) => rank(one.issue) - rank(other.issue) || one.index - other.index)
226    .map(({ issue }) => issue)
227}
228
229type View = Marks & { search: string; filter: Filter; viewer: string }
230
231/** The issues the panel lists: a pinned one passes every filter but the search text. */
232export const visible = (issues: Issue[], view: View): Issue[] => {
233  const text = view.search.trim().toLowerCase().replace(/^#/, '')
234  const { filter } = view
235
236  return ordered(issues, view).filter(issue => {
237    const isPinned = view.pinned.includes(issue.number)
238    const isFound =
239      text.length === 0 ||
240      String(issue.number).includes(text) ||
241      issue.title.toLowerCase().includes(text) ||
242      issue.body.toLowerCase().includes(text)
243
244    if (!isFound || (filter.isPinnedOnly && !isPinned)) {
245      return false
246    }
247
248    return (
249      isPinned ||
250      ((filter.state === 'all' || (filter.state === 'open') === issue.isOpen) &&
251        (filter.status.length === 0 || issue.status.name === filter.status) &&
252        (!filter.isMine || issue.assignees.includes(view.viewer)))
253    )
254  })
255}
256
257export const statusesOf = (issues: Issue[]): string[] => [...new Set(issues.map(issue => issue.status.name))]
258
259const cut = (text: string, width: number): string =>
260  text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
261
262const STATUS_COLUMNS = 14
263
264/** The last space a line `room` wide can break `text` at, else `room` itself. */
265const breakAt = (text: string, room: number): number => {
266  const space = text.lastIndexOf(' ', room)
267
268  return space > room / 2 ? space : room
269}
270
271/**
272 * A card's header `width` cells wide: the number in a block of its own, a
273 * cell, the title and the `gap` that carries it to the status name at the
274 * right, which the dot follows. A title too long for the line goes on, once,
275 * on a second line under its start, with the gap that fills that line.
276 */
277export const headerOf = (
278  issue: Issue,
279  width: number,
280): { number: string; title: string; gap: number; status: string; below: { title: string; gap: number } | null } => {
281  const status = cut(issue.status.name, STATUS_COLUMNS)
282  const number = ` #${issue.number} `
283  // A cell before the title; a cell each side of the status, the dot and the closing cell.
284  const room = Math.max(0, width - status.length - 4 - number.length - 1)
285
286  if (issue.title.length <= room) {
287    return { number, title: issue.title, gap: room - issue.title.length, status, below: null }
288  }
289
290  const at = breakAt(issue.title, room)
291  const wide = Math.max(0, width - number.length - 2)
292  const rest = cut(issue.title.slice(at).trim(), wide)
293
294  // The second line keeps the first one's closing cell.
295  return { number, title: issue.title.slice(0, at), gap: room - at, status, below: { title: rest, gap: wide - rest.length + 1 } }
296}
297
298const SOURCES: Record<Progress['source'], string> = {
299  'sub-issues': 'sub-issue',
300  checklist: 'criteria',
301  closed: 'closed',
302}
303
304/** A card's footer: a bar, the percentage, and what it counts. */
305export const footerOf = (progress: Progress, width: number): { filled: string; rest: string; label: string } => {
306  const percent = percentOf(progress)
307  const cells = Math.round((percent / 100) * width)
308  const count = progress.source === 'closed' ? '' : ` · ${progress.done}/${progress.total}`
309
310  return {
311    filled: '█'.repeat(cells),
312    rest: '░'.repeat(width - cells),
313    label: ` ${percent}%${count} ${SOURCES[progress.source]}`,
314  }
315}
316
hooks/ports.ts 19 lines
1/**
2 * What the GitHub and git adapters need of the machine. The hooks module
3 * builds it over the engine; a test hands in its own.
4 */
5export type Ports = {
6  run: (
7    argv: readonly string[],
8    init?: { stdin?: string; env?: Record<string, string>; timeoutMs?: number },
9  ) => Promise<{ exitCode: number; stdout: string }>
10  fetch: (
11    url: string,
12    init: { method: string; headers: Record<string, string>; body: string },
13  ) => Promise<{ text: string }>
14  /** A GitHub token the environment already carries, when it does. */
15  envToken: () => Promise<string | undefined>
16  list: (dir: string) => Promise<{ name: string; kind: string }[]>
17  exists: (path: string) => Promise<boolean>
18}
19
hooks/project.ts 81 lines
1import type { Ports } from './ports'
2
3import type { Repo } from '../types'
4import { parseRemote } from './model'
5
6const TIMEOUT_MS = 10_000
7
8const git = async (io: Ports, dir: string, ...args: string[]): Promise<string | undefined> => {
9  try {
10    const ran = await io.run(['git', '-C', dir, ...args], { timeoutMs: TIMEOUT_MS })
11
12    return ran.exitCode === 0 ? ran.stdout.trim() : undefined
13  } catch {
14    return undefined
15  }
16}
17
18/** The directories already asked about, so a path is located once. */
19const located = new Map<string, Repo | undefined>()
20
21/** The repository whose working copy holds `dir`, by its `origin` or first remote. */
22export const locate = async (io: Ports, dir: string): Promise<Repo | undefined> => {
23  if (located.has(dir)) {
24    return located.get(dir)
25  }
26
27  const root = await git(io, dir, 'rev-parse', '--show-toplevel')
28  const remote = root === undefined ? undefined : await git(io, root, 'remote')
29  const name = remote?.split('\n').find(one => one === 'origin') ?? remote?.split('\n')[0]
30  const url = root && name ? await git(io, root, 'remote', 'get-url', name) : undefined
31  const parsed = url ? parseRemote(url) : undefined
32  const repo = root && parsed ? { ...parsed, root } : undefined
33  located.set(dir, repo)
34
35  return repo
36}
37
38/** The repositories whose working copies sit directly inside `dir`. */
39export const locateChildren = async (io: Ports, dir: string): Promise<Repo[]> => {
40  try {
41    const entries = await io.list(dir)
42    const found: Repo[] = []
43
44    for (const entry of entries.filter(one => one.kind === 'dir' && !one.name.startsWith('.'))) {
45      const child = `${dir.replace(/[\\/]$/, '')}/${entry.name}`
46
47      if (await io.exists(`${child}/.git`)) {
48        const repo = await locate(io, child)
49
50        if (repo) {
51          found.push(repo)
52        }
53      }
54    }
55
56    return found
57  } catch {
58    return []
59  }
60}
61
62export const branchOf = async (io: Ports, repo: Repo): Promise<string> =>
63  repo.root.length > 0 ? ((await git(io, repo.root, 'branch', '--show-current')) ?? '') : ''
64
65/** The directory a tool call works in, when its input names one. */
66export const dirOf = (input: Record<string, unknown>): string | undefined => {
67  const file = [input.file_path, input.notebook_path, input.path].find(
68    (value): value is string => typeof value === 'string' && /^([a-zA-Z]:)?[\\/]/.test(value),
69  )
70
71  if (file) {
72    return file.replace(/[\\/][^\\/]*$/, '')
73  }
74
75  const command = typeof input.command === 'string' ? input.command : ''
76  const [, quoted, bare] = /(?:\bgit\s+-C|\bcd)\s+(?:"([^"]+)"|'[^']*'|(\S+))/.exec(command) ?? []
77  const dir = quoted ?? bare
78
79  return dir && /^([a-zA-Z]:)?[\\/]/.test(dir) ? dir : undefined
80}
81
hooks/mod-signals/kit/signals.ts 110 lines
1/**
2 * The Mod Signals kit, for version 0.1 of the standard. A mod imports this
3 * file as it is: it is the same in every mod, and is not to be edited there.
4 *
5 * Mod Signals 0.1, the part every mod carries: what a signal is, how one is
6 * made and how one is read. A mod emits by writing a signal under its own state
7 * key `signal`, and listens by hooking `state.set` on that key, whoever writes.
8 */
9export type SignalKind = 'event' | 'command' | 'announce'
10
11export type Signal = {
12  v: 1
13  kind: SignalKind
14  name: string
15  to?: string
16  data?: Record<string, unknown>
17  /** What kind of signal this is, for whoever picks signals by kind: `['info', 'completion']`. */
18  tags?: string[]
19  id: string
20}
21
22/** What a mod says of itself in an `announce`. */
23export type Announce = {
24  title: string
25  accepts: string[]
26  emits: string[]
27}
28
29/** The most characters of a `name`, an `id` or a `to`. */
30const NAME_LENGTH = 64
31
32/** The most tags of a signal. */
33const TAGS_LENGTH = 8
34
35/**
36 * The tags every mod shares; any other is its mod's own, written with a
37 * namespace: `citroen.low-fuel`.
38 */
39export const TAGS = ['info', 'warning', 'error', 'success', 'completion', 'important', 'progress'] as const
40
41/** A mod answers the roll-calls of others no more often than this. */
42export const ROLL_CALL_MS = 1000
43
44const KINDS: readonly SignalKind[] = ['event', 'command', 'announce']
45
46/** Lowercase words joined by `-`, `.` between namespaces. */
47const NAME = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/
48
49const isRecord = (value: unknown): value is Record<string, unknown> =>
50  typeof value === 'object' && value !== null && !Array.isArray(value)
51
52const isShort = (value: unknown): value is string =>
53  typeof value === 'string' && value.length > 0 && value.length <= NAME_LENGTH
54
55const isName = (value: unknown): value is string => isShort(value) && NAME.test(value)
56
57/** A signal of this mod's, the `count`-th it emits: its id is unique in the session. */
58export const signalOf = (
59  kind: SignalKind,
60  name: string,
61  count: number,
62  now: number,
63  rest: { to?: string; data?: Record<string, unknown>; tags?: string[] } = {},
64): Signal => ({
65  v: 1,
66  kind,
67  name,
68  id: `${now.toString(36)}-${count}`,
69  ...(rest.to === undefined ? {} : { to: rest.to }),
70  ...(rest.data === undefined ? {} : { data: rest.data }),
71  ...(rest.tags === undefined || rest.tags.length === 0 ? {} : { tags: rest.tags }),
72})
73
74/**
75 * The signal a written value holds, or null: an unknown version or kind, a name
76 * off the grammar, a command with no receiver, a receiver on anything else.
77 */
78export const readSignal = (value: unknown): Signal | null => {
79  if (!isRecord(value) || value.v !== 1 || !isName(value.name) || !isShort(value.id)) {
80    return null
81  }
82
83  const kind = KINDS.find(one => one === value.kind)
84  const isAddressed = value.to !== undefined
85
86  if (kind === undefined || (isAddressed && !isShort(value.to)) || isAddressed !== (kind === 'command')) {
87    return null
88  }
89
90  if ((kind === 'announce') !== (value.name === 'announce') || (value.data !== undefined && !isRecord(value.data))) {
91    return null
92  }
93
94  if (value.tags !== undefined && !(Array.isArray(value.tags) && value.tags.length <= TAGS_LENGTH && value.tags.every(isName))) {
95    return null
96  }
97
98  return value as Signal
99}
100
101/** Whether the signal carries the tag. */
102export const hasTag = (signal: Signal, tag: string): boolean => (signal.tags ?? []).includes(tag)
103
104/** What an `announce` says its sender accepts, or null when it does not hold. */
105export const acceptsOf = (signal: Signal): string[] | null => {
106  const accepts = signal.kind === 'announce' ? signal.data?.accepts : undefined
107
108  return Array.isArray(accepts) && accepts.every(isShort) ? accepts : null
109}
110
types/index.d.ts 128 lines
1/** A GitHub repository the session can show the issues of. */
2export type Repo = {
3  host: string
4  owner: string
5  name: string
6  /** The working copy's root on this machine; empty when attached by name alone. */
7  root: string
8}
9
10/** An issue's state as its project names and colors it, else GitHub's own. */
11export type Status = { name: string; color: string }
12
13/** Where an issue's status lives in its project, and the statuses that project offers. */
14export type Board = {
15  projectId: string
16  itemId: string
17  fieldId: string
18  options: (Status & { id: string })[]
19}
20
21/** A label as the repository names and colors it. */
22export type Label = { name: string; color: string }
23
24/** How far an issue is, and what says so. */
25export type Progress = {
26  done: number
27  total: number
28  source: 'sub-issues' | 'checklist' | 'closed'
29}
30
31export type Issue = {
32  /** GitHub's node id, what a change of state names the issue by. */
33  id: string
34  number: number
35  title: string
36  url: string
37  isOpen: boolean
38  status: Status
39  /** Null when no project gives the issue a status: it is open or closed then. */
40  board: Board | null
41  /** The issue's text as written, markdown, without its hidden comments. */
42  body: string
43  labels: Label[]
44  /** Null when nothing in the issue defines a completion. */
45  progress: Progress | null
46  assignees: string[]
47  updatedAt: string
48}
49
50export type Filter = {
51  state: 'open' | 'closed' | 'all'
52  /** A status name, or empty for every status. */
53  status: string
54  isMine: boolean
55  isPinnedOnly: boolean
56}
57
58/** The issues loaded so far and where the next page starts. */
59export type Feed = {
60  issues: Issue[]
61  cursor: string | null
62  hasMore: boolean
63  /** How many issues GitHub holds for the list asked, loaded or not. */
64  total: number
65  isLoading: boolean
66  error: string | null
67  /** The login the machine's GitHub access belongs to. */
68  viewer: string
69  /** The search text the server answered this list for. */
70  query: string
71}
72
73export type Access = 'unknown' | 'granted' | 'none'
74
75/** A signal of Mod Signals 0.1, as the mod writes it under `signal` for every other mod to hear. */
76export type IssuesSignal = {
77  v: 1
78  kind: 'event' | 'command' | 'announce'
79  name: string
80  to?: string
81  data?: Record<string, unknown>
82  /** What kind of signal this is, for whoever picks signals by kind: `['info', 'completion']`. */
83  tags?: string[]
84  id: string
85}
86
87/**
88 * What any other mod calls on `$.issues` to drive the pane with no command typed.
89 * Each answers whether the pane is open once the call is done.
90 */
91export type Issues = {
92  /** Opens the pane on the attached project; false when there is no project to show. */
93  open: () => Promise<boolean>
94  close: () => Promise<boolean>
95  /** Closes the pane when it is open, opens it otherwise. */
96  toggle: () => Promise<boolean>
97  isOpen: () => Promise<boolean>
98}
99
100declare module 'claude-code' {
101  interface EngineInterface {
102    issues: Issues
103  }
104
105  interface PluginState {
106    'issue-tracker': {
107      repo: Repo | null
108      /** Repositories found on this machine the person can switch to. */
109      candidates: Repo[]
110      access: Access
111      feed: Feed
112      search: string
113      filter: Filter
114      pinned: number[]
115      /** Issues this session mentioned or works on the branch of. */
116      working: number[]
117      /** True while the panel shows the project selection in place of the issues. */
118      isChoosing: boolean
119      /** The issue whose status choices are open, if any. */
120      editing: number | null
121      /** The issue the panel shows in full in place of the list, if any. */
122      viewing: number | null
123      /** The mod's signals: `announce`, `opened`, `closed`, `notify`. */
124      signal: IssuesSignal | null
125    }
126  }
127}
128