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

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.
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>.
The mod asks for no login. It uses what the machine already holds, in this order:
gh CLI, with whatever account it is logged in as;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 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.
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.feat/242-x) come right after the pinned ones, marked working.The bar of a card comes from the first of these the issue has:
- [ ], - [x]): checked over total, shown as criteria.An issue with none of them has no bar.
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.
/issues opens the pane and refreshes it./issues attach <path or owner/name> shows another repository./issues close closes the pane.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
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.
hooks/register.tsx 781 lines1import { 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}
781hooks/github.ts 286 lines1import 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}
286hooks/model.ts 316 lines1import 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}
316hooks/ports.ts 19 lines1/**
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}
19hooks/project.ts 81 lines1import 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}
81hooks/mod-signals/kit/signals.ts 110 lines1/**
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}
110types/index.d.ts 128 lines1/** 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