A pane beside the chat: this session's PRs and branches, and what each one needs

A Claude Code mod: a pane beside the chat with every PR and branch the current session opened or pushed, and what each one needs right now.
The session rail of the Claude desktop app already lists the session's PRs in its Changes section, with their state shown by icon and color. This mod says the same in words (conflict, checks failed, behind main, ready to merge), puts what waits for you in the line under the prompt, and adds two lists: branches the session pushed without a PR, and all your open PRs across repositories.
One line per PR that still needs something: a label, the number (a link to GitHub) and the title.
| Label | When |
|---|---|
| conflict | GitHub cannot merge the branch without a conflict |
| checks failed | checks on the latest commit failed |
| changes requested | a reviewer asked for changes |
| behind main | the branch is behind, and the repository requires it up to date |
| review required | the repository requires an approval that is missing |
| checks running | checks have not finished yet |
| ready to merge | no conflict, checks passed |
| draft | the PR is marked as a draft |
A branch the session pushed without a PR shows as "no PR, N commits not in main".
Below, one line each for what is done: merged #12 #13 …, closed …, and no PR, all in main … for branches that are safe to delete. When the session's PRs span several repositories, numbers carry the repository name: api#42.
The line under the prompt names only what waits for you, for example #42 ready to merge. When nothing waits, there is no line.
At the bottom of the pane, "Refresh", and "All my open PRs": every open PR authored by you across your repositories, and branches without an open PR.
At the prompt of a Claude Code terminal session:
/plugin install old-school-prs --marketplace rbaranov/old-school-prs
Answer y to add the marketplace, then pick a scope (user scope makes it load in every session).
From a shell:
claude plugin install old-school-prs --marketplace rbaranov/old-school-prs
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with rbaranov/old-school-prs, then Add plugin.
gh, signed in (gh auth login). The mod reads PRs as that account. It looks for gh on PATH, then in /opt/homebrew/bin and /usr/local/bin./prs opens it in any session, also after it was closed with ×.mcp__old-school-prs__show tool, which opens the pane and answers with the list as text./reload-plugins.gh pr create (the PR number in its output) and git push ... origin <branch> (the repository from the output, or from the folder the command ran in). Only commands the shell runs count: a heredoc body or a script that mentions git push in its text does not. Subagents' commands count while the session runs.A PR created outside gh pr create (say, with the button on github.com) is not seen until the session pushes its branch.
gh api graphql query for all of the session's PRs.gh pr …, git push and git merge the session runs, every 2 minutes, and on "Refresh". The all-open-PRs list refreshes every 5 minutes, only while it is expanded.hooks/register.tsx holds the hooks and the pane; hooks/scan.ts parses commands and picks labels, with no calls to the engine, so it is tested on its own./reload-plugins in one) → the "Session PRs" pane on the right lists them; if one waits, the line under the prompt reads old-school-prs #<number> <label>.gh pr view <number> --json mergeable,statusCheckRollup → the conflict and checks match.gh pr merge) → after the command it moves to the merged line, and the line under the prompt goes away.gh search prs --author @me --state open./prs → "No PRs in this session", and no line under the prompt.claude plugin validate .
claude plugin test .
To run your working copy instead of the installed one, add the folder as a marketplace (claude plugin marketplace add <folder>) and install from it: the plugin is then read from the folder, and /reload-plugins picks up edits.
hooks/register.tsx 538 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Branch, Checks, Pr, SessionBranch, SessionPr, SessionView, Tracked } from '../types'
5import {
6 age,
7 cdTarget,
8 commits,
9 isPrChange,
10 mergeTracked,
11 pushedBranch,
12 repoFromRemote,
13 scanCommand,
14 sessionItems,
15 sessionLine,
16 trackedQuery,
17 verdict,
18} from './scan'
19import type { TrackedData } from './scan'
20
21const PANE = 'old-school-prs'
22const TITLE = 'Session PRs'
23const SESSION_REFRESH_MS = 2 * 60 * 1000
24const ALL_REFRESH_MS = 5 * 60 * 1000
25const MAX_TRACKED = 40
26const MAX_BRANCHES = 20
27
28const tracked = atom({ plugin: 'old-school-prs', key: 'tracked' } as const, [])
29const session = atom({ plugin: 'old-school-prs', key: 'session' } as const, null)
30const sessionError = atom({ plugin: 'old-school-prs', key: 'sessionError' } as const, null)
31const isLoading = atom({ plugin: 'old-school-prs', key: 'isLoading' } as const, false)
32const isAllShown = atom({ plugin: 'old-school-prs', key: 'isAllShown' } as const, false)
33const snapshot = atom({ plugin: 'old-school-prs', key: 'snapshot' } as const, null)
34const error = atom({ plugin: 'old-school-prs', key: 'error' } as const, null)
35const isAllLoading = atom({ plugin: 'old-school-prs', key: 'isAllLoading' } as const, false)
36
37// A session the desktop app starts may not have Homebrew on its PATH.
38const GH = ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh']
39
40const PR_QUERY = `query {
41 viewer {
42 pullRequests(states: OPEN, first: 100, orderBy: { field: UPDATED_AT, direction: DESC }) {
43 nodes {
44 number title url isDraft mergeable updatedAt
45 repository { nameWithOwner }
46 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
47 }
48 }
49 }
50}`
51
52const BRANCH_QUERY = `query {
53 viewer {
54 repositories(first: 30, orderBy: { field: PUSHED_AT, direction: DESC },
55 ownerAffiliations: [OWNER, ORGANIZATION_MEMBER, COLLABORATOR]) {
56 nodes {
57 nameWithOwner isArchived
58 defaultBranchRef { name }
59 refs(refPrefix: "refs/heads/", first: 100) {
60 nodes {
61 name
62 target { ... on Commit { committedDate } }
63 associatedPullRequests(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
64 nodes { number state }
65 }
66 }
67 }
68 }
69 }
70 }
71}`
72
73type PrNode = {
74 number: number
75 title: string
76 url: string
77 isDraft: boolean
78 mergeable: Pr['mergeable']
79 updatedAt: string
80 repository: { nameWithOwner: string }
81 commits: { nodes: { commit: { statusCheckRollup: { state: Checks } | null } }[] }
82}
83
84type RepoNode = {
85 nameWithOwner: string
86 isArchived: boolean
87 defaultBranchRef: { name: string } | null
88 refs: {
89 nodes: {
90 name: string
91 target: { committedDate?: string } | null
92 associatedPullRequests: { nodes: { number: number; state: 'OPEN' | 'MERGED' | 'CLOSED' }[] }
93 }[]
94 } | null
95}
96
97async function run($: EngineInterface, argv: string[]) {
98 let reason = 'gh not found'
99 for (const bin of argv[0] === 'gh' ? GH : [argv[0]!]) {
100 try {
101 return await $.process.run([bin, ...argv.slice(1)], { timeoutMs: 60_000 })
102 } catch (err) {
103 reason = err instanceof Error ? err.message : String(err)
104 }
105 }
106 throw new Error(reason)
107}
108
109async function gh($: EngineInterface, args: string[]): Promise<string> {
110 const ran = await run($, ['gh', ...args])
111 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `gh exited with code ${ran.exitCode}`)
112 return ran.stdout
113}
114
115// gh exits non-zero when a query answers partly (a branch since deleted): the data still counts.
116async function graphql<T>($: EngineInterface, query: string): Promise<T> {
117 const ran = await run($, ['gh', 'api', 'graphql', '-f', `query=${query}`])
118 let out: { data?: T; errors?: { message: string }[] } = {}
119 try {
120 out = JSON.parse(ran.stdout)
121 } catch {
122 throw new Error(ran.stderr.trim() || `gh exited with code ${ran.exitCode}`)
123 }
124 if (!out.data) throw new Error(out.errors?.[0]?.message ?? ran.stderr.trim())
125 return out.data
126}
127
128async function repoAt($: EngineInterface, dir: string, cache: Map<string, string | null>): Promise<string | null> {
129 if (cache.has(dir)) return cache.get(dir)!
130 let repo: string | null = null
131 // A worktree removed since keeps its repository's folder above it.
132 for (let path = dir, i = 0; i < 4 && path.length > 1; i++, path = path.replace(/\/[^/]+\/?$/, '')) {
133 const ran = await run($, ['git', '-C', path, 'remote', 'get-url', 'origin']).catch(() => null)
134 if (ran?.exitCode === 0) {
135 repo = repoFromRemote(ran.stdout.trim())
136 break
137 }
138 }
139 cache.set(dir, repo)
140 return repo
141}
142
143async function scanOne(
144 $: EngineInterface,
145 command: string,
146 output: string,
147 cwd: string,
148 cache: Map<string, string | null>,
149): Promise<Tracked[]> {
150 let repoOf: string | null = null
151 if (pushedBranch(command)) {
152 const target = cdTarget(command)
153 const dir = !target ? cwd : target.startsWith('/') ? target : target.startsWith('~') ? null : `${cwd}/${target}`
154 repoOf = dir ? await repoAt($, dir, cache) : null
155 }
156 return scanCommand(command, output, repoOf)
157}
158
159// Everything the transcript shows the session opened or pushed, and the
160// branch its own working copy is on.
161async function scanSession($: EngineInterface): Promise<Tracked[]> {
162 const cwd = await $.session.cwd()
163 const cache = new Map<string, string | null>()
164 let found: Tracked[] = []
165 for (const message of await $.session.messages()) {
166 for (const use of message.toolUses) {
167 const command = String(use.input.command ?? '')
168 if (use.tool !== 'Bash' || !isPrChange(command)) continue
169 found = mergeTracked(found, await scanOne($, command, use.text ?? '', cwd, cache))
170 }
171 }
172 const repo = repoFromRemote((await $.session.repo())?.remote)
173 const current = await run($, ['git', '-C', cwd, 'branch', '--show-current']).catch(() => null)
174 const branch = current?.exitCode === 0 ? current.stdout.trim() : ''
175 if (repo && branch && branch !== 'main' && branch !== 'master') {
176 found = mergeTracked(found, [{ repo, branch }])
177 }
178 return found
179}
180
181async function loadSession($: EngineInterface, list: readonly Tracked[]): Promise<SessionView['items']> {
182 const recent = list.slice(-MAX_TRACKED)
183 if (recent.length === 0) return []
184 return sessionItems(recent, await graphql<TrackedData>($, trackedQuery(recent)))
185}
186
187async function loadPrs($: EngineInterface): Promise<Pr[]> {
188 const data = await graphql<{ viewer: { pullRequests: { nodes: PrNode[] } } }>($, PR_QUERY)
189 return data.viewer.pullRequests.nodes.map(node => ({
190 repo: node.repository.nameWithOwner,
191 number: node.number,
192 title: node.title,
193 url: node.url,
194 isDraft: node.isDraft,
195 mergeable: node.mergeable,
196 checks: node.commits.nodes[0]?.commit.statusCheckRollup?.state ?? null,
197 updatedAt: node.updatedAt,
198 }))
199}
200
201async function aheadBy($: EngineInterface, branch: Branch): Promise<number | null> {
202 try {
203 const path = `repos/${branch.repo}/compare/${encodeURI(branch.base)}...${encodeURI(branch.name)}`
204 return Number((await gh($, ['api', path, '--jq', '.ahead_by'])).trim())
205 } catch {
206 return null
207 }
208}
209
210async function loadBranches($: EngineInterface): Promise<Branch[]> {
211 const data = await graphql<{ viewer: { repositories: { nodes: RepoNode[] } } }>($, BRANCH_QUERY)
212 const branches: Branch[] = []
213 for (const repo of data.viewer.repositories.nodes) {
214 const base = repo.defaultBranchRef?.name
215 if (repo.isArchived || !base) continue
216 for (const ref of repo.refs?.nodes ?? []) {
217 const pr = ref.associatedPullRequests.nodes[0]
218 if (ref.name === base || pr?.state === 'OPEN') continue
219 branches.push({
220 repo: repo.nameWithOwner,
221 name: ref.name,
222 base,
223 url: `https://github.com/${repo.nameWithOwner}/tree/${encodeURI(ref.name)}`,
224 committedAt: ref.target?.committedDate ?? '',
225 lastPr: pr ? { number: pr.number, state: pr.state as 'MERGED' | 'CLOSED' } : null,
226 aheadBy: null,
227 })
228 }
229 }
230 branches.sort((a, b) => b.committedAt.localeCompare(a.committedAt))
231 const shown = branches.slice(0, MAX_BRANCHES)
232 const counts = await Promise.all(shown.map(branch => aheadBy($, branch)))
233 return shown.map((branch, i) => ({ ...branch, aheadBy: counts[i] ?? null }))
234}
235
236let sessionInFlight: Promise<void> | null = null
237let allInFlight: Promise<void> | null = null
238
239async function loadSessionView($: EngineInterface): Promise<void> {
240 await update($, isLoading, () => true)
241 try {
242 const items = await loadSession($, await read($, tracked))
243 await update($, session, () => ({ items, fetchedAt: Date.now() }))
244 await update($, sessionError, () => null)
245 $.ui.status(sessionLine(items))
246 } catch (err) {
247 await update($, sessionError, () => (err instanceof Error ? err.message : String(err)))
248 } finally {
249 await update($, isLoading, () => false)
250 sessionInFlight = null
251 }
252}
253
254function refreshSession($: EngineInterface): Promise<void> {
255 sessionInFlight ??= loadSessionView($)
256 return sessionInFlight
257}
258
259async function loadAll($: EngineInterface): Promise<void> {
260 await update($, isAllLoading, () => true)
261 try {
262 const [prs, branches] = await Promise.all([loadPrs($), loadBranches($)])
263 await update($, snapshot, () => ({ prs, branches, fetchedAt: Date.now() }))
264 await update($, error, () => null)
265 } catch (err) {
266 await update($, error, () => (err instanceof Error ? err.message : String(err)))
267 } finally {
268 await update($, isAllLoading, () => false)
269 allInFlight = null
270 }
271}
272
273function refreshAll($: EngineInterface): Promise<void> {
274 allInFlight ??= loadAll($)
275 return allInFlight
276}
277
278// v2: v1 lists could hold branches a script only mentioned in its text.
279const storeKey = async ($: EngineInterface) => `tracked:v2:${await $.session.id()}`
280
281async function openPane($: EngineInterface): Promise<void> {
282 const panes = await $.ui.panes().catch(() => [])
283 if (!panes.some(pane => pane.id === PANE)) await $.ui.open({ id: PANE, title: TITLE })
284}
285
286async function track($: EngineInterface, found: readonly Tracked[]): Promise<void> {
287 const before = await read($, tracked)
288 const after = mergeTracked(before, found)
289 if (after.length === before.length) return
290 await update($, tracked, () => after)
291 await $.store.set(await storeKey($), after)
292 await openPane($)
293}
294
295// What the store kept (it outlives compaction) and what the transcript shows now.
296async function start($: EngineInterface): Promise<void> {
297 const stored = await $.store.get(await storeKey($))
298 const known = Array.isArray(stored) ? (stored as Tracked[]) : []
299 const list = mergeTracked(known, await scanSession($))
300 await update($, tracked, () => list)
301 await $.store.set(await storeKey($), list)
302 if (list.length > 0) await openPane($)
303 await refreshSession($)
304}
305
306function sessionText(view: SessionView | null): string {
307 if (!view || view.items.length === 0) return 'This session has no PRs or pushed branches.'
308 return view.items
309 .map(item =>
310 item.kind === 'pr'
311 ? `- ${item.repo}#${item.number} ${item.title}: ${verdict(item).label}`
312 : `- ${item.repo} ${item.name}: ${verdict(item).label}`,
313 )
314 .join('\n')
315}
316
317export const register: Register = on => {
318 on('session.start', async ($, e, next) => {
319 await $.command.register({
320 name: 'prs',
321 description: "This session's PRs and branches: what is merged, what is ready to merge, what has a conflict",
322 })
323 await $.tool.register({
324 name: 'show',
325 description:
326 'Opens the pane with the pull requests and branches this session opened or pushed, and returns each with what it needs: merged, ready to merge, conflict, failing checks.',
327 })
328 await update($, isLoading, () => false)
329 await update($, isAllLoading, () => false)
330 $.clock.after(10, () => void start($))
331 $.clock.every(SESSION_REFRESH_MS, () => void refreshSession($))
332 $.clock.every(ALL_REFRESH_MS, async () => {
333 if (await read($, isAllShown)) void refreshAll($)
334 })
335
336 return next(e)
337 })
338
339 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
340 const ran = await next(e)
341 if (isPrChange(e.command)) {
342 const cwd = await $.session.cwd()
343 await track($, await scanOne($, e.command, ran.text ?? '', cwd, new Map()))
344 void refreshSession($)
345 }
346
347 return ran
348 }).catch(($, e, next) => next(e))
349
350 on('command.run', { command: 'prs' }, async $ => {
351 const opened = await $.ui.open({ id: PANE, title: TITLE })
352 await refreshSession($)
353 const lines = [sessionText(await read($, session))]
354 if (!opened.isPlaced) lines.push(`Pane not shown: ${opened.reason}`)
355
356 return { text: lines.join('\n') }
357 })
358
359 on('tool.call', { tool: 'mcp__old-school-prs__show' }, async $ => {
360 const opened = await $.ui.open({ id: PANE, title: TITLE })
361 await refreshSession($)
362 const failed = await read($, sessionError)
363 const lines = [
364 opened.isPlaced ? 'Pane is shown.' : `Pane is not shown: ${opened.reason}`,
365 failed ? `Last refresh failed: ${failed}` : '',
366 sessionText(await read($, session)),
367 ]
368
369 return { result: lines.filter(Boolean).join('\n') }
370 })
371
372 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
373 const { Box, Text, Button, Link } = $.ui.resolve(e)
374 const view = await read($, session)
375 const loading = await read($, isLoading)
376 const failed = await read($, sessionError)
377 const showAll = await read($, isAllShown)
378 const all = showAll ? await read($, snapshot) : null
379 const allLoading = showAll && (await read($, isAllLoading))
380 const allFailed = showAll ? await read($, error) : null
381 const now = Date.now()
382 const items = view?.items ?? []
383 const isMultiRepo = new Set(items.map(item => item.repo)).size > 1
384 const prLabel = (pr: SessionPr) => (isMultiRepo ? `${pr.repo.split('/')[1]}#${pr.number}` : `#${pr.number}`)
385 const active = items.filter(item => verdict(item).rank < 6)
386 const merged = items.filter((item): item is SessionPr => item.kind === 'pr' && item.state === 'MERGED')
387 const closed = items.filter((item): item is SessionPr => item.kind === 'pr' && item.state === 'CLOSED')
388 const finished = items.filter((item): item is SessionBranch => item.kind === 'branch' && verdict(item).rank >= 6)
389
390 const tone = (item: SessionPr | SessionBranch) => {
391 const v = verdict(item)
392 return v.tone === 'dim' ? <Text dimColor>{v.label}</Text> : <Text color={v.tone} bold>{v.label}</Text>
393 }
394
395 const byRepo = <T extends { repo: string }>(list: T[]): [string, T[]][] => {
396 const groups = new Map<string, T[]>()
397 for (const item of list) groups.set(item.repo, [...(groups.get(item.repo) ?? []), item])
398 return [...groups]
399 }
400
401 const checksMark = (pr: Pr) => {
402 if (pr.checks === 'SUCCESS') return <Text color="success">✓</Text>
403 if (pr.checks === 'FAILURE' || pr.checks === 'ERROR') return <Text color="error">✗</Text>
404 if (pr.checks === 'PENDING' || pr.checks === 'EXPECTED') return <Text color="warning">…</Text>
405 return <Text dimColor>·</Text>
406 }
407
408 const branchNote = (b: Branch) => {
409 const notes: string[] = []
410 if (b.aheadBy === 0) notes.push(`all in ${b.base}`)
411 else if (b.aheadBy) notes.push(`+${b.aheadBy} ${commits(b.aheadBy)}`)
412 if (b.lastPr) notes.push(`PR #${b.lastPr.number} ${b.lastPr.state === 'MERGED' ? 'merged' : 'closed'}`)
413 if (b.committedAt) notes.push(age(b.committedAt, now))
414 return notes.join(' · ')
415 }
416
417 return (
418 <Box flexDirection="column" gap={1}>
419 {failed && <Text color="error">Could not refresh: {failed}</Text>}
420 {!view && !failed && <Text dimColor>Looking for PRs…</Text>}
421 {view && items.length === 0 && <Text dimColor>No PRs in this session.</Text>}
422 {active.length > 0 && (
423 <Box flexDirection="column">
424 {active.map(item => (
425 <Box
426 key={item.kind === 'pr' ? `s-${item.repo}#${item.number}` : `s-${item.repo}@${item.name}`}
427 flexDirection="row"
428 gap={1}
429 >
430 <Box flexShrink={0}>{tone(item)}</Box>
431 {item.kind === 'pr' ? (
432 <Link href={item.url} label={prLabel(item)} />
433 ) : (
434 <Link href={item.url} label={item.name} />
435 )}
436 {item.kind === 'pr' && (
437 <Box flexGrow={1} flexShrink={1} minWidth={0}>
438 <Text wrap="truncate-end">{item.title}</Text>
439 </Box>
440 )}
441 </Box>
442 ))}
443 </Box>
444 )}
445 {(merged.length > 0 || closed.length > 0 || finished.length > 0) && (
446 <Box flexDirection="column">
447 {merged.length > 0 && (
448 <Box key="merged" flexDirection="row" flexWrap="wrap" columnGap={1}>
449 <Text color="merged">merged</Text>
450 {merged.map(pr => (
451 <Link href={pr.url} label={prLabel(pr)} />
452 ))}
453 </Box>
454 )}
455 {closed.length > 0 && (
456 <Box key="closed" flexDirection="row" flexWrap="wrap" columnGap={1}>
457 <Text dimColor>closed</Text>
458 {closed.map(pr => (
459 <Link href={pr.url} label={prLabel(pr)} />
460 ))}
461 </Box>
462 )}
463 {finished.length > 0 && (
464 <Box key="finished" flexDirection="row" flexWrap="wrap" columnGap={1}>
465 <Text dimColor>no PR, all in {finished[0]!.base}</Text>
466 {finished.map(b => (
467 <Link href={b.url} label={b.name} />
468 ))}
469 </Box>
470 )}
471 </Box>
472 )}
473 <Box flexDirection="row" gap={2}>
474 <Button key="refresh" plain label={loading ? 'Refreshing…' : 'Refresh'} onPress={() => void refreshSession($)} />
475 <Button
476 key="toggle-all"
477 plain
478 label={showAll ? 'Hide all open PRs' : 'All my open PRs'}
479 onPress={async () => {
480 const next = !(await read($, isAllShown))
481 await update($, isAllShown, () => next)
482 if (next) void refreshAll($)
483 }}
484 />
485 </Box>
486 {showAll && allLoading && !all && <Text dimColor>Loading…</Text>}
487 {allFailed && <Text color="error">Could not refresh: {allFailed}</Text>}
488 {all && (
489 <Box flexDirection="column">
490 <Text bold>All open PRs · {all.prs.length}</Text>
491 {byRepo(all.prs).map(([repo, prs]) => (
492 <Box key={`pr-${repo}`} flexDirection="column" marginTop={1}>
493 <Text dimColor>{repo}</Text>
494 {prs.map(pr => (
495 <Box key={`pr-${repo}-${pr.number}`} flexDirection="row" gap={1}>
496 {checksMark(pr)}
497 <Link href={pr.url} label={`#${pr.number}`} />
498 <Box flexGrow={1} flexShrink={1} minWidth={0}>
499 <Text wrap="truncate-end">
500 {pr.mergeable === 'CONFLICTING' && <Text color="error">conflict </Text>}
501 {pr.isDraft && <Text dimColor>draft </Text>}
502 {pr.title}
503 </Text>
504 </Box>
505 <Box flexShrink={0}>
506 <Text dimColor>{age(pr.updatedAt, now)}</Text>
507 </Box>
508 </Box>
509 ))}
510 </Box>
511 ))}
512 </Box>
513 )}
514 {all && (
515 <Box flexDirection="column">
516 <Text bold>Branches without an open PR · {all.branches.length}</Text>
517 {byRepo(all.branches).map(([repo, branches]) => (
518 <Box key={`br-${repo}`} flexDirection="column" marginTop={1}>
519 <Text dimColor>{repo}</Text>
520 {branches.map(b => (
521 <Box key={`br-${repo}-${b.name}`} flexDirection="row" gap={1}>
522 <Link href={b.url} label={b.name} />
523 <Box flexGrow={1} flexShrink={1} minWidth={0}>
524 <Text dimColor wrap="truncate-end">
525 {branchNote(b)}
526 </Text>
527 </Box>
528 </Box>
529 ))}
530 </Box>
531 ))}
532 </Box>
533 )}
534 </Box>
535 )
536 })
537}
538hooks/scan.ts 228 lines1import type { SessionBranch, SessionPr, Tracked } from '../types'
2
3const REPO = '[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+'
4const DEFAULT_BRANCHES = new Set(['main', 'master'])
5
6export function trackedKey(t: Tracked): string {
7 return t.number ? `${t.repo}#${t.number}` : `${t.repo}@${t.branch}`
8}
9
10export function mergeTracked(old: readonly Tracked[], found: readonly Tracked[]): Tracked[] {
11 const seen = new Set(old.map(trackedKey))
12 const added = found.filter(t => {
13 const key = trackedKey(t)
14 if (seen.has(key)) return false
15 seen.add(key)
16 return true
17 })
18 return [...old, ...added]
19}
20
21export function repoFromRemote(remote: string | null | undefined): string | null {
22 const match = remote?.match(new RegExp(`github\\.com[:/](${REPO}?)(?:\\.git)?/?$`))
23 return match?.[1]?.replace(/\.git$/, '') ?? null
24}
25
26// The directory a command `cd`s into before it pushes, if it says so.
27export function cdTarget(command: string): string | null {
28 const match = command.match(/(?:^|[;&|]\s*)cd\s+("[^"]+"|'[^']+'|[^\s;&|]+)/)
29 return match?.[1]?.replace(/^["']|["']$/g, '') ?? null
30}
31
32const GIT_PUSH = /^git\s+(?:-C\s+\S+\s+)?push\b/
33const PR_CREATE = /^gh\s+pr\s+create\b/
34const PR_CHANGE = /^gh\s+pr\s+(?:create|merge|close|ready|reopen)\b|^git\s+(?:-C\s+\S+\s+)?(?:push|merge)\b/
35
36// The commands a shell line runs, each from its start: heredoc bodies and the
37// text of a script that merely mentions `git push` are not commands.
38export function commandParts(command: string): string[] {
39 const code = command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2\s*(?=\n|$)/g, '')
40 return code
41 .split(/\|\||&&|;|\||\n/)
42 .map(part => part.trim().replace(/^[({]\s*/, ''))
43 .filter(Boolean)
44}
45
46export function isPrChange(command: string): boolean {
47 return commandParts(command).some(part => PR_CHANGE.test(part))
48}
49
50// A command that runs `git push` names its branch after the remote: `-u origin feat/x`.
51export function pushedBranch(command: string): string | null {
52 for (const part of commandParts(command)) {
53 if (!GIT_PUSH.test(part) || /--delete|\s:\S/.test(part)) continue
54 const match = part.match(/\bpush\b(?:\s+-{1,2}[\w-]+)*\s+origin\s+(?:HEAD:)?([\w./-]+)/)
55 const branch = match?.[1]
56 if (branch && !DEFAULT_BRANCHES.has(branch) && branch !== 'HEAD') return branch
57 }
58 return null
59}
60
61// What one Bash call shows the session opened or pushed. `repoOf` names the
62// repository a pushed branch belongs to when the output does not say.
63export function scanCommand(command: string, output: string, repoOf: string | null): Tracked[] {
64 const found: Tracked[] = []
65 const parts = commandParts(command)
66 if (parts.some(part => PR_CREATE.test(part))) {
67 for (const m of output.matchAll(new RegExp(`https://github\\.com/(${REPO})/pull/(\\d+)`, 'g'))) {
68 found.push({ repo: m[1]!, number: Number(m[2]) })
69 }
70 }
71 if (parts.some(part => GIT_PUSH.test(part))) {
72 for (const m of output.matchAll(new RegExp(`https://github\\.com/(${REPO})/pull/new/(\\S+)`, 'g'))) {
73 found.push({ repo: m[1]!, branch: m[2]! })
74 }
75 const branch = pushedBranch(command)
76 const outRepo = repoFromRemote(output.match(/^To (\S+)/m)?.[1])
77 const repo = outRepo ?? repoOf
78 if (branch && repo) found.push({ repo, branch })
79 }
80 return mergeTracked([], found)
81}
82
83const PR_FIELDS = `number title url state isDraft mergeable mergeStateStatus reviewDecision
84 mergedAt closedAt updatedAt headRefName baseRefName
85 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }`
86
87type SessionPrNode = {
88 number: number
89 title: string
90 url: string
91 state: SessionPr['state']
92 isDraft: boolean
93 mergeable: SessionPr['mergeable']
94 mergeStateStatus: string
95 reviewDecision: string | null
96 mergedAt: string | null
97 closedAt: string | null
98 updatedAt: string
99 headRefName: string
100 baseRefName: string
101 commits: { nodes: { commit: { statusCheckRollup: { state: SessionPr['checks'] } | null } }[] }
102}
103
104type TrackedNode = {
105 pullRequest?: SessionPrNode | null
106 pullRequests?: { nodes: SessionPrNode[] }
107 ref?: { name: string } | null
108 defaultBranchRef?: { name: string; compare: { aheadBy: number } | null } | null
109}
110
111export type TrackedData = Record<string, TrackedNode | null>
112
113// One query for all of them: `t<i>` answers the i-th, a PR by number or a branch by name.
114export function trackedQuery(list: readonly Tracked[]): string {
115 const parts = list.map((t, i) => {
116 const [owner, name] = t.repo.split('/')
117 const head = `t${i}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)})`
118 if (t.number) return `${head} { pullRequest(number: ${t.number}) { ${PR_FIELDS} } }`
119 const branch = JSON.stringify(t.branch)
120 return `${head} {
121 defaultBranchRef { name compare(headRef: ${branch}) { aheadBy } }
122 ref(qualifiedName: ${JSON.stringify(`refs/heads/${t.branch}`)}) { name }
123 pullRequests(headRefName: ${branch}, first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
124 nodes { ${PR_FIELDS} }
125 }
126 }`
127 })
128 return `query {\n${parts.join('\n')}\n}`
129}
130
131function toSessionPr(repo: string, node: SessionPrNode): SessionPr {
132 return {
133 kind: 'pr',
134 repo,
135 number: node.number,
136 title: node.title,
137 url: node.url,
138 isDraft: node.isDraft,
139 mergeable: node.mergeable,
140 checks: node.commits.nodes[0]?.commit.statusCheckRollup?.state ?? null,
141 updatedAt: node.updatedAt,
142 state: node.state,
143 mergeState: node.mergeStateStatus,
144 reviewDecision: node.reviewDecision,
145 branch: node.headRefName,
146 base: node.baseRefName,
147 mergedAt: node.mergedAt,
148 closedAt: node.closedAt,
149 }
150}
151
152// A branch with a PR shows as that PR; a branch deleted with no PR is left out.
153export function sessionItems(list: readonly Tracked[], data: TrackedData): (SessionPr | SessionBranch)[] {
154 const items = new Map<string, SessionPr | SessionBranch>()
155 list.forEach((t, i) => {
156 const node = data[`t${i}`]
157 if (!node) return
158 const pr = t.number ? node.pullRequest : node.pullRequests?.nodes[0]
159 if (pr) {
160 items.set(`${t.repo}#${pr.number}`, toSessionPr(t.repo, pr))
161 } else if (t.branch && node.ref) {
162 items.set(trackedKey(t), {
163 kind: 'branch',
164 repo: t.repo,
165 name: t.branch,
166 base: node.defaultBranchRef?.name ?? 'main',
167 url: `https://github.com/${t.repo}/tree/${encodeURI(t.branch)}`,
168 aheadBy: node.defaultBranchRef?.compare?.aheadBy ?? null,
169 })
170 }
171 })
172 const when = (item: SessionPr | SessionBranch) =>
173 item.kind === 'pr' ? (item.mergedAt ?? item.closedAt ?? item.updatedAt) : ''
174 return [...items.values()].sort((a, b) => verdict(a).rank - verdict(b).rank || when(b).localeCompare(when(a)))
175}
176
177export type Verdict = {
178 label: string
179 tone: 'success' | 'error' | 'warning' | 'merged' | 'dim'
180 rank: number
181}
182
183export function verdict(item: SessionPr | SessionBranch): Verdict {
184 if (item.kind === 'branch') {
185 if (item.aheadBy === 0) return { label: `no PR, all in ${item.base}`, tone: 'dim', rank: 7 }
186 if (item.aheadBy) {
187 return { label: `no PR, ${item.aheadBy} ${commits(item.aheadBy)} not in ${item.base}`, tone: 'warning', rank: 3 }
188 }
189 return { label: 'no PR', tone: 'warning', rank: 3 }
190 }
191 if (item.state === 'MERGED') return { label: 'merged', tone: 'merged', rank: 6 }
192 if (item.state === 'CLOSED') return { label: 'closed without merge', tone: 'dim', rank: 7 }
193 if (item.mergeable === 'CONFLICTING') return { label: 'conflict', tone: 'error', rank: 0 }
194 if (item.checks === 'FAILURE' || item.checks === 'ERROR') return { label: 'checks failed', tone: 'error', rank: 0 }
195 if (item.reviewDecision === 'CHANGES_REQUESTED') return { label: 'changes requested', tone: 'error', rank: 0 }
196 if (item.isDraft) return { label: 'draft', tone: 'dim', rank: 4 }
197 if (item.checks === 'PENDING' || item.checks === 'EXPECTED') return { label: 'checks running', tone: 'warning', rank: 2 }
198 if (item.mergeable === 'UNKNOWN') return { label: 'checking for conflicts', tone: 'warning', rank: 2 }
199 if (item.mergeState === 'BEHIND') return { label: `behind ${item.base}`, tone: 'warning', rank: 1 }
200 if (item.reviewDecision === 'REVIEW_REQUIRED') return { label: 'review required', tone: 'warning', rank: 1 }
201 return { label: 'ready to merge', tone: 'success', rank: 1 }
202}
203
204export function commits(n: number): string {
205 return n === 1 ? 'commit' : 'commits'
206}
207
208export function age(iso: string, now: number): string {
209 const minutes = Math.max(0, Math.round((now - Date.parse(iso)) / 60_000))
210 if (minutes < 60) return `${minutes}m`
211 const hours = Math.round(minutes / 60)
212 if (hours < 24) return `${hours}h`
213 const days = Math.round(hours / 24)
214 if (days < 30) return `${days}d`
215 return `${Math.round(days / 30)}mo`
216}
217
218// The status line names only what still waits for the person; nothing waits, no line.
219export function sessionLine(items: readonly (SessionPr | SessionBranch)[]): string | undefined {
220 const waiting = items.filter(item => verdict(item).rank < 6)
221 if (waiting.length === 0) return undefined
222 const name = (item: SessionPr | SessionBranch) => (item.kind === 'pr' ? `#${item.number}` : item.name)
223 return `${waiting
224 .slice(0, 3)
225 .map(item => `${name(item)} ${verdict(item).label}`)
226 .join(' · ')}${waiting.length > 3 ? ` · ${waiting.length - 3} more` : ''}`
227}
228types/index.d.ts 65 lines1export type Checks = 'SUCCESS' | 'FAILURE' | 'ERROR' | 'PENDING' | 'EXPECTED' | null
2
3export type Pr = {
4 repo: string
5 number: number
6 title: string
7 url: string
8 isDraft: boolean
9 mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
10 checks: Checks
11 updatedAt: string
12}
13
14export type Branch = {
15 repo: string
16 name: string
17 base: string
18 url: string
19 committedAt: string
20 lastPr: { number: number; state: 'MERGED' | 'CLOSED' } | null
21 aheadBy: number | null
22}
23
24export type Snapshot = { prs: Pr[]; branches: Branch[]; fetchedAt: number }
25
26// What the session pushed or opened: a PR by number, or a branch by name.
27export type Tracked = { repo: string; number?: number; branch?: string }
28
29export type SessionPr = Pr & {
30 kind: 'pr'
31 state: 'OPEN' | 'MERGED' | 'CLOSED'
32 mergeState: string
33 reviewDecision: string | null
34 branch: string
35 base: string
36 mergedAt: string | null
37 closedAt: string | null
38}
39
40export type SessionBranch = {
41 kind: 'branch'
42 repo: string
43 name: string
44 base: string
45 url: string
46 aheadBy: number | null
47}
48
49export type SessionView = { items: (SessionPr | SessionBranch)[]; fetchedAt: number }
50
51declare module 'claude-code' {
52 interface PluginState {
53 'old-school-prs': {
54 tracked: Tracked[]
55 session: SessionView | null
56 sessionError: string | null
57 isLoading: boolean
58 isAllShown: boolean
59 snapshot: Snapshot | null
60 error: string | null
61 isAllLoading: boolean
62 }
63 }
64}
65