SLOPSHOPPER

gh-issue-links

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

newrowsprocess
v0.2.0no licenseupdated 2026-10-14adamico/gh-issue-links
A shopper browsing a rack in a slop shop
README

gh-issue-links

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.
  • Titles come from gh api, so gh must be installed and logged in. A reference whose lookup fails is left as written.
  • In the fullscreen terminal, a plain click on a link toggles a card under the reply with the issue's status (open, closed as completed / not planned / duplicate, merged, draft) and its labels. Ctrl+click still opens it.

Install

/plugin install gh-issue-links --marketplace adamico/gh-issue-links

Develop

claude plugin validate .
claude plugin test .
Source 4 files
hooks/register.tsx 137 lines
1import { 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}
137
hooks/details.ts 63 lines
1export 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}
63
hooks/linkify.ts 102 lines
1export 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}
102
types/index.d.ts 9 lines
1// 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