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

A Claude Code plugin marketplace with three plugins, installed separately:
/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.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.
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).
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.
| Section | Shows | Needs |
|---|---|---|
| Model | model, context bar, 5h / 7d rate-limit bars (subscriptions only), prompt-cache chip | nothing; see the two optional extras below for a fuller cache chip |
| Session | cost, elapsed time, API time, effort, session id with a copy button | nothing |
| Workspace | repo (and (wt) in a worktree), cwd, project root, branch, added dirs | git on PATH; added dirs need the status line feed |
| CI | the 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 on | gh, signed in (gh auth login). Hidden when there's no PR or gh can't answer |
| Tasks | Claude's own task list (TodoWrite, TaskCreate, TaskUpdate) for the main conversation | nothing |
| Cost | the 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 off | nothing |
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.
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).
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:
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.
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.
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 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
✓ passing, ✗ failing, … running, blank with no checks. conflict shows when GitHub reports a merge conflict. Drafts are dim.gh pr view --web), r refreshes, Escape closes./config: Refresh interval (minutes).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.
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.
hooks/register.tsx 233 lines1import { 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}
233hooks/format.ts 39 lines1import 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)
39hooks/owner.ts 6 lines1// 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}@`)
6hooks/query.ts 141 lines1import 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}
141hooks/seen.ts 54 lines1import 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}
54hooks/involved.ts 3 lines1// GitHub logins aren't case-sensitive.
2export const isSameLogin = (a: string | undefined, b: string) => a !== undefined && a.toLowerCase() === b.toLowerCase()
3types/index.d.ts 44 lines1// 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