SLOPSHOPPER

repo-issues

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

newpanecommandstatusprocesstimer
★ 3v0.1.0MITupdated 2026-10-04no1cromo/claude-code-mod-for-github-issues
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repo-issues
│ ┃ GitHub issues ✕ › fix the failing auth test and add an audit log call │ ┃ Couldn't fetch issues: JSON Parse error: │ ┃ Unexpected EOF ⏺ Read(src/auth.ts) │ ┃ Check that `gh auth status` works in this ⎿ Read 6 lines │ ┃ folder. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ Refresh ] ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /issues │ ⎿ repo-issues: Couldn't fetch GitHub issues: JSON Parse error: Une │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · GitHub issues
Couldn't fetch issues: JSON Parse error: Unexpected EOF Check that `gh auth status` works in this folder. [ Refresh ]
README

Claude Code mod for GitHub issues

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.

What you need

  • Claude Code 2.1.289 or later. This is a mod built on Claude Code's function-hook plugin API, which is marked early access: a future Claude Code release may change it and break the mod.
  • The GitHub CLI, 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.

Install

Inside Claude Code, run these one at a time (paste one, press Enter, then the next; pasted together they arrive as a single command):

  1. Add this repo as a plugin source:
   /plugin marketplace add https://github.com/no1cromo/claude-code-mod-for-github-issues
  1. Install the mod from it:
   /plugin install repo-issues@repo-issues
  1. Type /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

Use

  • /issues opens the pane and fetches fresh numbers.
  • In a GitHub repo, the pane also opens by itself at start when the terminal is wide enough to dock it (144 columns); it stays shut in folders that are not GitHub repos.
  • It refreshes every 5 minutes; press r in the pane (or click Refresh) to refresh now. The status line shows the total too.

How it counts

  • Type is the issue's GitHub issue type. An issue without one counts as a Bug if it has a 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.
  • People: an issue with two assignees counts once for each. The eight people with the most issues get a row and a colour; everyone else shares one "others" row.
  • Large repos: the headline is always the true total, but the breakdown covers the 1,000 most recently updated open issues, so a repo with tens of thousands of issues still refreshes in seconds.
  • Forks: gh picks the repo, which for a fork with an upstream remote is usually the upstream one. The pane shows which repo it counted.
  • Pull requests are not counted.

Limits

  • github.com only (not GitHub Enterprise Server).
  • Nothing leaves your machine except the gh calls to GitHub's API.

Develop

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.

License

MIT

Source 3 files
hooks/register.tsx 241 lines
1import { 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}
241
hooks/summary.ts 192 lines
1import 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}
192
types/index.d.ts 33 lines
1/** 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