Watches the GitHub PRs of the repo you are working in: status line, toasts on change, and a /prs pane with CI, review and merge details

A Claude Code mod that keeps an eye on the GitHub pull requests of the repo you're working in: your own PRs and the ones waiting on your review.

<sub>Illustration with sample data (acme/rocket): the /prs pane on the right and the status line under the prompt.</sub>
rocket · PRs ✓1 ✗1 ●1 · 1 conflict · 2 to review. ✓ means passing CI, ✗ failing, ● running.The first check in a session only records a starting point, so opening a session doesn't produce a burst of toasts.
/prs pane. It lists your PRs and your review requests, each with:you when it's you), and unresolved threadsPress Refresh (r) to check right away.
gh and propose a fix.origin remote of the session's folder, so each session shows its own repo. Folders that aren't on github.com get no status line.gh must be installed and logged in (gh auth login) with the repo scope.origin remote on github.com. GitHub Enterprise hosts aren't supported yet.Run this in a terminal Claude Code session:
/plugin install pr-watch --marketplace ngxuanbminh/pr-watch
y to add the marketplace.The mod is active right away and loads in every session you start afterwards. That includes the desktop app's Code tab, which doesn't have the /plugin command itself.
Clone the repo and point Claude Code at the folder in ~/.claude/settings.json:
git clone https://github.com/ngxuanbminh/pr-watch ~/.claude/mods/pr-watch
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/pr-watch"
}
}
New sessions pick it up. Restart any session that is already open.
Use only one of the two install methods; otherwise the mod loads twice.
| What | How |
|---|---|
| Summary | Always in the status line while the session is in a GitHub repo |
| Details | Type /prs to open the pane |
| Check now | Refresh in the pane, or r while the pane has focus |
| Fix failing CI | Ask Claude under a failing PR of yours |
| Fix a conflict or catch up with main | Ask Claude under the Merge line of a PR of yours that conflicts or is behind |
| Merge an approved PR | The merge button under its Merge line, then Confirm |
In the terminal, the pane opens docked beside the conversation when the window is wide enough, and inline when it isn't.
| Option | Default | Meaning |
|---|---|---|
pollSeconds | 120 | How often to check GitHub; at least 30 |
Left unset, the default applies. To change it, run /plugin configure pr-watch@pr-watch in Claude Code, or pass it when installing from the shell:
claude plugin install pr-watch@pr-watch --config pollSeconds=300
Each check runs one gh api graphql request with two searches, both limited to the session's repo:
is:open is:pr author:@me repo:<owner>/<name>is:open is:pr review-requested:@me repo:<owner>/<name>That costs about 6 points of GitHub's 5,000-points-per-hour API limit, so every two minutes is about 180 points an hour per open session. The repo comes from git remote get-url origin in the session's folder, and is checked again when the session moves to another folder.
The mod talks to GitHub only through your gh with your credentials, and sends nothing anywhere else.
claude plugin validate . # manifest, marketplace file and hooks module
claude plugin test . # the tests in tests/
Once Claude Code has loaded the mod, it writes its type declarations into .claude-plugin/types/ (gitignored). After that, tsc -p . type-checks the mod.
| File | Role |
|---|---|
hooks/register.tsx | Hooks: polling, the /prs command, the pane, toasts, the status line |
hooks/github.ts | GraphQL query, response parsing, remote URL parsing |
hooks/details.ts | The CI, Review, Merge and Size lines |
hooks/prompts.ts | The prompts behind the Ask Claude buttons |
hooks/merge.ts | When the merge button shows, and the gh pr merge it runs |
hooks/diff.ts | Change detection for toasts, and the status line summary |
hooks/register.tsx 395 lines1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { CiState, MergeMethod, Pr, Snapshot } from '../types'
5import { ciLine, mergeLine, reviewLine, sizeLine } from './details'
6import type { Line } from './details'
7import { CI_ICON, changes, ref, settle, summary, toastText } from './diff'
8import { GH_CANDIDATES, ghArgs, parsePrs, parseRemote, reasonOf } from './github'
9import type { FetchResult } from './github'
10import { canMerge, mergeArgs, mergeFailure, mergeLabel } from './merge'
11import { ciPrompt, newSessionUrl, stalenessOf, updatePrompt } from './prompts'
12
13const PANE = 'pr-watch'
14const snapshot = atom({ plugin: 'pr-watch', key: 'latest' } as const, null)
15const confirming = atom({ plugin: 'pr-watch', key: 'confirming' } as const, null)
16const merging = atom({ plugin: 'pr-watch', key: 'merging' } as const, null)
17
18const CI_COLOR: Record<CiState, Color> = {
19 success: 'success',
20 failure: 'error',
21 pending: 'warning',
22 none: 'subtle',
23}
24
25// CI, review and merge state have their own lines; the header keeps who and what kind.
26const tagsOf = (p: Pr): { text: string; color: Color }[] => [
27 ...(p.isDraft ? [{ text: 'draft', color: 'subtle' }] : []),
28 ...(p.role === 'review' ? [{ text: `by ${p.author}`, color: 'subtle' }] : []),
29]
30
31const hhmm = (ms: number): string => {
32 const at = new Date(ms)
33 const pad = (n: number) => String(n).padStart(2, '0')
34
35 return `${pad(at.getHours())}:${pad(at.getMinutes())}`
36}
37
38// Module state starts over on every reload; the snapshot itself lives in $.state.
39let pollSeconds = 120
40let isPolling = false
41let isPollWanted = false
42let ghPath: string | undefined
43let repoOf: { cwd: string; repo: string | null } | undefined
44// PRs merged from the pane: their "closed or merged" toast would only repeat "Merged".
45const mergedHere = new Set<string>()
46
47/** The session folder's github.com repo (`owner/name`), or null; asks git once per folder. */
48async function sessionRepo($: EngineInterface): Promise<string | null> {
49 const cwd = await $.session.cwd()
50 if (repoOf?.cwd === cwd) return repoOf.repo
51
52 let repo: string | null = null
53 try {
54 const ran = await $.process.run(['git', 'remote', 'get-url', 'origin'], {
55 cwd,
56 timeoutMs: 5_000,
57 })
58 repo = ran.exitCode === 0 ? parseRemote(ran.stdout) : null
59 } catch {
60 repo = null // no git here, or the folder is gone
61 }
62 repoOf = { cwd, repo }
63
64 return repo
65}
66
67type GhRun =
68 | { ok: true; exitCode: number; stdout: string; stderr: string }
69 | { ok: false; error: string }
70
71/** Runs the person's `gh` with `args`, finding it on first use. */
72async function runGh($: EngineInterface, args: readonly string[]): Promise<GhRun> {
73 for (const gh of ghPath === undefined ? GH_CANDIDATES : [ghPath]) {
74 let ran
75 try {
76 ran = await $.process.run([gh, ...args], { timeoutMs: 30_000 })
77 } catch (err) {
78 if (/timed? ?out|still running/i.test(String(err)))
79 return { ok: false, error: 'gh timed out' }
80 continue // not installed here; try the next place
81 }
82 ghPath = gh
83
84 return { ok: true, exitCode: ran.exitCode, stdout: ran.stdout, stderr: ran.stderr }
85 }
86 ghPath = undefined
87
88 return { ok: false, error: 'gh not found' }
89}
90
91/** Runs the repo's PR query. */
92async function fetchPrs($: EngineInterface, repo: string): Promise<FetchResult> {
93 const ran = await runGh($, ghArgs(repo))
94 if (!ran.ok) return ran
95 if (ran.exitCode !== 0) return { ok: false, error: reasonOf(ran.stderr) }
96
97 return parsePrs(ran.stdout)
98}
99
100/** One poll: find the repo, fetch, toast what changed since the last good poll, store, show. */
101async function pollOnce($: EngineInterface): Promise<void> {
102 const repo = await sessionRepo($)
103 if (repo === null) {
104 await update($, snapshot, () => ({
105 repo: null,
106 mergeMethod: null,
107 prs: [],
108 fetchedAt: null,
109 error: null,
110 }))
111 $.ui.status(undefined)
112 return
113 }
114
115 // Another repo's PRs are no baseline: a switch starts over without toasts.
116 const stored = await read($, snapshot)
117 const prev = stored?.repo === repo ? stored : null
118 const result = await fetchPrs($, repo)
119
120 if (!result.ok) {
121 const failed: Snapshot = {
122 repo,
123 mergeMethod: prev?.mergeMethod ?? null,
124 prs: prev?.prs ?? [],
125 fetchedAt: prev?.fetchedAt ?? null,
126 error: result.error,
127 }
128 await update($, snapshot, () => failed)
129 $.ui.status(summary(failed))
130 return
131 }
132
133 const prs = settle(prev?.prs, result.prs)
134 // The first good poll is the baseline: nothing is new to the person yet.
135 if (prev !== null && prev.fetchedAt !== null) {
136 const found = changes(prev.prs, prs).filter(
137 c => !(c.what === 'closed or merged' && mergedHere.has(c.pr.url)),
138 )
139 const text = toastText(found)
140 if (text !== undefined) $.ui.toast(text, { timeoutMs: 10_000 })
141 }
142
143 mergedHere.clear()
144 const fresh: Snapshot = {
145 repo,
146 mergeMethod: result.mergeMethod,
147 prs,
148 fetchedAt: await $.clock.now(),
149 error: null,
150 }
151 await update($, snapshot, () => fresh)
152 $.ui.status(summary(fresh))
153}
154
155/** Polls now; a call while one runs is folded into one more run after it. */
156async function poll($: EngineInterface): Promise<void> {
157 if (isPolling) {
158 isPollWanted = true
159 return
160 }
161 isPolling = true
162 try {
163 do {
164 isPollWanted = false
165 await pollOnce($)
166 } while (isPollWanted)
167 } catch (err) {
168 $.ui.status(`PRs ⚠ ${String(err).slice(0, 80)}`)
169 } finally {
170 isPolling = false
171 }
172}
173
174/**
175 * Hands `text` to Claude; `what` names the ask in the toast. In the desktop
176 * app it opens a new Claude Code session on this session's folder with the
177 * prompt in its composer, for the person to send; elsewhere, or if that
178 * fails, it queues the prompt in this conversation.
179 */
180async function askClaude(
181 $: EngineInterface,
182 p: Pr,
183 text: string,
184 what: string,
185 surface: RenderSurface,
186): Promise<void> {
187 if (surface === 'desktop') {
188 try {
189 const url = newSessionUrl(await $.session.cwd(), text)
190 const ran = await $.process.run(['/usr/bin/open', url], { timeoutMs: 10_000 })
191 if (ran.exitCode === 0) {
192 $.ui.toast(`Opened a new session to ${what} on ${ref(p)}; send the prompt there to start`)
193 return
194 }
195 } catch {
196 // No `open` on this machine: ask in this conversation instead.
197 }
198 }
199
200 void $.prompt
201 .submit({ text, asUser: true })
202 .catch(() => $.ui.toast(`pr-watch: could not queue the prompt for ${ref(p)}`))
203 $.ui.toast(`Asked Claude to ${what} on ${ref(p)}; it starts once the session is idle`)
204}
205
206/** Merges `p` as the pane showed it (Confirm was pressed), says how it went, and refreshes. */
207async function mergePr($: EngineInterface, p: Pr, method: MergeMethod): Promise<void> {
208 if ((await read($, merging)) !== null) return
209 await update($, confirming, () => null)
210 await update($, merging, () => p.url)
211
212 let said: string
213 try {
214 const ran = await runGh($, mergeArgs(p, method))
215 if (!ran.ok) said = `Could not merge ${ref(p)}: ${ran.error}`
216 else if (ran.exitCode !== 0) said = `Could not merge ${ref(p)}: ${mergeFailure(ran.stderr)}`
217 else {
218 mergedHere.add(p.url)
219 said = `Merged ${ref(p)} into ${p.baseBranch || 'main'}`
220 }
221 } finally {
222 await update($, merging, () => null)
223 }
224 $.ui.toast(said, { timeoutMs: 10_000 })
225 await poll($)
226}
227
228export const register: Register = (on, options) => {
229 pollSeconds = Math.max(30, Number(options.pollSeconds ?? 120) || 120)
230
231 on('session.start', async ($, e, next) => {
232 await $.command.register({
233 name: 'prs',
234 description: "Show your open PRs and review requests in this session's GitHub repo",
235 })
236
237 // After a hot reload the session's last snapshot is still there.
238 const prev = await read($, snapshot)
239 if (prev !== null) $.ui.status(summary(prev))
240
241 void poll($)
242 $.clock.every(pollSeconds * 1000, () => void poll($))
243
244 return next(e)
245 })
246
247 // The session moved to another folder, maybe another repo: look again now.
248 on('classic.CwdChanged', ($, e, next) => {
249 void poll($)
250
251 return next(e)
252 })
253
254 // Opens the pane and says nothing in the transcript.
255 on('command.run', { command: 'prs' }, async $ => {
256 const opened = await $.ui.open({ id: PANE, title: 'Pull requests' })
257 if (!opened.isPlaced) $.ui.toast('pr-watch: the PR pane opens once the window has room')
258 void poll($)
259
260 return {}
261 })
262
263 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
264 const { Box, Button, Link, Text } = $.ui.resolve(e)
265 const snap = await read($, snapshot)
266
267 if (snap === null) return <Text dimColor>Checking GitHub…</Text>
268 if (snap.repo === null) {
269 return (
270 <Text dimColor>
271 This session's folder is not a GitHub repo (no github.com origin remote), so there are no
272 PRs to show.
273 </Text>
274 )
275 }
276
277 const confirmingUrl = await read($, confirming)
278 const mergingUrl = await read($, merging)
279 const method = snap.mergeMethod ?? null
280 const mine = snap.prs.filter(p => p.role === 'mine')
281 const asked = snap.prs.filter(p => p.role === 'review')
282 const now = snap.fetchedAt ?? 0
283
284 const detail = (label: string, line: Line) => (
285 <Box flexDirection="row" paddingLeft={2}>
286 <Box width={8} flexShrink={0}>
287 <Text dimColor>{label}</Text>
288 </Box>
289 <Text color={line.color ?? 'text'}>{line.text}</Text>
290 </Box>
291 )
292
293 const row = (p: Pr) => {
294 const stale = p.role === 'mine' ? stalenessOf(p) : undefined
295 const mergeWith = method !== null && canMerge(p) ? method : null
296 const isConfirming = mergeWith !== null && confirmingUrl === p.url && mergingUrl !== p.url
297
298 return (
299 <Box flexDirection="column">
300 <Box flexDirection="row" gap={1}>
301 <Text color={CI_COLOR[p.ci]}>{CI_ICON[p.ci]}</Text>
302 <Link href={p.url} label={`#${p.number}`} />
303 {tagsOf(p).map(tag => (
304 <Text color={tag.color}>{tag.text}</Text>
305 ))}
306 </Box>
307 <Box paddingLeft={2}>
308 <Text dimColor wrap="truncate-end">
309 {p.title}
310 </Text>
311 </Box>
312 {detail('CI', ciLine(p))}
313 {p.role === 'mine' && p.ci === 'failure' && (
314 <Box paddingLeft={10}>
315 <Button
316 key={`ask-ci:${p.url}`}
317 label="Ask Claude"
318 onPress={() => askClaude($, p, ciPrompt(p), 'look into failing CI', e.surface)}
319 />
320 </Box>
321 )}
322 {detail('Review', reviewLine(p))}
323 {detail('Merge', mergeLine(p))}
324 {(stale !== undefined || mergeWith !== null) && (
325 <Box paddingLeft={10} flexDirection="row" gap={1} flexWrap="wrap">
326 {stale !== undefined && (
327 <Button
328 key={`ask-update:${p.url}`}
329 label="Ask Claude"
330 onPress={() =>
331 askClaude(
332 $,
333 p,
334 updatePrompt(p, stale),
335 stale === 'conflict'
336 ? 'fix the merge conflicts'
337 : `bring it up to date with ${p.baseBranch || 'main'}`,
338 e.surface,
339 )
340 }
341 />
342 )}
343 {mergeWith !== null && mergingUrl === p.url && <Text color="warning">Merging…</Text>}
344 {mergeWith !== null && mergingUrl !== p.url && !isConfirming && (
345 <Button
346 key={`merge:${p.url}`}
347 label={mergeLabel(mergeWith)}
348 onPress={() => update($, confirming, () => p.url)}
349 />
350 )}
351 </Box>
352 )}
353 {mergeWith !== null && isConfirming && (
354 <Box paddingLeft={10} flexDirection="row" gap={1} flexWrap="wrap">
355 <Text color="warning">
356 {`${mergeLabel(mergeWith)} #${p.number} into ${p.baseBranch || 'main'}?`}
357 </Text>
358 <Button
359 key={`merge-confirm:${p.url}`}
360 label="Confirm"
361 variant="primary"
362 onPress={() => mergePr($, p, mergeWith)}
363 />
364 <Button
365 key={`merge-cancel:${p.url}`}
366 label="Cancel"
367 onPress={() => update($, confirming, () => null)}
368 />
369 </Box>
370 )}
371 {detail('Size', sizeLine(p, now))}
372 </Box>
373 )
374 }
375
376 return (
377 <Box flexDirection="column" gap={1}>
378 <Box flexDirection="row" justifyContent="space-between">
379 <Text dimColor wrap="truncate-end">
380 {snap.repo} ·{' '}
381 {snap.fetchedAt === null ? 'not loaded yet' : `updated ${hhmm(snap.fetchedAt)}`} · every{' '}
382 {pollSeconds}s
383 </Text>
384 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => poll($)} />
385 </Box>
386 {snap.error !== null && <Text color="error">⚠ {snap.error}</Text>}
387 <Text bold>Your PRs ({mine.length})</Text>
388 {mine.length === 0 ? <Text dimColor>None open.</Text> : mine.map(row)}
389 <Text bold>Review requested ({asked.length})</Text>
390 {asked.length === 0 ? <Text dimColor>Nothing waiting on you.</Text> : asked.map(row)}
391 </Box>
392 )
393 })
394}
395hooks/details.ts 96 lines1import type { Color } from 'claude-code'
2
3import type { Pr } from '../types'
4
5/** One detail line of a PR in the pane: its words and, when it signals something, a color. */
6export type Line = { text: string; color?: Color }
7
8/** `a, b, c +2`: at most three names, then how many more. */
9const names = (list: readonly string[]): string =>
10 list.length > 3 ? `${list.slice(0, 3).join(', ')} +${list.length - 3}` : list.join(', ')
11
12const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
13
14/** `✗ 2 failed (checks, test) · 10 passed`: the head commit's checks by outcome. */
15export const ciLine = (p: Pr): Line => {
16 const { passed, pending, skipped } = p.checks
17 // The named failures count even where GitHub's tally of them is missing.
18 const failed = Math.max(p.checks.failed, p.failedChecks.length)
19 if (passed + failed + pending + skipped === 0) return { text: 'no checks', color: 'subtle' }
20
21 const failedNames = p.failedChecks.length > 0 ? ` (${names(p.failedChecks)})` : ''
22 const parts = [
23 ...(failed > 0 ? [`${failed} failed${failedNames}`] : []),
24 ...(pending > 0 ? [`${pending} running`] : []),
25 ...(passed > 0 ? [`${passed} passed`] : []),
26 ...(skipped > 0 ? [`${skipped} skipped`] : []),
27 ].join(' · ')
28
29 if (failed > 0) return { text: `✗ ${parts}`, color: 'error' }
30 if (pending > 0) return { text: `● ${parts}`, color: 'warning' }
31
32 return { text: `✓ ${parts}`, color: 'success' }
33}
34
35/** `✓ approved by a · waiting on you · 2 unresolved threads`. */
36export const reviewLine = (p: Pr): Line => {
37 const { approvedBy, changesBy, waitingOn, unresolvedThreads } = p.reviews
38 const parts = [
39 ...(approvedBy.length > 0 ? [`✓ approved by ${names(approvedBy)}`] : []),
40 ...(changesBy.length > 0 ? [`✎ changes requested by ${names(changesBy)}`] : []),
41 ...(waitingOn.length > 0 ? [`waiting on ${names(waitingOn)}`] : []),
42 ]
43 if (parts.length === 0) parts.push(p.review === 'required' ? 'review required' : 'no reviews yet')
44 if (unresolvedThreads > 0) parts.push(`${plural(unresolvedThreads, 'unresolved thread')}`)
45
46 const text = parts.join(' · ')
47 if (changesBy.length > 0) return { text, color: 'error' }
48 if (waitingOn.includes('you')) return { text, color: 'warning' }
49 if (p.review === 'approved') return { text, color: 'success' }
50
51 return { text }
52}
53
54/** Whether GitHub would let it merge now, and if not, why. */
55export const mergeLine = (p: Pr): Line => {
56 const base = p.baseBranch || 'the base branch'
57 if (p.merge === 'conflict' || p.mergeStatus === 'conflict') {
58 return { text: `⚠ conflicts with ${base}`, color: 'warning' }
59 }
60
61 switch (p.mergeStatus) {
62 case 'ready':
63 return { text: '✓ ready to merge', color: 'success' }
64 case 'behind':
65 return { text: `behind ${base}`, color: 'warning' }
66 case 'blocked':
67 return { text: 'blocked by branch rules (reviews or required checks)' }
68 case 'unstable':
69 return { text: 'mergeable, but some checks fail', color: 'warning' }
70 case 'draft':
71 return { text: 'draft, not ready for review', color: 'subtle' }
72 case 'unknown':
73 return { text: 'GitHub is still checking', color: 'subtle' }
74 }
75}
76
77/** `3h ago`, from a span in milliseconds. */
78export const ago = (ms: number): string => {
79 const minutes = Math.floor(Math.max(0, ms) / 60_000)
80 if (minutes < 1) return 'just now'
81 if (minutes < 60) return `${minutes}m ago`
82 if (minutes < 60 * 24) return `${Math.floor(minutes / 60)}h ago`
83
84 return `${Math.floor(minutes / (60 * 24))}d ago`
85}
86
87/** `+120 −30 · 4 files · updated 2h ago`, the age as of `now`. */
88export const sizeLine = (p: Pr, now: number): Line => {
89 const updated = p.updatedAt > 0 ? [`updated ${ago(now - p.updatedAt)}`] : []
90 const text = [`+${p.additions} −${p.deletions}`, plural(p.changedFiles, 'file'), ...updated].join(
91 ' · ',
92 )
93
94 return { text, color: 'subtle' }
95}
96hooks/diff.ts 124 lines1import type { CiState, Pr, Snapshot } from '../types'
2
3/** One status change worth telling the person about. */
4export type Change = { icon: string; what: string; pr: Pr }
5
6/** `app#123`: the repo without its owner, then the number. */
7export const ref = (pr: Pr): string => `${pr.repo.split('/').pop()}#${pr.number}`
8
9export const CI_ICON: Record<CiState, string> = {
10 success: '✓',
11 failure: '✗',
12 pending: '●',
13 none: '·',
14}
15
16/**
17 * GitHub answers `UNKNOWN` for mergeability and merge state while it
18 * recomputes (after every push); keep the previous known values so a conflict
19 * is not reported twice and the Merge line and its buttons don't flicker.
20 */
21export const settle = (prev: readonly Pr[] | undefined, next: readonly Pr[]): Pr[] => {
22 const before = new Map((prev ?? []).map(p => [p.url, p]))
23
24 return next.map(p => {
25 const old = before.get(p.url)
26
27 if (old === undefined) return p
28
29 return {
30 ...p,
31 merge: p.merge === 'unknown' ? old.merge : p.merge,
32 mergeStatus: p.mergeStatus === 'unknown' ? old.mergeStatus : p.mergeStatus,
33 }
34 })
35}
36
37/** What changed between two polls, in the order of the new poll. */
38export const changes = (prev: readonly Pr[], next: readonly Pr[]): Change[] => {
39 const before = new Map(prev.map(p => [p.url, p]))
40 const after = new Set(next.map(p => p.url))
41 const found: Change[] = []
42
43 for (const p of next) {
44 const old = before.get(p.url)
45
46 if (old === undefined) {
47 if (p.role === 'review') found.push({ icon: '👀', what: 'review requested', pr: p })
48 continue
49 }
50 if (p.role !== 'mine') continue
51
52 if (p.ci === 'failure' && old.ci !== 'failure') {
53 found.push({ icon: '✗', what: 'CI failed', pr: p })
54 }
55 if (p.ci === 'success' && old.ci !== 'success') {
56 found.push({ icon: '✓', what: 'CI passed', pr: p })
57 }
58 if (p.review !== old.review && p.review === 'approved') {
59 found.push({ icon: '✓', what: 'approved', pr: p })
60 }
61 if (p.review !== old.review && p.review === 'changes') {
62 found.push({ icon: '✎', what: 'changes requested', pr: p })
63 }
64 if (p.merge === 'conflict' && old.merge !== 'conflict') {
65 found.push({ icon: '⚠', what: 'merge conflict', pr: p })
66 }
67 }
68
69 for (const p of prev) {
70 if (p.role === 'mine' && !after.has(p.url)) {
71 found.push({ icon: '⏏', what: 'closed or merged', pr: p })
72 }
73 }
74
75 return found
76}
77
78/** One toast line for a poll's changes, or undefined when there are none. */
79export const toastText = (found: readonly Change[]): string | undefined => {
80 const [only] = found
81 if (only === undefined) return undefined
82 if (found.length === 1) return `${only.icon} ${ref(only.pr)} ${only.what}: ${only.pr.title}`
83
84 const list = found.map(c => `${c.icon} ${ref(c.pr)} ${c.what}`).join(' · ')
85
86 return `${found.length} PR updates: ${list}`
87}
88
89/**
90 * The status line, `app · PRs ✓6 ✗1 ●2 · 2 conflicts · 2 to review`, or
91 * undefined (no line) when the session is not in a GitHub repo.
92 */
93export const summary = (snap: Snapshot): string | undefined => {
94 if (snap.repo === null) return undefined
95
96 return `${snap.repo.split('/').pop()} · ${countsOf(snap)}`
97}
98
99const countsOf = (snap: Snapshot): string => {
100 const error = snap.error === null ? [] : [`⚠ ${snap.error}`]
101 if (snap.fetchedAt === null) return `PRs ${error.join('') || '…'}`
102
103 const mine = snap.prs.filter(p => p.role === 'mine')
104 const toReview = snap.prs.length - mine.length
105 const count = (ci: CiState) => mine.filter(p => p.ci === ci).length
106 const conflicts = mine.filter(p => p.merge === 'conflict').length
107
108 const ci =
109 (['success', 'failure', 'pending'] as const)
110 .filter(state => count(state) > 0)
111 .map(state => `${CI_ICON[state]}${count(state)}`)
112 .join(' ') || (mine.length > 0 ? `${mine.length} open` : '')
113 const parts = [
114 ...(conflicts > 0 ? [`${conflicts} conflict${conflicts === 1 ? '' : 's'}`] : []),
115 ...(toReview > 0 ? [`${toReview} to review`] : []),
116 ...error,
117 ]
118
119 if (ci === '' && parts.length === 0) return 'PRs: none open'
120 if (ci === '') return `PRs ${parts.join(' · ')}`
121
122 return [`PRs ${ci}`, ...parts].join(' · ')
123}
124hooks/github.ts 291 lines1import type {
2 Checks,
3 CiState,
4 MergeMethod,
5 MergeState,
6 MergeStatus,
7 Pr,
8 ReviewState,
9 Reviews,
10} from '../types'
11
12// Two searches in one request: the PRs you opened in the repo, and the ones
13// there waiting on your review, plus the merge method GitHub preselects for
14// you there. About 6 rate-limit points per poll.
15export const QUERY = `
16query($mine: String!, $review: String!, $owner: String!, $name: String!) {
17 viewer { login }
18 repo: repository(owner: $owner, name: $name) { viewerDefaultMergeMethod }
19 mine: search(query: $mine, type: ISSUE, first: 50) { nodes { ...pr } }
20 review: search(query: $review, type: ISSUE, first: 50) { nodes { ...pr } }
21}
22fragment pr on PullRequest {
23 number title url isDraft mergeable mergeStateStatus reviewDecision
24 headRefName headRefOid baseRefName updatedAt additions deletions changedFiles
25 author { login }
26 repository { nameWithOwner }
27 latestOpinionatedReviews(first: 10) { nodes { state author { login } } }
28 reviewRequests(first: 10) { nodes { requestedReviewer {
29 __typename
30 ... on User { login }
31 ... on Team { name }
32 ... on Bot { login }
33 ... on Mannequin { login }
34 } } }
35 reviewThreads(first: 50) { nodes { isResolved } }
36 commits(last: 1) { nodes { commit { statusCheckRollup {
37 state
38 contexts(first: 50) {
39 checkRunCountsByState { state count }
40 statusContextCountsByState { state count }
41 nodes {
42 __typename
43 ... on CheckRun { name conclusion }
44 ... on StatusContext { context state }
45 }
46 }
47 } } } }
48}`
49
50// The session's PATH may not reach Homebrew's bin (an app-started session).
51export const GH_CANDIDATES = ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh']
52/** The `gh` arguments of one poll, both searches held to `repo` (`owner/name`). */
53export const ghArgs = (repo: string): string[] => {
54 const [owner = '', name = ''] = repo.split('/')
55
56 return [
57 'api',
58 'graphql',
59 '-f',
60 `query=${QUERY}`,
61 '-f',
62 `mine=is:open is:pr archived:false author:@me repo:${repo}`,
63 '-f',
64 `review=is:open is:pr archived:false review-requested:@me repo:${repo}`,
65 '-f',
66 `owner=${owner}`,
67 '-f',
68 `name=${name}`,
69 ]
70}
71
72/**
73 * `owner/name` of a github.com remote URL (https, ssh or scp form), or null
74 * for any other host or a URL that names no repo.
75 */
76export const parseRemote = (url: string): string | null => {
77 const found = /github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(url.trim())
78
79 return found === null ? null : `${found[1]}/${found[2]}`
80}
81
82// Check run conclusions and status context states, by how the pane counts them.
83const FAILED = new Set([
84 'FAILURE',
85 'ERROR',
86 'TIMED_OUT',
87 'ACTION_REQUIRED',
88 'STARTUP_FAILURE',
89 'CANCELLED',
90])
91const PASSED = new Set(['SUCCESS'])
92const PENDING = new Set(['PENDING', 'EXPECTED', 'IN_PROGRESS', 'QUEUED', 'WAITING'])
93const SKIPPED = new Set(['SKIPPED', 'NEUTRAL', 'STALE'])
94
95type RawCount = { state?: string; count?: number }
96type RawContext = {
97 __typename?: string
98 name?: string
99 conclusion?: string | null
100 context?: string
101 state?: string
102}
103type RawPr = {
104 number?: number
105 title?: string
106 url?: string
107 isDraft?: boolean
108 mergeable?: string
109 mergeStateStatus?: string
110 reviewDecision?: string | null
111 headRefName?: string
112 headRefOid?: string
113 baseRefName?: string
114 updatedAt?: string
115 additions?: number
116 deletions?: number
117 changedFiles?: number
118 author?: { login?: string } | null
119 repository?: { nameWithOwner?: string }
120 latestOpinionatedReviews?: { nodes?: { state?: string; author?: { login?: string } | null }[] }
121 reviewRequests?: {
122 nodes?: { requestedReviewer?: { login?: string; name?: string } | null }[]
123 }
124 reviewThreads?: { nodes?: { isResolved?: boolean }[] }
125 commits?: {
126 nodes?: {
127 commit?: {
128 statusCheckRollup?: {
129 state?: string
130 contexts?: {
131 checkRunCountsByState?: RawCount[]
132 statusContextCountsByState?: RawCount[]
133 nodes?: RawContext[]
134 }
135 } | null
136 }
137 }[]
138 }
139}
140type RawResponse = {
141 data?: {
142 viewer?: { login?: string }
143 repo?: { viewerDefaultMergeMethod?: string } | null
144 mine?: { nodes?: RawPr[] }
145 review?: { nodes?: RawPr[] }
146 }
147 errors?: { message?: string }[]
148}
149
150export type FetchResult =
151 | { ok: true; prs: Pr[]; mergeMethod: MergeMethod | null }
152 | { ok: false; error: string }
153
154const METHODS: Record<string, MergeMethod> = { MERGE: 'merge', SQUASH: 'squash', REBASE: 'rebase' }
155
156const ciOf = (state: string | undefined): CiState => {
157 if (state === 'SUCCESS') return 'success'
158 if (state === 'FAILURE' || state === 'ERROR') return 'failure'
159 if (state === 'PENDING' || state === 'EXPECTED') return 'pending'
160 return 'none'
161}
162
163const reviewOf = (decision: string | null | undefined): ReviewState => {
164 if (decision === 'APPROVED') return 'approved'
165 if (decision === 'CHANGES_REQUESTED') return 'changes'
166 if (decision === 'REVIEW_REQUIRED') return 'required'
167 return 'none'
168}
169
170const mergeOf = (mergeable: string | undefined): MergeState => {
171 if (mergeable === 'MERGEABLE') return 'clean'
172 if (mergeable === 'CONFLICTING') return 'conflict'
173 return 'unknown'
174}
175
176const MERGE_STATUS: Record<string, MergeStatus> = {
177 CLEAN: 'ready',
178 HAS_HOOKS: 'ready',
179 BEHIND: 'behind',
180 BLOCKED: 'blocked',
181 UNSTABLE: 'unstable',
182 DIRTY: 'conflict',
183 DRAFT: 'draft',
184}
185
186const checksOf = (counts: readonly RawCount[]): Checks => {
187 const checks: Checks = { passed: 0, failed: 0, pending: 0, skipped: 0 }
188 for (const { state = '', count = 0 } of counts) {
189 if (PASSED.has(state)) checks.passed += count
190 else if (FAILED.has(state)) checks.failed += count
191 else if (PENDING.has(state)) checks.pending += count
192 else if (SKIPPED.has(state)) checks.skipped += count
193 }
194
195 return checks
196}
197
198const reviewsOf = (raw: RawPr, viewer: string | undefined): Reviews => {
199 const by = (state: string) =>
200 (raw.latestOpinionatedReviews?.nodes ?? [])
201 .filter(r => r.state === state)
202 .map(r => r.author?.login ?? 'ghost')
203 const waitingOn = (raw.reviewRequests?.nodes ?? [])
204 .map(r => r.requestedReviewer?.login ?? r.requestedReviewer?.name)
205 .filter((name): name is string => name !== undefined)
206 .map(name => (name === viewer ? 'you' : name))
207
208 return {
209 approvedBy: by('APPROVED'),
210 changesBy: by('CHANGES_REQUESTED'),
211 waitingOn,
212 unresolvedThreads: (raw.reviewThreads?.nodes ?? []).filter(t => t.isResolved === false).length,
213 }
214}
215
216const toPr = (raw: RawPr, role: Pr['role'], viewer: string | undefined): Pr | undefined => {
217 if (raw.url === undefined || raw.number === undefined) return undefined
218
219 const rollup = raw.commits?.nodes?.[0]?.commit?.statusCheckRollup ?? null
220 const contexts = rollup?.contexts
221 const failedChecks = (contexts?.nodes ?? [])
222 .filter(c => FAILED.has(c.conclusion ?? c.state ?? ''))
223 .map(c => c.name ?? c.context ?? 'unnamed check')
224 const updatedAt = Date.parse(raw.updatedAt ?? '')
225
226 return {
227 url: raw.url,
228 repo: raw.repository?.nameWithOwner ?? '',
229 number: raw.number,
230 title: raw.title ?? '',
231 author: raw.author?.login ?? 'ghost',
232 branch: raw.headRefName ?? '',
233 baseBranch: raw.baseRefName ?? '',
234 headSha: raw.headRefOid ?? '',
235 isDraft: raw.isDraft === true,
236 ci: ciOf(rollup?.state),
237 checks: checksOf([
238 ...(contexts?.checkRunCountsByState ?? []),
239 ...(contexts?.statusContextCountsByState ?? []),
240 ]),
241 failedChecks,
242 review: reviewOf(raw.reviewDecision),
243 reviews: reviewsOf(raw, viewer),
244 merge: mergeOf(raw.mergeable),
245 mergeStatus: MERGE_STATUS[raw.mergeStateStatus ?? ''] ?? 'unknown',
246 additions: raw.additions ?? 0,
247 deletions: raw.deletions ?? 0,
248 changedFiles: raw.changedFiles ?? 0,
249 updatedAt: Number.isNaN(updatedAt) ? 0 : updatedAt,
250 role,
251 }
252}
253
254/** The PRs in a GraphQL answer: yours first, then review requests not already listed. */
255export const parsePrs = (body: string): FetchResult => {
256 let raw: RawResponse
257 try {
258 raw = JSON.parse(body) as RawResponse
259 } catch {
260 return { ok: false, error: 'GitHub answered something that is not JSON' }
261 }
262 const message = raw.errors?.[0]?.message
263 if (message !== undefined || raw.data === undefined) {
264 return { ok: false, error: message ?? 'GitHub answered no data' }
265 }
266
267 const viewer = raw.data.viewer?.login
268 const mine = (raw.data.mine?.nodes ?? []).map(p => toPr(p, 'mine', viewer))
269 const review = (raw.data.review?.nodes ?? []).map(p => toPr(p, 'review', viewer))
270 const prs: Pr[] = []
271 const seen = new Set<string>()
272 for (const p of [...mine, ...review]) {
273 if (p === undefined || seen.has(p.url)) continue
274 seen.add(p.url)
275 prs.push(p)
276 }
277
278 const mergeMethod = METHODS[raw.data.repo?.viewerDefaultMergeMethod ?? ''] ?? null
279
280 return { ok: true, prs, mergeMethod }
281}
282
283/** A short reason for a failed `gh` run, fit for the status line. */
284export const reasonOf = (stderr: string): string => {
285 if (/auth login|not logged in/i.test(stderr)) return 'gh not logged in'
286 if (/could not resolve|network|timeout|dial tcp/i.test(stderr)) return 'GitHub unreachable'
287 const line = stderr.trim().split('\n')[0] ?? ''
288
289 return line === '' ? 'gh failed' : line.slice(0, 80)
290}
291hooks/merge.ts 53 lines1import type { MergeMethod, Pr } from '../types'
2
3/** Approved: GitHub's review decision says so, or, where no review is required, someone approved and nobody asked for changes. */
4const isApproved = (p: Pr): boolean =>
5 p.review === 'approved' ||
6 (p.review === 'none' && p.reviews.approvedBy.length > 0 && p.reviews.changesBy.length === 0)
7
8/**
9 * Whether a PR of mine gets the merge button: approved, and nothing GitHub
10 * would refuse it for. Behind is allowed; a repo that requires up-to-date
11 * branches refuses it, and the toast says why.
12 */
13export const canMerge = (p: Pr): boolean =>
14 p.role === 'mine' &&
15 isApproved(p) &&
16 !p.isDraft &&
17 p.merge !== 'conflict' &&
18 (p.mergeStatus === 'ready' || p.mergeStatus === 'behind') &&
19 p.ci !== 'failure' &&
20 p.ci !== 'pending' &&
21 p.headSha !== ''
22
23/**
24 * The `gh` arguments that merge `p`: by `--repo` and without
25 * `--delete-branch`, so no local checkout or branch is touched, and pinned to
26 * the head commit the pane showed, so a later push is refused. Deleting the
27 * branch is left to the repo's own setting, as on GitHub.
28 */
29export const mergeArgs = (p: Pr, method: MergeMethod): string[] => [
30 'pr',
31 'merge',
32 String(p.number),
33 '--repo',
34 p.repo,
35 `--${method}`,
36 '--match-head-commit',
37 p.headSha,
38]
39
40/** The button label, as GitHub words its own. */
41export const mergeLabel = (method: MergeMethod): string =>
42 ({ merge: 'Merge', squash: 'Squash and merge', rebase: 'Rebase and merge' })[method]
43
44/** Why `gh pr merge` refused, from its standard error, fit for a toast. */
45export const mergeFailure = (stderr: string): string => {
46 if (/head branch was modified|head commit/i.test(stderr)) {
47 return 'the PR has new commits since the pane last loaded; refresh and look again'
48 }
49 const line = (stderr.trim().split('\n')[0] ?? '').replace(/^[X✗]\s+/, '')
50
51 return line === '' ? 'gh pr merge failed' : line.slice(0, 200)
52}
53hooks/prompts.ts 61 lines1import type { Pr } from '../types'
2
3/** Why a PR of mine is out of date with its base branch, if it is. */
4export type Staleness = 'conflict' | 'behind'
5
6export const stalenessOf = (p: Pr): Staleness | undefined => {
7 if (p.merge === 'conflict' || p.mergeStatus === 'conflict') return 'conflict'
8 if (p.mergeStatus === 'behind') return 'behind'
9 return undefined
10}
11
12const header = (p: Pr) => `My pull request ${p.repo}#${p.number} "${p.title}" (${p.url})`
13
14/** The prompt behind Ask Claude on a PR of mine whose CI fails. */
15export const ciPrompt = (p: Pr): string =>
16 [
17 `CI is failing on ${header(p)}, branch \`${p.branch}\`.`,
18 `Failed checks: ${p.failedChecks.length > 0 ? p.failedChecks.join(', ') : 'not named by GitHub'}.`,
19 `Use \`gh pr checks ${p.number} --repo ${p.repo}\` and \`gh run view <run-id> --repo ${p.repo} --log-failed\` to read the failing logs.`,
20 'Explain the root cause and propose a fix. Do not push or change the PR without asking me first.',
21 ].join('\n')
22
23/**
24 * The prompt behind Ask Claude on a PR of mine that conflicts with, or is
25 * behind, its base branch: Claude looks into it and picks the fix itself.
26 */
27export const updatePrompt = (p: Pr, why: Staleness): string => {
28 const base = p.baseBranch || 'main'
29 const isConflict = why === 'conflict'
30 const options = isConflict
31 ? `merging \`${base}\` into the branch or rebasing it onto \`${base}\``
32 : `merging \`${base}\` into the branch, rebasing it onto \`${base}\`, or GitHub's update-branch (\`gh pr update-branch ${p.number} --repo ${p.repo}\`, with or without \`--rebase\`)`
33
34 return [
35 `${header(p)} ${isConflict ? 'has merge conflicts with' : 'is behind'} \`${base}\`; its branch is \`${p.branch}\`.`,
36 'Look into it and decide the best way to fix it:',
37 `1. Run \`git fetch origin\` and see how far \`${p.branch}\` is behind \`origin/${base}\`${isConflict ? ' and which files conflict' : ''}.`,
38 ...(isConflict
39 ? []
40 : [
41 " If updating isn't needed (nothing requires the branch to be up to date and it merges cleanly), tell me so and stop.",
42 ]),
43 `2. Choose between ${options}. Weigh the repo's conventions (its docs and recent history), whether branch protection requires the branch to be up to date, and any reviews a force-push would disrupt. Tell me what you chose and why.`,
44 `3. Do the work on \`${p.branch}\` in a separate git worktree so my current checkout and any uncommitted changes stay untouched${isConflict ? ', resolving each conflict so that the intent of both sides is kept' : ''}.`,
45 `4. Run the project's tests and type-check${isConflict ? ', then show me which files conflicted and how you resolved each one' : ''}.`,
46 'Do not push, force-push or update the PR on GitHub without asking me first.',
47 ].join('\n')
48}
49
50/** The most prompt text the desktop app takes from a link. */
51export const LINK_PROMPT_LIMIT = 14_336
52
53/**
54 * The desktop app's link for a new Claude Code session on `folder` with
55 * `prompt` in its composer; the person sends it from there.
56 */
57export const newSessionUrl = (folder: string, prompt: string): string =>
58 `claude://code/new?folder=${encodeURIComponent(folder)}&q=${encodeURIComponent(
59 prompt.slice(0, LINK_PROMPT_LIMIT),
60 )}&source=pr-watch`
61types/index.d.ts 76 lines1export type CiState = 'success' | 'failure' | 'pending' | 'none'
2export type ReviewState = 'approved' | 'changes' | 'required' | 'none'
3export type MergeState = 'clean' | 'conflict' | 'unknown'
4/** GitHub's merge state status, as the pane words it. */
5export type MergeStatus = 'ready' | 'behind' | 'blocked' | 'unstable' | 'conflict' | 'draft' | 'unknown'
6
7/** How GitHub merges a PR: what `gh pr merge --merge | --squash | --rebase` names. */
8export type MergeMethod = 'merge' | 'squash' | 'rebase'
9
10/** The head commit's checks, counted by outcome. */
11export type Checks = { passed: number; failed: number; pending: number; skipped: number }
12
13/** Who has reviewed, who still has to, and the open review threads. */
14export type Reviews = {
15 approvedBy: string[]
16 changesBy: string[]
17 /** Requested reviewers (users or teams); `you` stands for the viewer. */
18 waitingOn: string[]
19 unresolvedThreads: number
20}
21
22/** One open pull request, as the last poll saw it. */
23export type Pr = {
24 url: string
25 repo: string
26 number: number
27 title: string
28 author: string
29 branch: string
30 baseBranch: string
31 /** The head commit; a merge is refused if the PR has moved past it. */
32 headSha: string
33 isDraft: boolean
34 ci: CiState
35 checks: Checks
36 failedChecks: string[]
37 review: ReviewState
38 reviews: Reviews
39 merge: MergeState
40 mergeStatus: MergeStatus
41 additions: number
42 deletions: number
43 changedFiles: number
44 /** Last update on GitHub, epoch milliseconds (0 when GitHub did not say). */
45 updatedAt: number
46 /** `mine`: you opened it; `review`: your review is requested. */
47 role: 'mine' | 'review'
48}
49
50/**
51 * The session's repo (`owner/name`, null: not a GitHub repo), its PRs as of
52 * the last good poll, when that ran (null: none yet), and the last error.
53 */
54export type Snapshot = {
55 repo: string | null
56 /** The method GitHub preselects for you in this repo (null: unknown). */
57 mergeMethod: MergeMethod | null
58 prs: Pr[]
59 fetchedAt: number | null
60 error: string | null
61}
62
63declare module 'claude-code' {
64 interface PluginState {
65 // `latest` replaced `snapshot` when PRs gained their detail fields, so a
66 // reload never draws a PR stored in the older shape.
67 'pr-watch': {
68 latest: Snapshot | null
69 /** The PR (by URL) whose merge waits for Confirm, if any. */
70 confirming: string | null
71 /** The PR (by URL) being merged right now, if any. */
72 merging: string | null
73 }
74 }
75}
76