Pane listing the open issues of the current project (number, title, last updated) from GitLab, or from GitHub when the default remote is hosted there…

A Claude Code mod that keeps a pane beside the chat listing the open issues of the current project: number, title and when each was last updated, newest first. Labels show as toggles above the list; pressing one cycles it through include, exclude and ignore, and r refreshes. Pressing an issue shows its description in the lower half of the pane, cut to fit with an ellipsis; m (Show more) shows it whole, x (Close) or pressing the issue again closes it. The list refreshes by itself every five minutes.
The forge is picked from the repository's default remote (the current branch's upstream remote, else origin, else the first remote):
github.com): gh issue list when gh is installed and signed in; otherwise the GitHub REST API, using GH_TOKEN or GITHUB_TOKEN when set. A public repository needs no token; a private one needs gh or a token. Pull requests are left out.glab issue list, so glab must be installed and signed in for GitLab projects, self-hosted ones included./plugin install git-issues --marketplace davideandres95/git-issues
Answer y to add the marketplace, then pick a scope.
/issues opens the pane and refreshes it, keeping the current label filter./issues bug -wontfix shows issues labelled bug and not wontfix./issues all clears the filter.glab.gh, GitHub issues come from one page of 100 items, pull requests included, so a repository with many open pull requests may show fewer issues.claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT, see LICENSE.
hooks/register.tsx 402 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Issue, IssueList, IssuePreview, IssueSource, LabelFilter } from '../types'
5
6const PANE = 'git-issues'
7const REFRESH_MS = 5 * 60 * 1000
8const NO_FILTER: LabelFilter = { include: [], exclude: [] }
9const list = atom({ plugin: 'git-issues', key: 'list' } as const, { issues: [] } as IssueList)
10const filter = atom({ plugin: 'git-issues', key: 'filter' } as const, NO_FILTER)
11const NO_PREVIEW: IssuePreview = { isExpanded: false }
12const preview = atom({ plugin: 'git-issues', key: 'preview' } as const, NO_PREVIEW)
13
14type Fetched = { issues: Issue[] } | { error: string }
15
16function run($: EngineInterface, argv: string[]) {
17 return $.process
18 .run(argv, { timeoutMs: 30_000 })
19 .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
20}
21
22function lastLine(text: string): string {
23 return text.trim().split('\n').pop() ?? ''
24}
25
26// The default remote: the current branch's upstream remote, else `origin`, else the first one.
27async function defaultRemoteUrl($: EngineInterface): Promise<string | undefined> {
28 const upstream = await run($, ['git', 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
29 const remotes = await run($, ['git', 'remote'])
30 const names = remotes.exitCode === 0 ? remotes.stdout.split('\n').map(s => s.trim()).filter(Boolean) : []
31 const tracked = upstream.exitCode === 0 ? names.find(n => upstream.stdout.trim().startsWith(`${n}/`)) : undefined
32 const name = tracked ?? (names.includes('origin') ? 'origin' : names[0])
33 if (!name) return undefined
34 const url = await run($, ['git', 'remote', 'get-url', name])
35 return url.exitCode === 0 ? url.stdout.trim() : undefined
36}
37
38// `git@host:owner/repo.git`, `ssh://git@host:22/owner/repo.git` and `https://host/owner/repo`.
39function parseRemote(url: string): { host: string; path: string } | undefined {
40 const scp = /^(?:[^@/]+@)?([^:/]+):(?!\/)(.+?)(?:\.git)?\/?$/.exec(url)
41 const full = /^[a-z+]+:\/\/(?:[^@/]+@)?([^:/]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/i.exec(url)
42 const m = full ?? scp
43 return m?.[1] && m[2] ? { host: m[1].toLowerCase(), path: m[2] } : undefined
44}
45
46function isGitHub(host: string): boolean {
47 return host === 'github.com' || host === 'ssh.github.com' || host === 'www.github.com'
48}
49
50async function fromGitLab($: EngineInterface): Promise<Fetched> {
51 const ran = await run($, ['glab', 'issue', 'list', '--opened', '-O', 'json', '-P', '100', '--order', 'updated_at'])
52 if (ran.exitCode !== 0) return { error: lastLine(ran.stderr) || `glab exited ${ran.exitCode}` }
53 try {
54 const raw = JSON.parse(ran.stdout) as {
55 iid: number
56 title: string
57 updated_at: string
58 labels?: string[]
59 description?: string | null
60 }[]
61 return {
62 issues: raw.map(i => ({
63 iid: i.iid,
64 title: i.title,
65 updatedAt: i.updated_at,
66 labels: i.labels ?? [],
67 description: i.description ?? '',
68 })),
69 }
70 } catch {
71 return { error: 'could not parse glab output' }
72 }
73}
74
75// `gh` when it is installed and signed in; otherwise the REST API, with GH_TOKEN or GITHUB_TOKEN
76// when set (a public repository needs neither). The REST list carries pull requests too.
77async function fromGitHub($: EngineInterface, repo: string): Promise<Fetched> {
78 const gh = await run($, [
79 'gh', 'issue', 'list', '-R', repo, '--state', 'open', '--limit', '100',
80 '--json', 'number,title,updatedAt,labels,body',
81 ])
82 if (gh.exitCode === 0) {
83 try {
84 const raw = JSON.parse(gh.stdout) as {
85 number: number
86 title: string
87 updatedAt: string
88 labels?: { name: string }[]
89 body?: string | null
90 }[]
91 return {
92 issues: raw.map(i => ({
93 iid: i.number,
94 title: i.title,
95 updatedAt: i.updatedAt,
96 labels: (i.labels ?? []).map(l => l.name),
97 description: i.body ?? '',
98 })),
99 }
100 } catch {
101 return { error: 'could not parse gh output' }
102 }
103 }
104
105 let token = ''
106 for (const name of ['GH_TOKEN', 'GITHUB_TOKEN']) {
107 const env = await run($, ['printenv', name])
108 if (env.exitCode === 0 && env.stdout.trim()) {
109 token = env.stdout.trim()
110 break
111 }
112 }
113 const headers: Record<string, string> = {
114 Accept: 'application/vnd.github+json',
115 'User-Agent': 'claude-code-git-issues',
116 }
117 if (token) headers.Authorization = `Bearer ${token}`
118 const url = `https://api.github.com/repos/${repo}/issues?state=open&per_page=100&sort=updated&direction=desc`
119 const res = await $.http.fetch(url, { headers }).catch((err: unknown) => ({ ok: false, status: 0, text: String(err) }))
120 if (!res.ok) {
121 const hint = token ? '' : ' (a private repository needs `gh auth login` or GH_TOKEN)'
122 return { error: res.status ? `GitHub answered ${res.status} for ${repo}${hint}` : lastLine(res.text) }
123 }
124 try {
125 const raw = JSON.parse(res.text) as {
126 number: number
127 title: string
128 updated_at: string
129 labels?: ({ name: string } | string)[]
130 body?: string | null
131 pull_request?: unknown
132 }[]
133 return {
134 issues: raw
135 .filter(i => i.pull_request === undefined)
136 .map(i => ({
137 iid: i.number,
138 title: i.title,
139 updatedAt: i.updated_at,
140 labels: (i.labels ?? []).map(l => (typeof l === 'string' ? l : l.name)),
141 description: i.body ?? '',
142 })),
143 }
144 } catch {
145 return { error: 'could not parse the GitHub answer' }
146 }
147}
148
149async function refresh($: EngineInterface) {
150 const url = await defaultRemoteUrl($)
151 const remote = url ? parseRemote(url) : undefined
152 const source: IssueSource =
153 remote && isGitHub(remote.host) ? { forge: 'github', repo: remote.path } : { forge: 'gitlab', repo: remote?.path }
154 const got = source.forge === 'github' && source.repo ? await fromGitHub($, source.repo) : await fromGitLab($)
155 const fetchedAt = new Date().toISOString()
156 if ('error' in got) {
157 await update($, list, old => ({ ...old, error: got.error, fetchedAt, source }))
158 return
159 }
160 // Most recently updated first, whichever forge answered.
161 const issues = [...got.issues].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
162 await update($, list, () => ({ issues, fetchedAt, source }))
163}
164
165function forgeName(source?: IssueSource): string {
166 return source?.forge === 'github' ? 'GitHub' : 'GitLab'
167}
168
169function labelsOf(issues: Issue[]): string[] {
170 return [...new Set(issues.flatMap(i => i.labels))].sort((a, b) => a.localeCompare(b))
171}
172
173// Shown: carries at least one included label (when any is ticked) and no excluded label.
174function matches(issue: Issue, { include, exclude }: LabelFilter): boolean {
175 if (include.length > 0 && !include.some(l => issue.labels.includes(l))) return false
176 return !exclude.some(l => issue.labels.includes(l))
177}
178
179// One press moves a label along: ignored -> included -> excluded -> ignored.
180function cycle({ include, exclude }: LabelFilter, name: string): LabelFilter {
181 if (include.includes(name)) {
182 return { include: include.filter(l => l !== name), exclude: [...exclude, name] }
183 }
184 if (exclude.includes(name)) return { include, exclude: exclude.filter(l => l !== name) }
185 return { include: [...include, name], exclude }
186}
187
188function describe({ include, exclude }: LabelFilter): string {
189 const parts = [
190 include.length > 0 && `with ${include.join(' or ')}`,
191 exclude.length > 0 && `without ${exclude.join(', ')}`,
192 ].filter(Boolean)
193 return parts.length > 0 ? ` ${parts.join(', ')}` : ''
194}
195
196// Rows `text` takes at `columns` cells, a line at least one.
197function rowsOf(line: string, columns: number): number {
198 return Math.max(1, Math.ceil(line.length / columns))
199}
200
201// The text cut to `rows` rows of `columns` cells, ending in an ellipsis where it was cut.
202function clip(text: string, rows: number, columns: number): { text: string; isCut: boolean } {
203 const lines = text.replace(/\r\n?/g, '\n').trim().split('\n')
204 const kept: string[] = []
205 let left = rows
206 for (const line of lines) {
207 const need = rowsOf(line, columns)
208 if (need <= left) {
209 kept.push(line)
210 left -= need
211 continue
212 }
213 if (left > 0) {
214 kept.push(`${line.slice(0, left * columns - 1).trimEnd()}…`)
215 } else {
216 // No row left: the ellipsis goes on the last kept line, in a cell of its own when one is free.
217 const last = kept.pop() ?? ''
218 kept.push(`${last.length % columns === 0 && last.length > 0 ? last.slice(0, -1) : last}…`)
219 }
220 return { text: kept.join('\n'), isCut: true }
221 }
222 return { text: kept.join('\n'), isCut: false }
223}
224
225function ago(iso: string): string {
226 const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000)
227 if (s < 60) return 'just now'
228 if (s < 3600) return `${Math.floor(s / 60)}m ago`
229 if (s < 86400) return `${Math.floor(s / 3600)}h ago`
230 return `${Math.floor(s / 86400)}d ago`
231}
232
233export const register: Register = on => {
234 on('session.start', async ($, e, next) => {
235 await $.command.register({
236 name: 'issues',
237 description: 'Show the open issues of this project (GitLab, or GitHub when the default remote is there) in a pane',
238 argumentHint: '[label … -label … | all]',
239 })
240 void refresh($)
241 $.clock.every(REFRESH_MS, () => void refresh($))
242 void $.ui.open({ id: PANE, title: 'Open issues' })
243
244 return next(e)
245 })
246
247 // `/issues` keeps the current filter, `/issues refactoring -nice-to-have` replaces it,
248 // `/issues all` clears it.
249 on('command.run', { command: 'issues' }, async ($, e) => {
250 await $.ui.open({ id: PANE, title: 'Open issues' })
251 await refresh($)
252 const { issues, error, source } = await read($, list)
253 if (error) return { text: `${forgeName(source)} issues: ${error}` }
254
255 const words = e.args.trim().split(/\s+/).filter(Boolean)
256 if (words.length === 1 && words[0]?.toLowerCase() === 'all') {
257 await update($, filter, () => NO_FILTER)
258 } else if (words.length > 0) {
259 const known = labelsOf(issues)
260 const next: LabelFilter = { include: [], exclude: [] }
261 for (const word of words) {
262 const isExcluded = word.startsWith('-')
263 const name = isExcluded ? word.slice(1) : word
264 const found = known.find(l => l.toLowerCase() === name.toLowerCase())
265 if (!found) return { text: `No open issue has the label "${name}".` }
266 ;(isExcluded ? next.exclude : next.include).push(found)
267 }
268 await update($, filter, () => next)
269 }
270 const active = await read($, filter)
271 const shown = issues.filter(i => matches(i, active))
272
273 return { text: `${shown.length} open issues${describe(active)}.` }
274 })
275
276 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
277 const { Box, Text, Button } = $.ui.resolve(e)
278 const { issues: all, error, fetchedAt, source } = await read($, list)
279 const labels = labelsOf(all)
280 const stored = await read($, filter)
281 // Labels no open issue carries any more drop out of the filter.
282 const active: LabelFilter = {
283 include: stored.include.filter(l => labels.includes(l)),
284 exclude: stored.exclude.filter(l => labels.includes(l)),
285 }
286 const isFiltered = active.include.length + active.exclude.length > 0
287 const issues = all.filter(i => matches(i, active))
288 const { iid: openIid, isExpanded } = await read($, preview)
289 // The open issue stays shown while the filter hides it from the list; one that closed drops out.
290 const open = all.find(i => i.iid === openIid)
291
292 // Rows left for the list (and the preview) under the status row, the label toggles and an error.
293 // Word wrap can take a row more than counted here; the pane then scrolls.
294 const columns = Math.max(20, e.props.bodyColumns)
295 const labelRows = labels.length > 0 ? rowsOf(labels.map(l => `[ ] ${l} `).join(''), columns) : 0
296 const room = Math.max(4, e.props.scroll.bodyRows - 1 - labelRows - (error ? 1 : 0))
297 // With a preview open the list keeps the upper half, the preview the lower one.
298 const listRows = open ? Math.max(2, Math.floor(room / 2)) : room
299 const previewRows = room - listRows
300 const fits = issues.length <= listRows
301 const shownCount = fits ? issues.length : Math.max(1, listRows - 1)
302 // Scrolled so the open issue stays in view.
303 const at = open ? issues.indexOf(open) : -1
304 const start = at >= shownCount ? at - shownCount + 1 : 0
305 const shown = issues.slice(start, start + shownCount)
306
307 const toggle = (iid: number) =>
308 void update($, preview, p => (p.iid === iid ? NO_PREVIEW : { iid, isExpanded: false }))
309 const close = () => void update($, preview, () => NO_PREVIEW)
310
311 // The preview's rows: a rule, the title, the labels, the description and its Show more.
312 const descriptionRows = Math.max(2, previewRows - 3 - (open && open.labels.length > 0 ? 1 : 0))
313 const description = open?.description?.trim() ?? ''
314 const clipped = isExpanded
315 ? { text: description, isCut: false }
316 : clip(description, descriptionRows, Math.max(10, columns - 2))
317
318 return (
319 <Box flexDirection="column">
320 <Box flexDirection="row">
321 <Text dimColor>
322 {issues.length} open{isFiltered ? ` of ${all.length}` : ''}
323 {source ? ` · ${forgeName(source)}${source.repo ? ` ${source.repo}` : ''}` : ''}
324 {fetchedAt ? ` · fetched ${ago(fetchedAt)}` : ' · loading…'}{' '}
325 </Text>
326 <Button hotkey="r" onPress={() => void refresh($)}>
327 Refresh
328 </Button>
329 {isFiltered && (
330 <Button hotkey="c" onPress={() => void update($, filter, () => NO_FILTER)}>
331 Clear
332 </Button>
333 )}
334 </Box>
335 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
336 {labels.map(name => {
337 const isIncluded = active.include.includes(name)
338 const isExcluded = active.exclude.includes(name)
339 return (
340 <Button
341 key={`label:${name}`}
342 plain
343 onPress={() => void update($, filter, f => cycle(f, name))}
344 >
345 {isIncluded ? (
346 <Text color="green">[✓]</Text>
347 ) : isExcluded ? (
348 <Text color="red">[✗]</Text>
349 ) : (
350 <Text dimColor>[ ]</Text>
351 )}{' '}
352 {name}
353 </Button>
354 )
355 })}
356 </Box>
357 {error && <Text color="red">{error}</Text>}
358 {shown.map(issue => (
359 <Button key={`issue:${issue.iid}`} plain onPress={() => toggle(issue.iid)}>
360 <Text wrap="truncate-end" inverse={issue.iid === openIid}>
361 <Text bold>#{issue.iid}</Text> {issue.title} <Text dimColor>{ago(issue.updatedAt)}</Text>
362 </Text>
363 </Button>
364 ))}
365 {!fits && <Text dimColor>… {issues.length - shown.length} more</Text>}
366 {all.length > 0 && issues.length === 0 && <Text dimColor>No open issue matches the filter.</Text>}
367 {open && (
368 <Box flexDirection="column">
369 <Text dimColor>{'─'.repeat(columns)}</Text>
370 <Box flexDirection="row" columnGap={1}>
371 <Box flexGrow={1}>
372 <Text bold wrap="truncate-end">
373 #{open.iid} {open.title}
374 </Text>
375 </Box>
376 <Button key="close-preview" hotkey="x" onPress={close}>
377 Close
378 </Button>
379 </Box>
380 {open.labels.length > 0 && (
381 <Text dimColor wrap="truncate-end">
382 {open.labels.join(' · ')}
383 </Text>
384 )}
385 {description ? <Text>{clipped.text}</Text> : <Text dimColor>No description.</Text>}
386 {(clipped.isCut || isExpanded) && (
387 <Button
388 key="expand-preview"
389 plain
390 hotkey="m"
391 onPress={() => void update($, preview, p => ({ ...p, isExpanded: !p.isExpanded }))}
392 >
393 {isExpanded ? 'Show less' : 'Show more'}
394 </Button>
395 )}
396 </Box>
397 )}
398 </Box>
399 )
400 })
401}
402types/index.d.ts 21 lines1/** `description` is absent on a list stored before the mod showed descriptions. */
2export type Issue = { iid: number; title: string; updatedAt: string; labels: string[]; description?: string }
3
4/** Where the issues came from: the forge the default remote is hosted on, and the repository there. */
5export type IssueSource = { forge: 'gitlab' | 'github'; repo?: string }
6
7/** `source` is absent on a list stored before the mod read GitHub. */
8export type IssueList = { issues: Issue[]; error?: string; fetchedAt?: string; source?: IssueSource }
9
10/** Labels an issue must carry one of (none: no constraint) and labels it must not carry. */
11export type LabelFilter = { include: string[]; exclude: string[] }
12
13/** The issue whose description shows under the list (none: closed), and whether it shows whole. */
14export type IssuePreview = { iid?: number; isExpanded: boolean }
15
16declare module 'claude-code' {
17 interface PluginState {
18 'git-issues': { list: IssueList; filter: LabelFilter; preview: IssuePreview }
19 }
20}
21