Watch GitHub PRs from a Claude Code session: merge state, review and required checks above the prompt, with alerts when they change

Watch GitHub pull requests without leaving your Claude Code session.
Paste a PR URL, or let Claude open one, and it gets one line above the prompt: merge state, review decision and required checks, refreshed every minute. When a check flips or the merge state moves you get a toast, a one-second flash and a sound. You keep working; the PR tells you when it needs you.
![]()
![]()
One PR is always one line; the details panel names the checks. Reading the line explains each part.
The plugin is built on Claude Code function hooks: TypeScript that runs inside Claude Code's own process, instead of shell-command hooks. They are in early access, behind an environment variable that the quick start sets.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 set. The quick start shows where.gh), logged in with access to the repos you watch. Check with gh auth status.afplay with the system sounds) and open. On Linux, xdg-open is tried when open is absent, and there is no sound.~/.claude/settings.json (create the file if it does not exist, or merge the env key into what is there). Without it the plugin installs fine but does nothing. {
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
This also loads the hooks module of any other installed plugin that ships one. For a single session instead, prefix the command: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.
claude plugin marketplace add sezaakgun/cc-pr-tracker
claude plugin install cc-pr-tracker@cc-pr-tracker
claude, paste a PR URL as the whole prompt and press Enter. No model turn runs; Claude Code shows Prompt dropped by a hook: watching owner/repo#N. Several URLs at once, separated by spaces or newlines, are all watched.![]()
If the line shows gh failed: … instead, see Troubleshooting.
To try it without installing, or to hack on it, clone and load it for one session:
git clone https://github.com/sezaakgun/cc-pr-tracker
cd cc-pr-tracker
claude --plugin-dir .
The repo's own .claude/settings.json sets the variable, so sessions started inside the folder need no prefix.
gh pr create prints when it runs through the Bash tool. Subagent answers are not scanned.repo#number (needs a terminal that renders hyperlinks), or hover the line and press open.details. A side panel lists every required check and every failing optional one, linked to its run when the run has an https link.![]()
mute. The line keeps updating but that PR no longer toasts, flashes or plays a sound; the line ends in · muted. Press unmute to turn alerts back on./config and turn on Mute all PR alerts. It applies at once, is saved across sessions, and every line ends in · muted while it is on. Turn it off to get alerts back; PRs you muted one by one stay muted.×, or paste the same URL again as the whole prompt. A paste of several URLs toggles each one; a URL inside a normal prompt never stops anything.Several PRs stack, one line each, in the order you added them.
repo#number, then draft, merged or closed when the PR is not simply open. A merged or closed PR keeps its line until you stop it.clean, has_hooks) means mergeable now. Yellow (behind, unstable) means update the branch or an optional check failed. Red (blocked, dirty) means a required check or review is missing, or there are conflicts. Gray (draft, unknown) needs no action; unknown usually resolves on the next poll.approved in green, changes requested in red, review required in yellow, or no review in gray when the repo has no review rules.✓N passed, ✗N failed or cancelled, ●N pending. Skipped checks are not counted and show as ○ only in the details panel. Failing optional checks are summarised as (+N optional ✗) and listed there too.· refresh failed in red at the end means the last poll errored and the line shows the previous values.Every poll is compared with the previous one. A required check changing bucket (for example pending → fail, or a new check appearing) or the merge state moving (for example blocked → clean) triggers:
my-service#42 lint: pending → fail● PR checks changedTwo things never alert: the first load of a PR, and a move into or out of GitHub's temporary unknown merge state.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is set in ~/.claude/settings.json under env (see Quick start), and that the plugin is loaded with /plugins.gh failed: … on the line. Run gh pr view <url> in a terminal. Usually gh is not logged in or has no access to that repo.Required checks: none reported in details. The repo has no branch protection with required checks. The line still shows the merge state and review./System/Library/Sounds present plays sounds.open.gh, one GraphQL call per PR per poll.claude -p run never draws. Only interactive terminal sessions show the UI.gh pr create output.github.com URLs are recognised; GitHub Enterprise hosts are not.refresh failed and the next one retries.The plugin is one hooks module, hooks/register.tsx. It hooks five events:
prompt.submit reads PR URLs from your prompt and starts or stops watching.turn.complete reads PR URLs from Claude's final answer, and tool.call on Bash reads the URL gh pr create prints.ui.render on AbovePrompt draws the lines; on Pane it draws the details panel.session.start sets up a 60-second timer that polls every watched PR.Each poll is one read-only GraphQL call through gh api graphql: the PR's title, state, merge state and review decision, plus every check on its head commit with GitHub's own isRequired flag. The plugin maps check states to the same pass / fail / pending / cancel / skipping buckets that gh pr checks uses. A failed poll keeps the previous values and marks the line refresh failed, so a network blip is not reported as a change. Every call has a 30-second timeout.
bun test # unit tests for the pure helpers
claude plugin validate .claude-plugin/plugin.json # lists the hooked events and $ calls
claude plugin validate . # checks the marketplace manifest
For type checking, run the built-in /plugin-types command inside this folder in a Claude Code session. It writes .claude/types/, which is git-ignored; tsc fails with a missing claude-code module until it exists. Then:
bunx -p typescript tsc -p .
Edits hot-reload into a running session. If a reload fails partway, the transcript says so; restart the session.
MIT. See LICENSE.
hooks/register.tsx 311 lines1/* @jsx h */
2import type { Register } from 'claude-code'
3
4// A GitHub PR URL in a prompt adds a line above the prompt that polls gh for the merge state and
5// the required checks; a prompt that is only the URL toggles it without a model turn. A PR URL in
6// Claude's answer, or printed by `gh pr create`, is added too. When checks or the merge state change, it toasts,
7// flashes, plays a sound and, inside cmux, flashes the session's pane and posts a notification.
8
9const PR_URL = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/
10// a prompt that is nothing but PR URLs (one or more, any whitespace) toggles them without a model turn
11export const ONLY_URLS = new RegExp(`^\\s*(${PR_URL.source}\\S*\\s*)+$`)
12// fixed 1 min poll, one GraphQL call per PR; a webhook if rate limits bite
13const POLL_MS = 60_000
14// macOS system sounds, played with afplay when present; silent elsewhere
15const SOUND_CHANGE = '/System/Library/Sounds/Glass.aiff'
16const SOUND_FAIL = '/System/Library/Sounds/Basso.aiff'
17// the /config toggle from plugin.json's userConfig; silences every PR while on
18const MUTE_ALL = 'cc-pr-tracker.muteAll'
19
20type Check = { name: string; bucket: string; link: string }
21type View = { number: number; title: string; state: string; isDraft: boolean; mergeable: string; mergeStateStatus: string; reviewDecision: string }
22type Context = { __typename: string; isRequired: boolean; name?: string; status?: string | null; conclusion?: string | null; detailsUrl?: string; context?: string; state?: string; targetUrl?: string }
23
24// one call replaces `gh pr view` plus `gh pr checks`; isRequired is per PR
25const QUERY = `query($o: String!, $r: String!, $n: Int!) {
26 repository(owner: $o, name: $r) { pullRequest(number: $n) {
27 number title state isDraft mergeable mergeStateStatus reviewDecision
28 commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes {
29 __typename
30 ... on CheckRun { name status conclusion detailsUrl isRequired(pullRequestNumber: $n) }
31 ... on StatusContext { context state targetUrl isRequired(pullRequestNumber: $n) }
32 } } } } } }
33 } }
34}`
35
36// the same buckets `gh pr checks` derives: a check run is pending until COMPLETED, then its
37// conclusion decides; a commit status has only a state
38// GitHub text goes to the terminal as-is, so control characters are dropped first
39const clean = (s: string) => s.replace(/[\x00-\x1f\x7f]/g, '')
40export function toCheck(c: Context): Check {
41 if (c.__typename === 'StatusContext') {
42 const bucket = c.state === 'SUCCESS' ? 'pass' : c.state === 'PENDING' || c.state === 'EXPECTED' ? 'pending' : 'fail'
43 return { name: clean(c.context ?? ''), bucket, link: c.targetUrl ?? '' }
44 }
45 const bucket = c.status !== 'COMPLETED' ? 'pending'
46 : c.conclusion === 'SUCCESS' || c.conclusion === 'NEUTRAL' ? 'pass'
47 : c.conclusion === 'SKIPPED' ? 'skipping'
48 : c.conclusion === 'CANCELLED' ? 'cancel' : 'fail'
49 return { name: clean(c.name ?? ''), bucket, link: c.detailsUrl ?? '' }
50}
51type Pr = { url: string; id: string; label: string; pane: string; auto?: boolean; muted?: boolean; view?: View; required: Check[]; others: Check[]; updated?: number; error?: string; busy?: boolean }
52
53const ICON: Record<string, [string, string]> = { pass: ['✓', 'green'], fail: ['✗', 'red'], pending: ['●', 'yellow'], skipping: ['○', 'gray'], cancel: ['⊘', 'red'] }
54const MERGE: Record<string, string> = { CLEAN: 'green', HAS_HOOKS: 'green', UNSTABLE: 'yellow', BEHIND: 'yellow', BLOCKED: 'red', DIRTY: 'red', DRAFT: 'gray', UNKNOWN: 'gray' }
55const REVIEW: Record<string, string> = { APPROVED: 'green', CHANGES_REQUESTED: 'red', REVIEW_REQUIRED: 'yellow' }
56
57// a Link refuses the whole tree unless its href is canonical and https: (or http://localhost);
58// a plain-http status link draws as text
59export const linkable = (href: string) => {
60 try {
61 const u = new URL(href)
62 return u.href === href && (u.protocol === 'https:' || (u.protocol === 'http:' && u.hostname === 'localhost'))
63 } catch { return false }
64}
65
66// what changed between two polls; nothing on the first load, and GitHub's lazy UNKNOWN merge
67// state is not a change worth an alert
68export function prChanges(prevMerge: string | undefined, prevBuckets: Map<string, string>, merge: string, required: Check[]): string[] {
69 if (prevMerge === undefined) return []
70 const out = required.filter(c => prevBuckets.get(c.name) !== c.bucket).map(c => `${c.name}: ${prevBuckets.get(c.name) ?? 'new'} → ${c.bucket}`)
71 if (prevMerge !== merge && prevMerge !== 'UNKNOWN' && merge !== 'UNKNOWN') out.unshift(`merge: ${prevMerge.toLowerCase()} → ${merge.toLowerCase()}`)
72 return out
73}
74
75let flashing = false
76let muteAll = false
77let poll: { cancel(): void } | undefined
78const prs = new Map<string, Pr>()
79// built in session.start, where $ is in hand; later hooks call them
80let refresh: ((pr: Pr) => Promise<void>) | undefined
81let stop: ((pr: Pr) => void) | undefined
82let watch: ((m: RegExpExecArray, auto?: boolean) => void) | undefined
83let openUrl: ((url: string) => void) | undefined
84
85export const register: Register = on => {
86 on('session.start', async ($, e, next) => {
87 const r = await next(e)
88 const log = (text: string) => $.ui.log(`cc-pr-tracker: ${text}`)
89
90 // inside cmux the session's own pane is in the environment: cmux's CLI flashes that pane and
91 // posts notifications that mark its workspace unread, even while you are in another workspace
92 const cmuxBin = await $.env.get('CMUX_BUNDLED_CLI_PATH')
93 const surfaceId = await $.env.get('CMUX_SURFACE_ID')
94 const cmux = (args: string[]) => {
95 if (!cmuxBin || !surfaceId) return
96 $.process.run([cmuxBin, ...args, '--surface', surfaceId])
97 .then(res => { if (res.exitCode) log(`cmux ${args[0]} failed: ${res.stderr.trim()}`) })
98 .catch(err => log(`cmux ${args[0]} failed: ${err}`))
99 }
100 const hasSounds = await $.fs.exists(SOUND_CHANGE)
101 muteAll = (await $.config.list()).find(row => row.key === MUTE_ALL)?.value === true
102 const alert = (sound: string) => {
103 flashing = true
104 $.ui.invalidate('ui.render')
105 cmux(['trigger-flash'])
106 if (hasSounds) $.process.run(['afplay', sound]).catch(err => log(`afplay failed: ${err}`))
107 $.clock.after(1000, () => {
108 flashing = false
109 $.ui.invalidate('ui.render')
110 })
111 }
112 // `open` on macOS, `xdg-open` elsewhere
113 openUrl = url => {
114 $.process.run(['open', url])
115 .catch(() => $.process.run(['xdg-open', url]))
116 .catch(err => log(`could not open ${url}: ${err}`))
117 }
118
119 refresh = async pr => {
120 if (pr.busy) return
121 pr.busy = true
122 try {
123 const [, owner, repo, num] = PR_URL.exec(pr.url)!
124 const { stdout, stderr, exitCode } = await $.process.run(
125 // -f keeps owner and repo as strings (a repo named 2048 would otherwise be sent as a number)
126 ['gh', 'api', 'graphql', '-f', `query=${QUERY}`, '-f', `o=${owner}`, '-f', `r=${repo}`, '-F', `n=${num}`], { timeoutMs: 30_000 })
127 if (exitCode) throw new Error(stderr.trim() || `gh exited ${exitCode}`)
128 const found = JSON.parse(stdout).data?.repository?.pullRequest
129 if (!found) throw new Error('PR not found')
130 const { number, commits, ...view } = found
131 const contexts: Context[] = commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []
132 const prevMerge = pr.view?.mergeStateStatus
133 const prevBuckets = new Map(pr.required.map(c => [c.name, c.bucket]))
134 const v: View = { number, ...view, title: clean(view.title) }
135 // a PR Claude only mentioned that is already merged or closed is not worth a line
136 if (pr.auto && prevMerge === undefined && v.state !== 'OPEN') { stop?.(pr); return }
137 if (!prs.has(pr.id)) return
138 pr.view = v
139 pr.required = contexts.filter(c => c.isRequired).map(toCheck)
140 pr.others = contexts.filter(c => !c.isRequired).map(toCheck)
141 pr.error = undefined
142 const changes = prChanges(prevMerge, prevBuckets, v.mergeStateStatus, pr.required)
143 // a muted PR (or every PR, under muteAll) still updates its line, it just never alerts
144 if (changes.length && !pr.muted && !muteAll) {
145 const failed = pr.required.some(c => c.bucket === 'fail' && prevBuckets.get(c.name) !== 'fail')
146 $.ui.toast(`${pr.label} ${changes.join(' · ')}`, { timeoutMs: 8000 })
147 alert(failed ? SOUND_FAIL : SOUND_CHANGE)
148 cmux(['notify', '--title', `${pr.label}: ${failed ? 'a required check failed' : 'checks changed'}`, '--body', changes.join(' · ')])
149 }
150 } catch (err) {
151 pr.error = err instanceof Error ? err.message : String(err)
152 } finally {
153 pr.busy = false
154 pr.updated = $.clock.now()
155 $.ui.invalidate('ui.render')
156 }
157 }
158 poll?.cancel()
159 poll = $.clock.every(POLL_MS, () => { for (const pr of prs.values()) refresh?.(pr) })
160
161 stop = pr => {
162 prs.delete(pr.id)
163 $.ui.close({ id: pr.pane })
164 $.ui.invalidate('ui.render')
165 }
166 watch = ([url, owner, repo, num], auto = false) => {
167 const id = `${owner}/${repo}#${num}`
168 if (prs.has(id)) return
169 const pane = `pr-${repo}-${num}`.replace(/[^\w-]/g, '_').slice(0, 64)
170 const pr: Pr = { url, id, label: `${repo}#${num}`, pane, auto, required: [], others: [] }
171 prs.set(id, pr)
172 refresh?.(pr)
173 $.ui.invalidate('ui.render')
174 }
175 return r
176 })
177
178 // flipping the toggle in /config takes effect at once
179 on('config.set', { key: MUTE_ALL }, async ($, e, next) => {
180 const r = await next(e)
181 if (!r.deny) { muteAll = r.value === true; $.ui.invalidate('ui.render') }
182 return r
183 })
184
185 on('prompt.submit', async ($, e, next) => {
186 const seen = new Set<string>()
187 const found = [...e.text.matchAll(new RegExp(PR_URL.source, 'g'))].filter(m => !seen.has(m[0]) && seen.add(m[0]))
188 if (!found.length) return next(e)
189 // a prompt that is only PR URLs (one or several) toggles them without a model turn
190 if (!ONLY_URLS.test(e.text)) { found.forEach(m => watch?.(m)); return next(e) }
191 const done: string[] = []
192 for (const m of found) {
193 const id = `${m[1]}/${m[2]}#${m[3]}`
194 const pr = prs.get(id)
195 if (pr) { stop?.(pr); done.push(`stopped watching ${id}`) } else { watch?.(m); done.push(`watching ${id}`) }
196 }
197 return { drop: done.join(', ') }
198 })
199
200 // a PR Claude opens in this session is watched too: `gh pr create` prints its URL on stdout
201 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
202 const r = await next(e)
203 const command = (e as { command?: unknown }).command
204 if ('deny' in r || r.isError || typeof command !== 'string' || !/\bgh\s+pr\s+create\b/.test(command)) return r
205 const m = PR_URL.exec((r.result as { stdout?: string } | undefined)?.stdout ?? '')
206 if (m) watch?.(m, true)
207 return r
208 })
209
210 // a PR Claude mentions in its answer (one it opened through any tool, or one it was asked about)
211 // is watched too if it is open; subagent turns are skipped
212 on('turn.complete', async ($, e, next) => {
213 const r = await next(e)
214 if (!e.agentId) for (const m of e.answer.matchAll(new RegExp(PR_URL.source, 'g'))) watch?.(m, true)
215 return r
216 })
217
218 // above the prompt: a one-second strip on a change, then one line per watched PR; the failing
219 // checks are named in the details pane
220 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
221 if (e.props.hasSurvey || (!flashing && !prs.size)) return next(e)
222 const { Box, Text, Link, Button } = await $.ui.resolve(e)
223 const cols = e.viewport?.columns ?? 80
224 return (
225 <Box flexDirection="column">
226 {flashing ? (
227 <Box backgroundColor="white" width={cols}>
228 <Text color="black" backgroundColor="white" bold> ● PR checks changed</Text>
229 </Box>
230 ) : <Box />}
231 {[...prs.values()].map(pr => {
232 const v = pr.view
233 if (!v) return <Text dimColor wrap="truncate-end">{`${pr.label} ${pr.error ? `gh failed: ${pr.error}` : 'loading…'}`}</Text>
234 const n = (b: string) => pr.required.filter(c => c.bucket === b).length
235 const state = v.isDraft && v.state === 'OPEN' ? 'draft' : v.state.toLowerCase()
236 const otherFails = pr.others.filter(c => c.bucket === 'fail').length
237 // the label is a Link (cmd+click opens the PR); the rest is one Text, truncated, title
238 // last so it is what the width cuts; hovering the row reveals its buttons (no hotkeys:
239 // a band hotkey would press on a digit typed as the first character of a prompt)
240 return (
241 <Box key={`row:${pr.pane}`} flexDirection="row">
242 <Box flexShrink={0}><Link href={pr.url} label={pr.label} /></Box>
243 <Text wrap="truncate-end">
244 <Text dimColor>{state === 'open' ? ' ' : ` ${state} · `}</Text>
245 <Text color={MERGE[v.mergeStateStatus] ?? 'gray'}>{v.mergeStateStatus.toLowerCase()}</Text>
246 <Text dimColor> · </Text>
247 <Text color={REVIEW[v.reviewDecision] ?? 'gray'}>{(v.reviewDecision || 'no review').toLowerCase().replace(/_/g, ' ')}</Text>
248 <Text dimColor> · </Text>
249 <Text color="green">{`✓${n('pass')}`}</Text>
250 <Text color="red">{n('fail') + n('cancel') ? ` ✗${n('fail') + n('cancel')}` : ''}</Text>
251 <Text color="yellow">{n('pending') ? ` ●${n('pending')}` : ''}</Text>
252 <Text color="red">{otherFails ? ` (+${otherFails} optional ✗)` : ''}</Text>
253 <Text color="red">{pr.error ? ' · refresh failed' : ''}</Text>
254 <Text dimColor>{pr.muted || muteAll ? ' · muted' : ''}</Text>
255 <Text dimColor>{` · ${v.title}`}</Text>
256 </Text>
257 <Box display="none" hover={{ display: 'flex' }} flexShrink={0} flexDirection="row">
258 <Button key={`open:${pr.pane}`} label="open" onPress={() => openUrl?.(pr.url)} />
259 <Button key={`mute:${pr.pane}`} label={pr.muted ? 'unmute' : 'mute'} onPress={() => { pr.muted = !pr.muted; $.ui.invalidate('ui.render') }} />
260 <Button key={`details:${pr.pane}`} label="details" onPress={() => { $.ui.open({ id: pr.pane, title: pr.id, focus: true }) }} />
261 <Button key={`stop:${pr.pane}`} label="×" onPress={() => stop?.(pr)} />
262 </Box>
263 </Box>
264 )
265 })}
266 {await next(e)}
267 </Box>
268 )
269 })
270
271 // one PR's details, opened from its row's "details" button: every required check and any
272 // failing optional one, each linked to its run
273 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
274 const pr = [...prs.values()].find(p => p.pane === e.requestId)
275 if (!pr) return next(e)
276 const { Box, Text, Link, Button } = await $.ui.resolve(e)
277 const v = pr.view
278 if (!v) return <Text dimColor>{pr.error ? `gh failed: ${pr.error}` : 'loading…'}</Text>
279 const state = v.isDraft && v.state === 'OPEN' ? 'draft' : v.state.toLowerCase()
280 const optionalFails = pr.others.filter(c => c.bucket === 'fail')
281 return (
282 <Box flexDirection="column">
283 <Text bold wrap="truncate-end">{v.title}</Text>
284 <Text>
285 <Text color={state === 'open' ? 'green' : state === 'merged' ? 'magenta' : 'gray'}>{state}</Text>
286 <Text dimColor> · merge </Text>
287 <Text color={MERGE[v.mergeStateStatus] ?? 'gray'}>{v.mergeStateStatus.toLowerCase()}</Text>
288 <Text dimColor>{` (${v.mergeable.toLowerCase()}) · `}</Text>
289 <Text color={REVIEW[v.reviewDecision] ?? 'gray'}>{(v.reviewDecision || 'no review decision').toLowerCase().replace(/_/g, ' ')}</Text>
290 </Text>
291 <Box flexDirection="row">
292 <Button key={`pane-open:${pr.pane}`} label="open in browser" onPress={() => openUrl?.(pr.url)} />
293 <Text> </Text>
294 <Button key={`pane-stop:${pr.pane}`} label="stop watching" onPress={() => stop?.(pr)} />
295 </Box>
296 <Text bold>{pr.required.length ? `Required checks (${pr.required.length})` : 'Required checks: none reported'}</Text>
297 {[...pr.required, ...optionalFails].map((c, i) => (
298 <Box key={c.link || c.name} flexDirection="row">
299 <Box flexShrink={0}>
300 <Text color={ICON[c.bucket]?.[1] ?? 'gray'}>{`${ICON[c.bucket]?.[0] ?? '?'} `}</Text>
301 </Box>
302 {linkable(c.link) ? <Link href={c.link} label={c.name} /> : <Text wrap="truncate-end">{c.name}</Text>}
303 <Text dimColor>{i >= pr.required.length ? ' (optional)' : ''}</Text>
304 </Box>
305 ))}
306 <Text dimColor>{`+ ${pr.others.length} optional checks · updated ${new Date(pr.updated ?? 0).toTimeString().slice(0, 8)}${pr.error ? ` · refresh failed: ${pr.error}` : ''}`}</Text>
307 </Box>
308 )
309 })
310}
311