A pane counting this repo's open GitHub issues, coloured by issue type and by who they are assigned to

A Claude Code mod that keeps a live count of the open GitHub issues in whatever repo you are working in, colour-coded by issue type and by who they are assigned to.
214 open issues [ Refresh ]
acme/widgets · updated 14:02
By type
████████████████████████████████████████████████
■ Task 38 18%
■ Bug 61 29%
■ Feature 70 33%
■ No type 45 21%
By person
████████████████████████████████████████████████
■ alice 52 ███████████████
■ bob 31 █████████
■ carol 12 ████
■ Unassigned 119 █████████████████████████████████
Types use GitHub's own colours (Bug red, Feature blue, Task yellow, and so on); each person gets a colour of their own, and their bar is split by issue type.
gh, logged in (gh auth status should say so). The mod asks gh for the issues, so it sees exactly the repos your gh login can see, private ones included, and needs no token of its own.Inside Claude Code, run these one at a time (paste one, press Enter, then the next; pasted together they arrive as a single command):
/plugin marketplace add https://github.com/no1cromo/claude-code-mod-for-github-issues
/plugin install repo-issues@repo-issues
/issues. If Claude Code does not know it yet, run /reload-plugins or start a new session.If step 2 says Plugin "repo-issues" not found in marketplace "repo-issues", step 1 has not run yet.
The same two steps from a terminal, outside Claude Code:
claude plugin marketplace add https://github.com/no1cromo/claude-code-mod-for-github-issues
claude plugin install repo-issues@repo-issues
Or from a clone, for one session:
claude --plugin-dir /path/to/claude-code-mod-for-github-issues
/issues opens the pane and fetches fresh numbers.bug label, or a Feature if it has an enhancement label (GitHub's default labels); otherwise it is "No type". So the numbers can be higher than GitHub's own issue-type filter shows.gh picks the repo, which for a fork with an upstream remote is usually the upstream one. The pane shows which repo it counted.gh calls to GitHub's API.claude plugin validate .
claude plugin test .
hooks/register.tsx fetches and draws the pane; hooks/summary.ts does the counting and bar widths; tests/ runs against a fake gh, so tests never call GitHub.
MIT
hooks/register.tsx 241 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5import { allocate, parsePages, summarise } from './summary'
6import type { Slice } from './summary'
7
8const PANE = 'repo-issues'
9const TITLE = 'GitHub issues'
10const REFRESH_MS = 5 * 60_000
11/** 10 pages of 100: a fair picture of any repo in seconds, where all of a huge one takes minutes. */
12const MAX_PAGES = 10
13const ERROR_COLOR = '#f85149'
14
15const QUERY = `query($owner: String!, $name: String!, $endCursor: String) {
16 repository(owner: $owner, name: $name) {
17 issueTypes(first: 25) { nodes { name color } }
18 issues(states: OPEN, first: 100, after: $endCursor, orderBy: { field: UPDATED_AT, direction: DESC }) {
19 totalCount
20 pageInfo { hasNextPage endCursor }
21 nodes {
22 number
23 issueType { name }
24 labels(first: 20) { nodes { name } }
25 assignees(first: 10) { nodes { login } }
26 }
27 }
28 }
29}`
30
31const snapshot = atom({ plugin: 'repo-issues', key: 'snapshot' } as const, null)
32const lastError = atom({ plugin: 'repo-issues', key: 'lastError' } as const, null)
33const isLoading = atom({ plugin: 'repo-issues', key: 'isLoading' } as const, false)
34
35function firstLine(text: string): string {
36 return text.trim().split('\n')[0] ?? ''
37}
38
39function clockTime(ms: number): string {
40 const at = new Date(ms)
41 return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
42}
43
44type PageInfo = { data?: { repository?: { issues?: { pageInfo?: { hasNextPage: boolean; endCursor: string | null } } } } }
45
46/** Asks the `gh` CLI (already logged in on this machine) for the repo's open issues. */
47async function fetchSnapshot($: EngineInterface): Promise<Snapshot> {
48 const view = await $.process.run(['gh', 'repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'])
49 if (view.exitCode !== 0) throw new Error(firstLine(view.stderr) || 'this folder is not a GitHub repo')
50 const repo = view.stdout.trim()
51 const [owner = '', name = ''] = repo.split('/')
52
53 const pages: unknown[] = []
54 let cursor: string | null = null
55 for (let page = 0; page < MAX_PAGES; page++) {
56 const argv = ['gh', 'api', 'graphql', '-f', `query=${QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`]
57 if (cursor !== null) argv.push('-f', `endCursor=${cursor}`)
58 const result = await $.process.run(argv, { timeoutMs: 60_000 })
59 if (result.exitCode !== 0) throw new Error(firstLine(result.stderr) || `gh exited with code ${result.exitCode}`)
60
61 const parsed = JSON.parse(result.stdout) as PageInfo
62 pages.push(parsed)
63 const info = parsed.data?.repository?.issues?.pageInfo
64 if (!info?.hasNextPage || !info.endCursor) break
65 cursor = info.endCursor
66 }
67
68 return parsePages(repo, pages, await $.clock.now())
69}
70
71// One fetch at a time; a module variable, so a reload never leaves it stuck.
72let inFlight: Promise<void> | null = null
73
74async function runRefresh($: EngineInterface): Promise<void> {
75 await update($, isLoading, () => true)
76 try {
77 const fresh = await fetchSnapshot($)
78 await update($, snapshot, () => fresh)
79 await update($, lastError, () => null)
80 $.ui.status(`GitHub: ${fresh.totalOpen} open issues`)
81 } catch (err) {
82 const message = err instanceof Error ? err.message : String(err)
83 await update($, lastError, () => message)
84 } finally {
85 await update($, isLoading, () => false)
86 inFlight = null
87 }
88}
89
90function refresh($: EngineInterface): Promise<void> {
91 inFlight ??= runRefresh($)
92
93 return inFlight
94}
95
96/** Opens the pane by itself only in a folder whose issues it could read. */
97async function firstLoad($: EngineInterface): Promise<void> {
98 await refresh($)
99 if ((await read($, lastError)) === null && (await read($, snapshot)) !== null) {
100 await $.ui.open({ id: PANE, title: TITLE })
101 }
102}
103
104export const register: Register = on => {
105 on('session.start', async ($, e, next) => {
106 await $.command.register({
107 name: 'issues',
108 description: "Show this repo's open GitHub issues by type and by person",
109 })
110 await update($, isLoading, () => false)
111
112 if (e.isInteractive) {
113 $.clock.after(1, () => void firstLoad($))
114 $.clock.every(REFRESH_MS, () => void refresh($))
115 }
116
117 return next(e)
118 })
119
120 on('command.run', { command: 'issues' }, async $ => {
121 await $.ui.open({ id: PANE, title: TITLE })
122 await refresh($)
123 const [fresh, error] = await Promise.all([read($, snapshot), read($, lastError)])
124
125 if (error !== null) return { text: `Couldn't fetch GitHub issues: ${error}` }
126 if (fresh === null) return { text: 'No GitHub issues fetched yet.' }
127
128 return { text: `${fresh.totalOpen} open issues in ${fresh.repo}.` }
129 })
130
131 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
132 const { Box, Text, Button } = $.ui.resolve(e)
133 const [fresh, error, loading] = await Promise.all([read($, snapshot), read($, lastError), read($, isLoading)])
134 const width = Math.max(24, e.props.bodyColumns)
135
136 const bar = (slices: Slice[], cells: number) => {
137 const widths = allocate(
138 slices.map(slice => slice.count),
139 cells,
140 )
141
142 return (
143 <Box flexDirection="row">
144 {slices.map((slice, i) => (widths[i] ?? 0) > 0 && <Text color={slice.color}>{'█'.repeat(widths[i] ?? 0)}</Text>)}
145 </Box>
146 )
147 }
148
149 const refreshButton = (
150 <Button key="refresh" hotkey="r" label={loading ? 'Refreshing…' : 'Refresh'} onPress={() => refresh($)} />
151 )
152 const errorLine = error !== null && (
153 <Text color={ERROR_COLOR} wrap="wrap">
154 Couldn't fetch issues: {error}
155 </Text>
156 )
157
158 if (fresh === null) {
159 return (
160 <Box flexDirection="column">
161 {error === null && <Text dimColor>Fetching open issues from GitHub…</Text>}
162 {errorLine}
163 {error !== null && <Text dimColor>Check that `gh auth status` works in this folder.</Text>}
164 <Box marginTop={1}>{refreshButton}</Box>
165 </Box>
166 )
167 }
168
169 const summary = summarise(fresh)
170 const typeNameWidth = Math.max(...summary.byType.map(slice => slice.name.length), 4)
171 const personNameWidth = Math.min(16, Math.max(...summary.byPerson.map(row => row.name.length), 4))
172 const countWidth = String(summary.total).length
173 const personBarWidth = Math.max(4, width - (2 + personNameWidth + 1 + countWidth + 1))
174 const largestPerson = Math.max(1, ...summary.byPerson.map(row => row.count))
175
176 return (
177 <Box flexDirection="column">
178 <Box flexDirection="row" justifyContent="space-between">
179 <Text bold>{fresh.totalOpen} open issues</Text>
180 {refreshButton}
181 </Box>
182 <Text dimColor wrap="truncate">
183 {fresh.repo} · updated {clockTime(fresh.fetchedAt)}
184 </Text>
185 {errorLine}
186 {fresh.totalOpen > summary.total && (
187 <Text dimColor wrap="wrap">
188 The breakdown below covers the {summary.total} most recently updated.
189 </Text>
190 )}
191
192 {summary.total === 0 ? (
193 <Box marginTop={1}>
194 <Text dimColor>No open issues.</Text>
195 </Box>
196 ) : (
197 <Box flexDirection="column">
198 <Box marginTop={1}>
199 <Text bold>By type</Text>
200 </Box>
201 {bar(summary.byType, width)}
202 {summary.byType.map(slice => (
203 <Box flexDirection="row">
204 <Text color={slice.color}>■ </Text>
205 <Text>{slice.name.padEnd(typeNameWidth)} </Text>
206 <Text bold>{String(slice.count).padStart(countWidth)}</Text>
207 <Text dimColor> {String(Math.round((slice.count / summary.total) * 100)).padStart(3)}%</Text>
208 </Box>
209 ))}
210
211 <Box marginTop={1}>
212 <Text bold>By person</Text>
213 </Box>
214 {bar(summary.byPerson, width)}
215 {summary.byPerson.map(row => {
216 const typesHeld = row.byType.filter(slice => slice.count > 0).length
217 const cells = Math.max(typesHeld, Math.round((row.count / largestPerson) * personBarWidth))
218
219 return (
220 <Box flexDirection="row">
221 <Text color={row.color} wrap="truncate">
222 ■ {row.name.slice(0, personNameWidth).padEnd(personNameWidth)}{' '}
223 </Text>
224 <Text bold>{String(row.count).padStart(countWidth)} </Text>
225 {bar(row.byType, cells)}
226 </Box>
227 )
228 })}
229 <Text dimColor wrap="wrap">
230 Each person's bar is split by issue type, in the colours above.
231 {summary.sharedCount > 0
232 ? ` ${summary.sharedCount} issue${summary.sharedCount === 1 ? ' has' : 's have'} more than one person, so ${summary.sharedCount === 1 ? 'it counts' : 'they count'} for each.`
233 : ''}
234 </Text>
235 </Box>
236 )}
237 </Box>
238 )
239 })
240}
241hooks/summary.ts 192 lines1import type { IssueTypeInfo, SlimIssue, Snapshot } from '../types'
2
3export const NO_TYPE = 'No type'
4export const UNASSIGNED = 'Unassigned'
5
6const NOTHING_SET = '#8b949e'
7
8/** GitHub's issue-type colour names, as hex. */
9const GITHUB_COLORS: Record<string, string> = {
10 GRAY: NOTHING_SET,
11 BLUE: '#4493f8',
12 GREEN: '#3fb950',
13 YELLOW: '#d29922',
14 ORANGE: '#db6d28',
15 RED: '#f85149',
16 PINK: '#db61a2',
17 PURPLE: '#ab7df8',
18}
19
20/** Used for a label-derived type the repo does not define itself. */
21const DEFAULT_TYPE_COLORS: Record<string, string> = { Bug: 'RED', Feature: 'BLUE', Task: 'YELLOW' }
22
23/** Picked to stay clear of the red / blue / yellow the issue types use. */
24const PERSON_COLORS = ['#2dd4bf', '#e879f9', '#a3e635', '#fb923c', '#818cf8', '#22d3ee', '#f472b6', '#c084fc']
25/** Past one colour each, people are grouped into one "others" row. */
26const OTHERS_COLOR = '#c9a26b'
27
28export type Slice = { name: string; color: string; count: number }
29export type PersonRow = Slice & { byType: Slice[] }
30export type Summary = {
31 total: number
32 byType: Slice[]
33 byPerson: PersonRow[]
34 /** Issues with more than one assignee: they count once per person. */
35 sharedCount: number
36}
37
38type GraphqlPage = {
39 data?: {
40 repository?: {
41 issueTypes?: { nodes?: ({ name: string; color: string } | null)[] } | null
42 issues: {
43 totalCount?: number
44 nodes: ({
45 number: number
46 issueType?: { name: string } | null
47 labels?: { nodes?: ({ name: string } | null)[] } | null
48 assignees?: { nodes?: ({ login: string } | null)[] } | null
49 } | null)[]
50 }
51 } | null
52 }
53 errors?: { message: string }[]
54}
55
56/** The type an issue counts under: GitHub's own field first, then the two default labels. */
57function typeOf(issueType: string | undefined, labels: string[]): string {
58 if (issueType) return issueType
59 const lower = labels.map(label => label.toLowerCase())
60 if (lower.includes('bug')) return 'Bug'
61 if (lower.includes('enhancement')) return 'Feature'
62 return NO_TYPE
63}
64
65/** Reads the GraphQL responses, one per page of up to 100 issues. */
66export function parsePages(repo: string, responses: unknown[], fetchedAt: number): Snapshot {
67 const pages = responses as GraphqlPage[]
68 const firstError = pages.flatMap(page => page.errors ?? [])[0]
69 if (firstError) throw new Error(firstError.message)
70
71 const types: IssueTypeInfo[] = []
72 const issues: SlimIssue[] = []
73 for (const page of pages) {
74 const repository = page.data?.repository
75 if (!repository) throw new Error(`GitHub returned no repository for ${repo}`)
76 for (const node of repository.issueTypes?.nodes ?? []) {
77 if (node && !types.some(type => type.name === node.name)) types.push(node)
78 }
79 for (const node of repository.issues.nodes) {
80 if (!node) continue
81 const labels = (node.labels?.nodes ?? []).flatMap(label => (label ? [label.name] : []))
82 issues.push({
83 number: node.number,
84 type: typeOf(node.issueType?.name, labels),
85 assignees: (node.assignees?.nodes ?? []).flatMap(person => (person ? [person.login] : [])),
86 })
87 }
88 }
89
90 const totalOpen = pages[0]?.data?.repository?.issues.totalCount ?? issues.length
91
92 return { repo, fetchedAt, totalOpen, issues, types }
93}
94
95function typeColor(name: string, types: IssueTypeInfo[]): string {
96 if (name === NO_TYPE) return NOTHING_SET
97 const named = types.find(type => type.name === name)?.color ?? DEFAULT_TYPE_COLORS[name] ?? 'GRAY'
98 return GITHUB_COLORS[named] ?? NOTHING_SET
99}
100
101function countBy(names: string[]): Map<string, number> {
102 const counts = new Map<string, number>()
103 for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1)
104 return counts
105}
106
107export function summarise(snapshot: Snapshot): Summary {
108 const { issues, types } = snapshot
109
110 // The repo's own type order, then any other type, then "No type" last.
111 const typeCounts = countBy(issues.map(issue => issue.type))
112 const typeOrder = [
113 ...types.map(type => type.name).filter(name => typeCounts.has(name)),
114 ...[...typeCounts.keys()].filter(name => name !== NO_TYPE && !types.some(type => type.name === name)).sort(),
115 ...(typeCounts.has(NO_TYPE) ? [NO_TYPE] : []),
116 ]
117 const slicesOf = (subset: SlimIssue[]): Slice[] => {
118 const counts = countBy(subset.map(issue => issue.type))
119 return typeOrder.map(name => ({ name, color: typeColor(name, types), count: counts.get(name) ?? 0 }))
120 }
121
122 // The people with the most issues get a row and a colour each; colours go
123 // in alphabetical order so they stay put between refreshes.
124 const perPerson = countBy(issues.flatMap(issue => issue.assignees))
125 const shown = [...perPerson.keys()]
126 .sort((a, b) => (perPerson.get(b) ?? 0) - (perPerson.get(a) ?? 0) || a.localeCompare(b))
127 .slice(0, PERSON_COLORS.length)
128 const colorOf = new Map(
129 [...shown].sort((a, b) => a.localeCompare(b)).map((login, i) => [login, PERSON_COLORS[i] ?? NOTHING_SET]),
130 )
131 const byPerson: PersonRow[] = shown.map(login => {
132 const theirs = issues.filter(issue => issue.assignees.includes(login))
133 return { name: login, color: colorOf.get(login) ?? NOTHING_SET, count: theirs.length, byType: slicesOf(theirs) }
134 })
135 const othersCount = perPerson.size - shown.length
136 if (othersCount > 0) {
137 const theirs = issues.filter(issue => issue.assignees.some(login => !colorOf.has(login)))
138 byPerson.push({
139 name: `${othersCount} other${othersCount === 1 ? '' : 's'}`,
140 color: OTHERS_COLOR,
141 count: theirs.length,
142 byType: slicesOf(theirs),
143 })
144 }
145 const unassigned = issues.filter(issue => issue.assignees.length === 0)
146 if (unassigned.length > 0) {
147 byPerson.push({ name: UNASSIGNED, color: NOTHING_SET, count: unassigned.length, byType: slicesOf(unassigned) })
148 }
149
150 return {
151 total: issues.length,
152 byType: slicesOf(issues),
153 byPerson,
154 sharedCount: issues.filter(issue => issue.assignees.length > 1).length,
155 }
156}
157
158/**
159 * Splits `width` cells between the counts in proportion, every non-zero count
160 * getting at least one cell so no colour disappears from a bar.
161 */
162export function allocate(counts: number[], width: number): number[] {
163 const total = counts.reduce((sum, count) => sum + count, 0)
164 if (total === 0 || width <= 0) return counts.map(() => 0)
165
166 const exact = counts.map(count => (count / total) * width)
167 const cells = exact.map((cell, i) => ((counts[i] ?? 0) > 0 ? Math.max(1, Math.floor(cell)) : 0))
168 let left = width - cells.reduce((sum, cell) => sum + cell, 0)
169
170 const byRemainder = counts
171 .map((count, i) => ({ i, count, remainder: (exact[i] ?? 0) - Math.floor(exact[i] ?? 0) }))
172 .filter(entry => entry.count > 0)
173 .sort((a, b) => b.remainder - a.remainder)
174 for (let k = 0; left > 0 && byRemainder.length > 0; k++) {
175 const entry = byRemainder[k % byRemainder.length]
176 if (entry) cells[entry.i] = (cells[entry.i] ?? 0) + 1
177 left--
178 }
179 // The one-cell minimum can overshoot: take back from the widest segments.
180 while (left < 0) {
181 let widest = -1
182 cells.forEach((cell, i) => {
183 if (cell > 1 && (widest < 0 || cell > (cells[widest] ?? 0))) widest = i
184 })
185 if (widest < 0) break
186 cells[widest] = (cells[widest] ?? 0) - 1
187 left++
188 }
189
190 return cells
191}
192types/index.d.ts 33 lines1/** One open issue, cut down to what the pane counts. */
2export type SlimIssue = {
3 number: number
4 /** GitHub's issue type, else one read off a `bug` / `enhancement` label, else "No type". */
5 type: string
6 /** GitHub logins; empty when nobody is assigned. */
7 assignees: string[]
8}
9
10/** An issue type the repo defines, with GitHub's colour name for it (RED, BLUE, ...). */
11export type IssueTypeInfo = { name: string; color: string }
12
13export type Snapshot = {
14 /** owner/name */
15 repo: string
16 fetchedAt: number
17 /** Every open issue in the repo, as GitHub counts them. */
18 totalOpen: number
19 /** The most recently updated open issues, capped: `totalOpen` can be larger. */
20 issues: SlimIssue[]
21 types: IssueTypeInfo[]
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'repo-issues': {
27 snapshot: Snapshot | null
28 lastError: string | null
29 isLoading: boolean
30 }
31 }
32}
33