SLOPSHOPPER

old-school-prs

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

newpaneguardcommandstatustool
v0.4.0no licenseupdated 2026-10-24rbaranov/old-school-prs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · old-school-prs
│ ┃ Session PRs ✕ › fix the failing auth test and add an audit log call │ ┃ No PRs in this session. │ ┃ ⏺ Read(src/auth.ts) │ ┃ Refresh All my open PRs ⎿ Read 6 lines │ ⏺ 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 │ │ › /prs │ ⎿ old-school-prs: This session has no PRs or pushed branches. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session PRs
No PRs in this session. Refresh All my open PRs
README

Old School PRs

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.

What it shows

One line per PR that still needs something: a label, the number (a link to GitHub) and the title.

LabelWhen
conflictGitHub cannot merge the branch without a conflict
checks failedchecks on the latest commit failed
changes requesteda reviewer asked for changes
behind mainthe branch is behind, and the repository requires it up to date
review requiredthe repository requires an approval that is missing
checks runningchecks have not finished yet
ready to mergeno conflict, checks passed
draftthe 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.

Install

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.

Requirements

  • Claude Code with function hooks (2.1.308 or later).
  • The GitHub CLI, 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.

Use

  • The pane opens by itself in a session that has PRs or pushed branches, and when the session opens its first PR.
  • /prs opens it in any session, also after it was closed with ×.
  • Ask the agent to show the session's PRs: it can call the mcp__old-school-prs__show tool, which opens the pane and answers with the list as text.
  • In a session that was already running when you installed or updated the mod, run /reload-plugins.

How it finds the session's PRs

  • From the session's transcript: 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.
  • The branch of the session's working folder, unless it is main or master.
  • What it found is kept in the plugin store under the session id, so compaction does not lose it.

A PR created outside gh pr create (say, with the button on github.com) is not seen until the session pushes its branch.

How it works

  • PR state comes from one gh api graphql query for all of the session's PRs.
  • It refreshes at session start, after every 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.

Checking it

  1. Open a session that created PRs (or run /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>.
  2. Compare the labels with GitHub: for an open PR, gh pr view <number> --json mergeable,statusCheckRollup → the conflict and checks match.
  3. Merge a PR from the session (gh pr merge) → after the command it moves to the merged line, and the line under the prompt goes away.
  4. Press "All my open PRs" → the list appears below; its count matches gh search prs --author @me --state open.
  5. Open a session without PRs and type /prs → "No PRs in this session", and no line under the prompt.

Developing

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.

Source 3 files
hooks/register.tsx 538 lines
1import { 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}
538
hooks/scan.ts 228 lines
1import 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}
228
types/index.d.ts 65 lines
1export 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