SLOPSHOPPER

pr-overview

/pr-overview: the open PRs in this repo you're involved in, with new activity marked, CI and merge conflicts

newpanecommandtoastprocesstimer
★ 2v0.1.1no licenseupdated 2026-10-21pthexton/claude-sidebar-mod/pr-overview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-overview
│ ┃ PRs ✕ › fix the failing auth test and add an audit log call │ ┃ PRs [ refresh ] │ ┃ Loading… ⏺ Read(src/auth.ts) │ ┃ Tab moves · Enter opens · r refreshes · Esc ⎿ Read 6 lines │ ┃ closes ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /pr-overview │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PRs
PRs [ refresh ] Loading… Tab moves · Enter opens · r refreshes · Esc closes
README

claude-sidebar-mod

A Claude Code plugin marketplace with three plugins, installed separately:

  • sidebar: a pane docked beside the transcript that shows the session's model, usage, workspace, CI and tasks. It replaces a multi-line status line, so the prompt box stays in one place.
  • pr-overview: /pr-overview lists the open PRs in the current repo that you're involved in, marks the ones with activity you haven't seen, and opens one in the browser. See PR overview.
  • rose-pine: Rosé Pine colour themes for Claude Code itself. See Rosé Pine themes.

The rest of this page is about the sidebar until those sections. What changed in each version is in CHANGELOG.md.

It's built on Claude Code's function-hooks plugin API, which is early access and may change between releases.

Install

At a Claude Code prompt:

/plugin install sidebar --marketplace pthexton/claude-sidebar-mod

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

From a local clone instead:

git clone https://github.com/pthexton/claude-sidebar-mod.git
claude plugin marketplace add /path/to/claude-sidebar-mod
claude plugin install sidebar@pt-mods --scope user

A marketplace added from a folder is read from that folder, so edits there reach new sessions without reinstalling (/reload-plugins for running ones).

Required setup

The sidebar only docks beside the transcript in the fullscreen layout. Add to ~/.claude/settings.json:

{ "tui": "fullscreen" }

(or run /tui and pick fullscreen). The pane opens by itself at 144 terminal columns or wider. Below that, run /sidebar to open it (from 110 columns). Drag its edge to resize it; Claude Code remembers the width.

Sections, and what each needs

SectionShowsNeeds
Modelmodel, context bar, 5h / 7d rate-limit bars (subscriptions only), prompt-cache chipnothing; see the two optional extras below for a fuller cache chip
Sessioncost, elapsed time, API time, effort, session id with a copy buttonnothing
Workspacerepo (and (wt) in a worktree), cwd, project root, branch, added dirsgit on PATH; added dirs need the status line feed
CIthe branch's PR, check counts, merge conflicts with the base (in red, with a toast when they first appear), failing and pending checks, refresh and open-PR buttons. Re-checked every 5 minutes once CI has finished, since the base can move ongh, signed in (gh auth login). Hidden when there's no PR or gh can't answer
TasksClaude's own task list (TodoWrite, TaskCreate, TaskUpdate) for the main conversationnothing
Costthe billing discount in use, the session cost at list price, and where to change the discount; with £ shown, the exchange rate in use. Hidden when the discount is 0 and £ is offnothing

Billing discount

Costs are shown after a discount off Anthropic's published prices, 9% by default. That applies to the session cost (Claude Code's own figure, scaled) and to the cache rebuild cost. Both are marked *, and the Cost section at the bottom shows the discount and the session cost at list price.

To change it, open /config and set Billing discount (%) under the sidebar plugin. Set it to 0 to see list prices; the Cost section then disappears.

Pounds (£)

To see costs in pounds as well as dollars, open /config and switch on GBP rate (£) under the sidebar plugin, or run /gbp (run it again to switch £ off). It's off by default. Dollars stay first, since Anthropic bills in dollars: the session cost reads $1.12* £0.85* (both starred while the billing discount is on), and the cache chip adds £ when the pane is wide enough for it.

The rate is the European Central Bank's daily reference rate, fetched from Frankfurter (free, no key) and shared by every session. The ECB publishes it once a working day, at about 3pm UK time. The saved rate is fetched again once it's 6 hours old, so that's at most 4 small requests a day, and each session checks hourly, so one left open all day picks up the new rate. The Cost section shows the rate in use and its date: £ rate: 0.7549 (ECB, 13th Oct). If the fetch fails, the last rate fetched is kept and it's tried again an hour later, once for all sessions; with no rate saved, no £ figure is shown. Nothing is fetched while the setting is off.

Commands: /sidebar shows or hides the pane (its close mark ignores clicks, so a stray one can't close it), /gbp switches £ on or off, /ci-refresh fetches CI now, /open-pr opens the PR in your browser (open on macOS, xdg-open on Linux, rundll32 url.dll,FileProtocolHandler on Windows).

Optional extras

Status line feed: fast mode, real cache TTL, added dirs

Mods can't read some of what Claude Code gives a status line command. sidebar/statusline-feed.sh is a status line command that saves that input per session to ~/.claude/state/statusline/<session_id>.json for the sidebar, and prints nothing, so no status line is drawn under the prompt. It needs jq. Point your status line at it in ~/.claude/settings.json:

{
  "statusLine": { "type": "command", "command": "/path/to/claude-sidebar-mod/sidebar/statusline-feed.sh" }
}

On Windows, use sidebar/statusline-feed.ps1 instead, which needs neither bash nor jq. Use forward slashes in the path, since Claude Code runs status line commands through Git Bash when it's installed:

{
  "statusLine": { "type": "command", "command": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"C:/path/to/claude-sidebar-mod/sidebar/statusline-feed.ps1\"" }
}

There's only one status line, so this replaces any you already have. To keep another tool's status line too, pass its program with -Then (both feeds take it): the feed saves the input first, then hands the same input to that program and prints what it prints. Starting PowerShell can take a second or more, and Claude Code cancels a status line run that's still going when the next update is due, so a long-running -Then program may not finish during a busy turn.

Orca users need nothing extra. Orca installs a status line of its own, for its usage display. In an Orca terminal, the feed hands the same input to Orca's status line script (~/.orca/agent-hooks/claude-statusline.cmd on Windows, claude-statusline.sh elsewhere) when it's installed, as Orca's own status line would. Outside Orca terminals, it doesn't. Orca installs that script only while the status line is empty or Orca's own, so if you set up the feed before installing Orca, there's no script to hand on to, and Orca gets its usage figures another way. Orca's script passes the input to Orca only when it holds rate limits (rate_limits), which Claude Code gives the status line for claude.ai Pro and Max subscribers and behind a Claude apps gateway with spend limits; for other accounts it sends Orca nothing, feed or no feed. Don't name Orca's script in your status line, with -Then or otherwise: Orca takes a status line that names its script as its own, and replaces it with Orca's next time Orca starts, which turns the feed off. If that's happened, point the status line at the feed again.

With it, the sidebar shows:

  • a red FAST MODE ON line (and a toast) when fast mode is on, since it bills at a higher rate;
  • the cache chip's real TTL, expiry and rebuild size;
  • effort from the start of the session;
  • elapsed time and API time including time before a resume;
  • added dirs.

Without it, fast mode comes from the fastMode setting, and the cache chip assumes a 1-hour TTL from the last response. Either way, after a --resume the cache chip stays hidden until the first reply.

Pricing file: cache rebuild cost in dollars

The cache chip always shows the rebuild size in tokens. To also show what a cold cache would cost to rebuild (↻$0.37 (45.9k), yellow from $1, red from $5), create ~/.claude/state/model-pricing.tsv. Without it, or for a model it doesn't list, no dollar figure is shown. The figure has the billing discount taken off.

It's tab separated, USD per million tokens, one row per model. Lines starting with # and the header row are skipped:

model_id	input	write_5m	write_1h	cache_read	output	fast_input
claude-opus-5-5	4	5	8	0.2	20	8
claude-haiku-4-5	1	1.25	2	0.1	5
  • model_id is matched by longest prefix, so dated or suffixed ids (claude-haiku-4-5-20251001, claude-opus-5-5[1m]) find their row.
  • write_5m / write_1h are the cache-write prices for each TTL.
  • fast_input is the fast-mode input price, or empty for a model without fast mode. In fast mode the write price is scaled by fast_input / input.
  • cache_read and output aren't used yet, but keep the columns in that order.

Take the figures from Anthropic's pricing page, and update them when prices change.

Keys for CI

Claude Code can't bind function keys, but it can bind chords to the CI commands. In ~/.claude/keybindings.json:

{
  "bindings": [
    {
      "context": "Chat",
      "bindings": { "ctrl+x r": "command:ci-refresh", "ctrl+x o": "command:open-pr" }
    }
  ]
}

To use F5 / F6 anyway, have your terminal send those chords: in Ghostty, keybind = f5=text:\x18r and keybind = f6=text:\x18o; in macOS Terminal, Settings > Profiles > Keyboard, Send Text \030r and \030o.

PR overview

/pr-overview opens a pane listing the open pull requests in the session's repo that you're involved in, most recently updated first. Run it again, or press Escape, to close it. Install:

/plugin install pr-overview --marketplace pthexton/claude-sidebar-mod

It needs gh, logged in (gh auth login). The repo is the one gh picks for the session's folder.

acme/app  updated 09:12  [ refresh ]
● #123  Fix token refresh on expiry            ✓  2h
  #118  Add Orca handover                      ✗ conflict  1d
  • Involved means you opened it, commented on or reviewed it, were asked to review it, wrote one of its commits, or were @-mentioned in it. Team review requests don't count. GitHub's search finds them, however many PRs the repo has open, except commits pushed to someone else's PR: for those it checks the last 50 commits of the 50 most recently updated open PRs. Closed and merged PRs are never listed.
  • ● marks a PR with activity since you last opened it from the list, or one you haven't opened from it yet. Your own activity (your comment, review or push) doesn't mark it. The marks are shared by every session, and a PR's entry is dropped once it's closed or merged.
  • CI is the last commit's checks: ✓ passing, ✗ failing, … running, blank with no checks. conflict shows when GitHub reports a merge conflict. Drafts are dim.
  • Keys: Tab moves between PRs, Enter opens one (gh pr view --web), r refreshes, Escape closes.
  • It refreshes when opened, then every 30 minutes while it's open. Change that in /config: Refresh interval (minutes).

Rosé Pine themes

Three Claude Code themes built from the official Rosé Pine palette: Rosé Pine, Rosé Pine Moon (both dark) and Rosé Pine Dawn (light). Install:

/plugin install rose-pine --marketplace pthexton/claude-sidebar-mod

then pick one in /theme, where it's marked as from rose-pine. To tweak one, edit it in /theme (Ctrl+E): Claude Code saves your edit as a copy in ~/.claude/themes/, since plugin themes are read-only.

The colours follow Rosé Pine's role guide: rose is the accent, love marks errors and removed lines, gold warnings and fast mode, foam success and added lines, iris permissions and merges, and the highlight shades the prompt border, selection and your messages.

They colour the terminal UI only. The desktop app's Code tab keeps its own colours, and so does your terminal: for the sidebar's colours (which use the terminal's ANSI palette) to match, give the terminal the Rosé Pine theme as well (Ghostty: theme = Rose Pine).

rose-pine/themes/*.json is generated: change rose-pine/build.rb and run ruby rose-pine/build.rb.

Developing

claude plugin validate rose-pine
claude plugin validate sidebar
claude plugin test sidebar

claude plugin test runs the mod's tests, but its test sandbox can't start programs, so the status line feeds have their own: bash sidebar/tests/statusline-feed.test.sh (macOS and Linux, needs jq) and powershell -NoProfile -ExecutionPolicy Bypass -File sidebar\tests\statusline-feed.test.ps1 (Windows). GitHub runs them on Linux, macOS and Windows whenever a feed or its tests change (.github/workflows/statusline-feed.yml).

Claude Code writes the API types into sidebar/.claude-plugin/types/ when it loads the mod (git ignores them), after which npx -p typescript@5 tsc -p sidebar type-checks it. To run a working copy for one session without installing it: claude --plugin-dir sidebar.

Source 7 files
hooks/register.tsx 233 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Pr } from '../types'
5import { clockTime, firstLine, rowText, toMinutes } from './format'
6import { isOwnedBy } from './owner'
7import { parseReply, queryArgs } from './query'
8import { SEEN_STORE_KEY, isNew, isSameSeen, markSeen, reconcile, toSeen } from './seen'
9
10// /pr-overview: a pane listing the open PRs in the session's repo that the
11// person is involved in, newest activity first, marking those with activity
12// since they last opened them from the list. Enter opens one in the browser.
13// See README.md.
14
15const PANE = 'pr-overview'
16const CMD = 'pr-overview'
17const PANE_COLUMNS = 72
18const PANE_ROWS = 14
19
20// A busy repo's query takes 10s or more: it's off every hot path.
21const GH_TIMEOUT_MS = 60_000
22const OPEN_TIMEOUT_MS = 15_000
23
24const overview = atom({ plugin: 'pr-overview', key: 'overview' } as const, null)
25const isFetching = atom({ plugin: 'pr-overview', key: 'isFetching' } as const, false)
26const seen = atom({ plugin: 'pr-overview', key: 'seen' } as const, {})
27const login = atom({ plugin: 'pr-overview', key: 'login' } as const, null)
28const intervalChange = atom({ plugin: 'pr-overview', key: 'intervalChange' } as const, null)
29
30const gh = async ($: EngineInterface, args: string[], timeoutMs = GH_TIMEOUT_MS) =>
31  $.process.run(['gh', ...args], { cwd: await $.session.cwd(), timeoutMs })
32
33const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err))
34
35// A failed refresh keeps the last good list, with the reason above it.
36const fail = ($: EngineInterface, error: string) =>
37  update($, overview, ov => ({ repo: ov?.repo, prs: ov?.prs ?? [], fetchedAt: ov?.fetchedAt ?? 0, error }))
38
39const lookUpLogin = async ($: EngineInterface) => {
40  const known = await read($, login)
41  if (known !== null) return known
42  const user = await gh($, ['api', 'user', '--jq', '.login'])
43  const name = user.stdout.trim()
44  if (user.exitCode !== 0 || name === '') return undefined
45  await update($, login, () => name)
46  return name
47}
48
49const refresh = async ($: EngineInterface) => {
50  if (await read($, isFetching)) return
51  await update($, isFetching, () => true)
52  try {
53    // gh's own pick of the repo, so a clone with several remotes goes where gh does.
54    const view = await gh($, ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'])
55    const repo = view.stdout.trim()
56    if (view.exitCode !== 0 || repo === '') return await fail($, firstLine(view.stderr, 'Not a GitHub repo'))
57    const me = await lookUpLogin($)
58    if (me === undefined) return await fail($, 'gh isn’t logged in: run gh auth login')
59
60    const q = await gh($, queryArgs(repo, me))
61    const reply = parseReply(q.stdout, me)
62    if ('error' in reply) {
63      // A 502 from a GitHub under load comes as HTML on stdout: say what gh said.
64      return await fail($, q.exitCode !== 0 ? firstLine(q.stderr, reply.error) : reply.error)
65    }
66    const { prs, open } = reply
67    const at = await $.clock.now()
68    const stored = toSeen(await $.store.get(SEEN_STORE_KEY))
69    const marks = reconcile(stored, repo, prs, open, me, at)
70    if (!isSameSeen(stored, marks)) await $.store.set(SEEN_STORE_KEY, marks)
71    await update($, seen, () => marks)
72    await update($, overview, () => ({ repo, prs, fetchedAt: at }))
73  } catch (err) {
74    await fail($, errorText(err))
75  } finally {
76    await update($, isFetching, () => false)
77  }
78}
79
80// The sidebar's openers, for when gh can't open a browser itself: macOS has
81// `open`; Linux desktops have `xdg-open`; Windows has rundll32's URL handler
82// (explorer.exe opens it too, but exits 1 when it succeeds).
83const openUrl = async ($: EngineInterface, url: string) => {
84  for (const opener of [['open'], ['xdg-open'], ['rundll32', 'url.dll,FileProtocolHandler']]) {
85    try {
86      if ((await $.process.run([...opener, url], { timeoutMs: 5000 })).exitCode === 0) return true
87    } catch {
88      // Not on this system: try the next.
89    }
90  }
91  return false
92}
93
94// Opens the PR with gh (or, failing that, the system's opener), then clears
95// its mark (read afresh, as another session may have marked others since).
96const openPr = async ($: EngineInterface, repo: string, pr: Pr) => {
97  let error: string | undefined
98  try {
99    const r = await gh($, ['pr', 'view', String(pr.number), '--repo', repo, '--web'], OPEN_TIMEOUT_MS)
100    if (r.exitCode !== 0) error = firstLine(r.stderr, 'gh failed')
101  } catch (err) {
102    error = errorText(err)
103  }
104  if (error !== undefined && !(await openUrl($, pr.url))) {
105    $.ui.toast(`Couldn't open #${pr.number}: ${error}`)
106    return
107  }
108  const marks = markSeen(toSeen(await $.store.get(SEEN_STORE_KEY)), repo, pr)
109  await $.store.set(SEEN_STORE_KEY, marks)
110  await update($, seen, () => marks)
111}
112
113// The refresh timer, while the pane is open.
114let timer: Timer | undefined
115
116const startTimer = ($: EngineInterface, minutes: number) => {
117  timer?.cancel()
118  timer = $.clock.every(minutes * 60_000, () => void refresh($))
119}
120
121const stopTimer = () => {
122  timer?.cancel()
123  timer = undefined
124}
125
126// The interval as the module loaded with it; a /config change this session wins.
127let minutesOption = 30
128const minutes = async ($: EngineInterface) => (await read($, intervalChange)) ?? minutesOption
129
130export const register: Register = (on, options) => {
131  minutesOption = toMinutes(options.refreshMinutes)
132
133  on('session.start', async ($, e, next) => {
134    await $.command.register({ name: CMD, description: 'Show or hide the open PRs in this repo you’re involved in' })
135
136    return next(e)
137  })
138
139  // /pr-overview toggles the pane. Answering with no text adds nothing to the
140  // conversation.
141  on('command.run', { command: CMD }, async $ => {
142    if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
143      await $.ui.close({ id: PANE })
144      stopTimer()
145      return {}
146    }
147    const opened = await $.ui.open({
148      id: PANE,
149      title: 'PRs',
150      focus: true,
151      closeOnEscape: true,
152      rows: PANE_ROWS,
153      columns: PANE_COLUMNS,
154    })
155    if (!opened.isPlaced) return { text: `PR overview not placed: ${opened.reason}` }
156    startTimer($, await minutes($))
157    $.clock.after(0, () => void refresh($))
158
159    return {}
160  })
161
162  // Escape or the close mark: stop refreshing.
163  on('ui.close', async ($, e, next) => {
164    const closed = await next(e)
165    if (e.id === PANE) stopTimer()
166
167    return closed
168  })
169
170  on('config.set', async ($, e, next) => {
171    const set = await next(e)
172    if (set.deny === undefined && isOwnedBy(e.provider.plugin, 'pr-overview') && e.key.endsWith('.refreshMinutes')) {
173      const m = toMinutes(set.value)
174      await update($, intervalChange, () => m)
175      if (timer !== undefined) startTimer($, m)
176    }
177
178    return set
179  })
180
181  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
182    const { Box, Button, Text } = $.ui.resolve(e)
183    const [ov, fetching, marks, at] = await Promise.all([
184      read($, overview),
185      read($, isFetching),
186      read($, seen),
187      $.clock.now(),
188    ])
189    // Nothing fetched yet in this session's state (a /clear, or the plugin
190    // reloaded with the pane open): fetch, and refresh from now on.
191    if (ov === null && !fetching) $.clock.after(0, () => void refresh($))
192    if (timer === undefined) startTimer($, await minutes($))
193
194    const repo = ov?.repo
195    const status = fetching
196      ? 'refreshing…'
197      : ov !== null && ov.fetchedAt > 0
198        ? `updated ${clockTime(ov.fetchedAt)}`
199        : ''
200    const rowWidth = Math.max(20, e.props.bodyColumns - 2)
201
202    return (
203      <Box flexDirection="column">
204        <Box flexDirection="row">
205          <Text bold wrap="truncate-end">{repo ?? 'PRs'}</Text>
206          <Text dimColor>  {status}  </Text>
207          <Button key="refresh" label="refresh" hotkey="r" dimColor onPress={() => void refresh($)} />
208        </Box>
209        {ov?.error !== undefined && <Text color="red" wrap="truncate-end">! {ov.error}</Text>}
210        {ov === null && <Text dimColor>Loading…</Text>}
211        {ov !== null && ov.error === undefined && ov.prs.length === 0 && (
212          <Text dimColor>No open PRs you’re involved in.</Text>
213        )}
214        {repo !== undefined &&
215          (ov?.prs ?? []).map((pr, i) => (
216            <Box flexDirection="row">
217              <Text color="magenta">{isNew(pr, repo, marks) ? '● ' : '  '}</Text>
218              <Button
219                key={`pr:${pr.number}`}
220                plain
221                label={rowText(pr, rowWidth, at)}
222                dimColor={pr.isDraft}
223                {...(i === 0 ? { autoFocus: true as const } : {})}
224                onPress={() => void openPr($, repo, pr)}
225              />
226            </Box>
227          ))}
228        <Text dimColor>Tab moves · Enter opens · r refreshes · Esc closes</Text>
229      </Box>
230    )
231  })
232}
233
hooks/format.ts 39 lines
1import type { Ci, Pr } from '../types'
2
3export const ciGlyph = (ci: Ci | undefined) => (ci === 'pass' ? '✓' : ci === 'fail' ? '✗' : ci === 'pending' ? '…' : ' ')
4
5// How long ago, at most two characters and a unit: 5m, 2h, 3d, 2w.
6export const ago = (iso: string, now: number) => {
7  const mins = Math.max(0, Math.floor((now - Date.parse(iso)) / 60_000))
8  if (!Number.isFinite(mins)) return '?'
9  if (mins < 60) return `${mins}m`
10  const hours = Math.floor(mins / 60)
11  if (hours < 24) return `${hours}h`
12  const days = Math.floor(hours / 24)
13  return days < 14 ? `${days}d` : `${Math.floor(days / 7)}w`
14}
15
16const cut = (text: string, width: number) =>
17  text.length <= width ? text : width <= 1 ? text.slice(0, Math.max(0, width)) : `${text.slice(0, width - 1)}…`
18
19// The row after its mark: "#123  title…  ✓ conflict  2h", the title cut or
20// padded so the status lines up at the right of `width` columns.
21export const rowText = (pr: Pr, width: number, now: number) => {
22  const head = `#${pr.number}  `
23  const tail = `  ${ciGlyph(pr.ci)}${pr.isConflicting ? ' conflict' : ''} ${ago(pr.updatedAt, now).padStart(3)}`
24  const room = Math.max(4, width - head.length - tail.length)
25  const title = pr.title.replace(/\s+/g, ' ').trim()
26  return `${head}${cut(title, room).padEnd(room)}${tail}`
27}
28
29// Local time of day, "09:12".
30export const clockTime = (ms: number) => {
31  const d = new Date(ms)
32  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
33}
34
35// The first line of a command's error output, for one line in the pane.
36export const firstLine = (text: string, fallback: string) => text.trim().split('\n')[0]?.trim() || fallback
37
38export const toMinutes = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? Math.max(1, value) : 30)
39
hooks/owner.ts 6 lines
1// Whether a /config row's owner (`provider.plugin`) is this plugin. The owner
2// carries where the plugin came from: `pr-overview@pt-mods` installed from a
3// marketplace, `pr-overview@inline` under `claude --plugin-dir`; tests use
4// the bare name.
5export const isOwnedBy = (owner: string, plugin: string) => owner === plugin || owner.startsWith(`${plugin}@`)
6
hooks/query.ts 141 lines
1import type { Ci, Pr } from '../types'
2import { isSameLogin } from './involved'
3
4// One GraphQL call per refresh. GitHub's search finds the open PRs the person
5// is involved in, however many the repo has open: involves (author, assignee,
6// commenter, @-mention), user-review-requested (asked by name; team requests
7// don't count) and reviewed-by. Search can't find commits pushed to someone
8// else's PR, so the 50 most recently updated open PRs are also checked for
9// the person's commits. Closed and merged PRs are never asked for. (Asking
10// every open PR for its participants instead missed PRs on busy repos and
11// made GitHub time out.)
12
13const TIMELINE_ACTORS = [
14  'HeadRefForcePushedEvent',
15  'BaseRefForcePushedEvent',
16  'ReadyForReviewEvent',
17  'ConvertToDraftEvent',
18  'ReviewRequestedEvent',
19  'ReviewRequestRemovedEvent',
20  'ReviewDismissedEvent',
21  'RenamedTitleEvent',
22  'LabeledEvent',
23  'UnlabeledEvent',
24  'AssignedEvent',
25  'UnassignedEvent',
26  'CrossReferencedEvent',
27  'ReferencedEvent',
28  'AutoMergeEnabledEvent',
29  'AutoMergeDisabledEvent',
30]
31
32const SEARCHES = ['involves', 'requested', 'reviewed'] as const
33
34export const QUERY = `fragment row on PullRequest {
35  number title url updatedAt isDraft mergeable state
36  latest: commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
37  timelineItems(last: 1) {
38    nodes {
39      ... on IssueComment { author { login } }
40      ... on PullRequestReview { author { login } }
41      ... on PullRequestCommit { commit { author { user { login } } } }
42${TIMELINE_ACTORS.map(t => `      ... on ${t} { actor { login } }`).join('\n')}
43    }
44  }
45}
46query($owner: String!, $name: String!, $involves: String!, $requested: String!, $reviewed: String!) {
47  repository(owner: $owner, name: $name) {
48    pullRequests(states: OPEN, first: 50, orderBy: { field: UPDATED_AT, direction: DESC }) {
49      nodes { ...row commits(last: 50) { nodes { commit { authors(first: 3) { nodes { user { login } } } } } } }
50    }
51  }
52${SEARCHES.map(s => `  ${s}: search(type: ISSUE, first: 50, query: $${s}) { nodes { ...row } }`).join('\n')}
53}`
54
55// The searches, each limited to the repo's open PRs.
56export const searches = (repo: string, login: string) => ({
57  involves: `repo:${repo} is:pr is:open involves:${login}`,
58  requested: `repo:${repo} is:pr is:open user-review-requested:${login}`,
59  reviewed: `repo:${repo} is:pr is:open reviewed-by:${login}`,
60})
61
62// The query and its variables as gh api graphql's -f flags take them.
63export const queryArgs = (repo: string, login: string) => {
64  const [owner = '', name = ''] = repo.split('/')
65  const vars = { owner, name, ...searches(repo, login) }
66  return ['api', 'graphql', ...Object.entries(vars).flatMap(([k, v]) => ['-f', `${k}=${v}`]), '-f', `query=${QUERY}`]
67}
68
69// statusCheckRollup.state: SUCCESS, FAILURE, ERROR, PENDING, EXPECTED.
70export const toCi = (state: unknown): Ci | undefined => {
71  if (state === 'SUCCESS') return 'pass'
72  if (state === 'FAILURE' || state === 'ERROR') return 'fail'
73  if (state === 'PENDING' || state === 'EXPECTED') return 'pending'
74  return undefined
75}
76
77type Json = Record<string, unknown>
78const obj = (v: unknown): Json => (typeof v === 'object' && v !== null ? (v as Json) : {})
79const nodes = (v: unknown): Json[] => {
80  const list = obj(v).nodes
81  return Array.isArray(list) ? list.map(obj) : []
82}
83const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
84const login = (v: unknown) => str(obj(v).login)
85
86const lastActor = (item: Json) =>
87  login(item.author) ?? login(item.actor) ?? login(obj(obj(item.commit).author).user)
88
89// Search's index can lag a merge or close: only nodes still OPEN count.
90const toPr = (n: Json): Pr | undefined => {
91  const { number, title, url, updatedAt } = n
92  if (n.state !== 'OPEN') return undefined
93  if (typeof number !== 'number' || typeof title !== 'string' || typeof url !== 'string' || typeof updatedAt !== 'string') {
94    return undefined
95  }
96  const last = nodes(n.timelineItems)[0]
97  return {
98    number,
99    title,
100    url,
101    updatedAt,
102    isDraft: n.isDraft === true,
103    isConflicting: n.mergeable === 'CONFLICTING',
104    ci: toCi(obj(obj(obj(nodes(n.latest)[0]).commit).statusCheckRollup).state),
105    lastActor: last === undefined ? undefined : lastActor(last),
106  }
107}
108
109const hasCommitBy = (n: Json, me: string) =>
110  nodes(n.commits).some(c => nodes(obj(c.commit).authors).some(a => isSameLogin(login(a.user), me)))
111
112// `prs`: the involved open PRs, newest activity first. `open`: the number of
113// every open PR the reply named, for dropping marks of closed ones.
114export type Reply = { prs: Pr[]; open: number[] } | { error: string }
115
116// gh api graphql's stdout, or GraphQL's first error.
117export const parseReply = (text: string, me: string): Reply => {
118  let body: Json
119  try {
120    body = obj(JSON.parse(text))
121  } catch {
122    return { error: 'GitHub sent a reply that isn’t JSON' }
123  }
124  if (Array.isArray(body.errors) && body.errors.length > 0) {
125    return { error: str(obj(body.errors[0]).message) ?? 'GitHub returned an error' }
126  }
127  const data = obj(body.data)
128  if (data.repository === null || data.repository === undefined) return { error: 'repository not found' }
129
130  const scanned = nodes(obj(data.repository).pullRequests)
131  const found = SEARCHES.flatMap(s => nodes(data[s]))
132  const byNumber = new Map<number, Pr>()
133  for (const n of [...found, ...scanned.filter(n => hasCommitBy(n, me))]) {
134    const pr = toPr(n)
135    if (pr !== undefined && !byNumber.has(pr.number)) byNumber.set(pr.number, pr)
136  }
137  const open = [...new Set([...scanned, ...found].map(toPr).flatMap(pr => (pr === undefined ? [] : [pr.number])))]
138  const prs = [...byNumber.values()].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
139  return { prs, open }
140}
141
hooks/seen.ts 54 lines
1import type { Pr, Seen } from '../types'
2import { isSameLogin } from './involved'
3
4// When each PR was last opened from the list: its updatedAt then, kept in
5// $.store so every session shares the marks.
6
7export const SEEN_STORE_KEY = 'seen'
8// Entries for repos not refreshed lately go once they're this old, so a repo
9// that's never opened again doesn't keep its entries for ever.
10export const SEEN_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000
11
12export const seenKey = (repo: string, number: number) => `${repo}#${number}`
13
14const repoOf = (key: string) => key.slice(0, key.lastIndexOf('#'))
15
16// A stored value as read back from $.store; anything unusable is dropped.
17export const toSeen = (value: unknown): Seen => {
18  if (typeof value !== 'object' || value === null) return {}
19  return Object.fromEntries(
20    Object.entries(value).filter((kv): kv is [string, string] => typeof kv[1] === 'string' && kv[0].includes('#')),
21  )
22}
23
24// New: never opened from the list, or updated since.
25export const isNew = (pr: Pr, repo: string, seen: Seen) => {
26  const at = seen[seenKey(repo, pr.number)]
27  return at === undefined || Date.parse(pr.updatedAt) > Date.parse(at)
28}
29
30export const markSeen = (seen: Seen, repo: string, pr: Pr): Seen => ({ ...seen, [seenKey(repo, pr.number)]: pr.updatedAt })
31
32// After a refresh of `repo`:
33// - its PRs whose latest activity is the person's own count as seen, so their
34//   own comment or push never marks a PR;
35// - its entries for PRs no longer open (`open`, every open PR fetched) go;
36// - other repos' entries go once older than SEEN_MAX_AGE_MS.
37export const reconcile = (seen: Seen, repo: string, involved: readonly Pr[], open: readonly number[], login: string, now: number): Seen => {
38  const openKeys = new Set(open.map(n => seenKey(repo, n)))
39  const out: Seen = {}
40  for (const [key, at] of Object.entries(seen)) {
41    const isKept = repoOf(key) === repo ? openKeys.has(key) : now - Date.parse(at) < SEEN_MAX_AGE_MS
42    if (isKept) out[key] = at
43  }
44  for (const pr of involved) {
45    if (isSameLogin(pr.lastActor, login) && isNew(pr, repo, out)) out[seenKey(repo, pr.number)] = pr.updatedAt
46  }
47  return out
48}
49
50export const isSameSeen = (a: Seen, b: Seen) => {
51  const ak = Object.keys(a)
52  return ak.length === Object.keys(b).length && ak.every(k => a[k] === b[k])
53}
54
hooks/involved.ts 3 lines
1// GitHub logins aren't case-sensitive.
2export const isSameLogin = (a: string | undefined, b: string) => a !== undefined && a.toLowerCase() === b.toLowerCase()
3
types/index.d.ts 44 lines
1// The last commit's CI rollup: absent when the PR has no checks.
2export type Ci = 'pass' | 'fail' | 'pending'
3
4// One open PR you're involved in, as the GraphQL reply gives it.
5export type Pr = {
6  number: number
7  title: string
8  url: string
9  // ISO 8601, GitHub's last update of the PR.
10  updatedAt: string
11  isDraft: boolean
12  // Only GitHub's definite CONFLICTING; UNKNOWN while it works it out is false.
13  isConflicting: boolean
14  ci?: Ci
15  // Who did the latest timeline item, when it's a kind that names someone.
16  lastActor?: string
17}
18
19// "owner/name#123" -> the PR's updatedAt when it was last opened from the list.
20export type Seen = Record<string, string>
21
22// What the pane shows: the involved PRs, newest activity first. `error` is
23// the last refresh's failure; the PRs are then the last good list.
24export type Overview = {
25  repo?: string
26  prs: Pr[]
27  fetchedAt: number
28  error?: string
29}
30
31declare module 'claude-code' {
32  interface PluginState {
33    'pr-overview': {
34      overview: Overview | null
35      isFetching: boolean
36      seen: Seen
37      // The gh user's login, looked up once per session.
38      login: string | null
39      // The refresh interval, minutes, once changed in /config this session.
40      intervalChange: number | null
41    }
42  }
43}
44