Turns GitHub issue and PR references in replies into links labelled repo#N (title), with a status and labels card on click

A Claude Code mod that turns GitHub issue and PR references in Claude's replies into links labelled repo#N (title), the way the desktop app does.
#123 links to the working directory's repo (its origin remote), repo#123 too when repo is that repo's name, and owner/repo#123 and github.com/.../issues/N or .../pull/N URLs anywhere.gh api, so gh must be installed and logged in. A reference whose lookup fails is left as written./plugin install gh-issue-links --marketplace adamico/gh-issue-links
claude plugin validate .
claude plugin test .hooks/register.tsx 137 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { DETAILS_JQ, inkOn, parseDetails, statusOf } from './details'
5import type { Details } from './details'
6import { findRefs, linkify, parseRemote, refKey } from './linkify'
7import type { Ref, Repo } from './linkify'
8
9const openCards = atom({ plugin: 'gh-issue-links', key: 'openCards' } as const, [])
10
11// Lookups are cheap to redo, so a reload starting them over is fine.
12const details = new Map<string, Details | null>()
13const pending = new Set<string>()
14// The working directory's repo, looked up again after each change of directory.
15let repo: Promise<Repo | undefined> | undefined
16
17async function lookUpRepo($: EngineInterface) {
18 const { exitCode, stdout } = await $.process.run(['git', 'remote', 'get-url', 'origin'])
19
20 return exitCode === 0 ? parseRemote(stdout) : undefined
21}
22
23async function fetchDetails($: EngineInterface, ref: Ref) {
24 const key = refKey(ref)
25 pending.add(key)
26
27 try {
28 const { exitCode, stdout } = await $.process.run(
29 ['gh', 'api', `repos/${ref.owner}/${ref.name}/issues/${ref.number}`, '--jq', DETAILS_JQ],
30 { timeoutMs: 10_000 },
31 )
32 details.set(key, exitCode === 0 ? parseDetails(stdout) : null)
33 } catch {
34 details.set(key, null)
35 } finally {
36 pending.delete(key)
37 }
38
39 $.ui.invalidate('ui.render')
40}
41
42const titleOf = (key: string) => details.get(key)?.title
43
44export const register: Register = on => {
45 on('classic.CwdChanged', ($, e, next) => {
46 repo = undefined
47
48 return next(e)
49 }).catch(($, e, next) => next(e))
50
51 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
52 repo ??= lookUpRepo($)
53 const sessionRepo = await repo
54
55 for (const ref of findRefs(e.props.text, sessionRepo)) {
56 const key = refKey(ref)
57
58 if (!details.has(key) && !pending.has(key)) {
59 void fetchDetails($, ref)
60 }
61 }
62
63 const { text, links } = linkify(e.props.text, sessionRepo, titleOf)
64
65 // Cards need clicks, which only the fullscreen terminal reports; elsewhere
66 // the rewritten text is drawn by the engine as any reply is.
67 if (e.surface !== 'terminal' || e.viewport?.isFullscreen !== true || links.size === 0) {
68 return next({ ...e, props: { ...e.props, text } })
69 }
70
71 const { Box, Markdown, Text } = $.ui.resolve(e)
72 const open = await read($, openCards)
73 const shown = [...new Set(links.values())].filter(key => open.includes(`${e.requestId} ${key}`))
74 const width = Math.min(72, (e.viewport?.columns ?? 80) - 4)
75
76 return (
77 <Box flexDirection="row">
78 <Box width={2} flexShrink={0}>
79 <Text>{e.props.isFirstOfReply ? '●' : ' '}</Text>
80 </Box>
81 <Box flexDirection="column" flexGrow={1}>
82 <Markdown
83 key="reply"
84 text={text}
85 pressableLinks={[...links.keys()]}
86 onLinkPress={link => {
87 const card = `${e.requestId} ${links.get(link.href)}`
88
89 return update($, openCards, cards =>
90 cards.includes(card) ? cards.filter(c => c !== card) : [...cards, card],
91 )
92 }}
93 />
94 {shown.map(key => {
95 const issue = details.get(key)
96
97 if (!issue) {
98 return null
99 }
100
101 const status = statusOf(issue)
102
103 return (
104 <Box
105 key={`card ${key}`}
106 flexDirection="column"
107 borderStyle="round"
108 borderColor="subtle"
109 paddingX={1}
110 width={width}
111 >
112 <Text>
113 <Text bold color={status.color}>{status.text}</Text>
114 <Text dimColor>{` ${key.replace('#', ' #')}`}</Text>
115 </Text>
116 <Text bold>{issue.title}</Text>
117 {issue.labels.length > 0 && (
118 <Text>
119 {issue.labels.map((label, i) => (
120 <Text key={`label ${i}`}>
121 {i > 0 ? ' ' : ''}
122 <Text backgroundColor={`#${label.color}`} color={inkOn(label.color)}>
123 {` ${label.name} `}
124 </Text>
125 </Text>
126 ))}
127 </Text>
128 )}
129 </Box>
130 )
131 })}
132 </Box>
133 </Box>
134 )
135 })
136}
137hooks/details.ts 63 lines1export type Label = { name: string; color: string }
2
3export type Details = {
4 title: string
5 state: 'open' | 'closed'
6 reason: string | null
7 isPullRequest: boolean
8 isMerged: boolean
9 isDraft: boolean
10 labels: Label[]
11}
12
13// What `gh api` is asked for: the issue (or PR) shaped into Details.
14export const DETAILS_JQ =
15 '{title, state, reason: .state_reason, isPullRequest: (.pull_request != null), ' +
16 'isMerged: (.pull_request.merged_at != null), isDraft: (.draft == true), ' +
17 'labels: [.labels[] | {name, color}]}'
18
19export const parseDetails = (json: string): Details | null => {
20 try {
21 const details = JSON.parse(json) as Details
22
23 return typeof details.title === 'string' ? details : null
24 } catch {
25 return null
26 }
27}
28
29export type Status = { text: string; color: string }
30
31export const statusOf = (details: Details): Status => {
32 if (details.isMerged) {
33 return { text: '⑂ Merged', color: 'merged' }
34 }
35
36 if (details.state === 'open') {
37 return details.isDraft
38 ? { text: '○ Draft', color: 'inactive' }
39 : { text: '○ Open', color: 'success' }
40 }
41
42 if (details.isPullRequest) {
43 return { text: '⊘ Closed', color: 'error' }
44 }
45
46 switch (details.reason) {
47 case 'not_planned':
48 return { text: '⊘ Closed as not planned', color: 'inactive' }
49 case 'duplicate':
50 return { text: '⊘ Closed as duplicate', color: 'inactive' }
51 default:
52 return { text: '✓ Closed as completed', color: 'merged' }
53 }
54}
55
56// Black or white text, whichever reads better on the label's colour.
57export const inkOn = (hex: string) => {
58 const n = Number.parseInt(hex, 16)
59 const luma = (0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255
60
61 return luma > 0.6 ? '#000000' : '#ffffff'
62}
63hooks/linkify.ts 102 lines1export type Repo = { owner: string; name: string }
2
3export type Ref = { owner: string; name: string; number: number }
4
5// The title of `owner/name#number`, once known.
6export type TitleOf = (key: string) => string | undefined
7
8// What linkify wrote: the reply's text, and which ref each link it added points at.
9export type Linked = { text: string; links: Map<string, string> }
10
11export const refKey = (ref: Ref) => `${ref.owner}/${ref.name}#${ref.number}`
12
13// Code fences, inline code, markdown links and autolinks are left as written.
14const SKIP = /(```[\s\S]*?(?:```|$)|`[^`\n]*`|!?\[[^\]\n]*\]\([^)\n]*\)|<https?:[^>\s]*>)/
15
16const REF =
17 /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(?:issues|pull)\/(\d+)(?![\w/])|(?<![\w/&#.-])(?:([\w.-]+)\/)?([\w.-]+)?#(\d+)\b/g
18
19export const parseRemote = (url: string): Repo | undefined => {
20 const match = /github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(url.trim())
21
22 const [, owner, name] = match ?? []
23
24 return owner !== undefined && name !== undefined ? { owner, name } : undefined
25}
26
27const resolve = (match: RegExpExecArray, repo: Repo | undefined): Ref | undefined => {
28 const [, urlOwner, urlName, urlNumber, owner, name, number] = match
29
30 if (urlOwner !== undefined && urlName !== undefined) {
31 return { owner: urlOwner, name: urlName, number: Number(urlNumber) }
32 }
33
34 if (owner !== undefined && name !== undefined) {
35 return { owner, name, number: Number(number) }
36 }
37
38 if (repo === undefined) {
39 return undefined
40 }
41
42 // `repo#N` names the session's repo only when the names agree; `#N` always does.
43 if (name !== undefined && name.toLowerCase() !== repo.name.toLowerCase()) {
44 return undefined
45 }
46
47 return { ...repo, number: Number(number) }
48}
49
50const escape = (text: string) => text.replace(/[\\[\]*_`<>]/g, c => `\\${c}`)
51
52const eachRef = (
53 text: string,
54 repo: Repo | undefined,
55 replace: (ref: Ref, written: string) => string,
56) =>
57 text
58 .split(SKIP)
59 .map((part, i) =>
60 i % 2 === 1
61 ? part
62 : part.replace(REF, (...args) => {
63 const match = args.slice(0, -2) as unknown as RegExpExecArray
64 const ref = resolve(match, repo)
65
66 return ref ? replace(ref, match[0]) : match[0]
67 }),
68 )
69 .join('')
70
71export const findRefs = (text: string, repo: Repo | undefined): Ref[] => {
72 const refs = new Map<string, Ref>()
73 eachRef(text, repo, (ref, written) => {
74 refs.set(refKey(ref), ref)
75
76 return written
77 })
78
79 return [...refs.values()]
80}
81
82export const linkify = (text: string, repo: Repo | undefined, titleOf: TitleOf): Linked => {
83 const links = new Map<string, string>()
84 const linked = eachRef(text, repo, (ref, written) => {
85 const key = refKey(ref)
86 const title = titleOf(key)
87
88 if (title === undefined) {
89 return written
90 }
91
92 const href = written.startsWith('https://')
93 ? written
94 : `https://github.com/${ref.owner}/${ref.name}/issues/${ref.number}`
95 links.set(href, key)
96
97 return `[${escape(`${ref.name}#${ref.number} (${title})`)}](${href})`
98 })
99
100 return { text: linked, links }
101}
102types/index.d.ts 9 lines1// Each open card: `<message requestId> <owner/name#number>`.
2export type OpenCards = readonly string[]
3
4declare module 'claude-code' {
5 interface PluginState {
6 'gh-issue-links': { openCards: OpenCards }
7 }
8}
9