Watches the PRs Claude opens: a band row with CI and merge state, merge buttons, and a nudge when one merges.

Watches the pull requests Claude opens in a session, so you stop typing "merged, keep going".
PRs #1138 ✓ CI · mergeable #1139 ✗ conflicts #1140 ● CI running #1141 merged. Polls gh pr view every 60 s (up to 4 at a time). With PRs from more than one repo, rows read owner/repo#n. A failed poll shows #12 ? gh failed 2m ago, never the last good value; a PR still failing after 6 h is dropped with a toast. Merged and closed PRs drop off 10 minutes after they close. A PR with no checks reads ● CI running for its first 3 minutes, then no CI; only passing checks get a ✓.gh pr merge --squash --delete-branch --match-head-commit <sha> from outside your checkout, so only the remote branch is deleted and a push since the last poll fails the merge. It shows only when checks pass (or there are none). When green merges on the first poll where checks pass, GitHub says mergeable and the merge state is CLEAN; it cancels if checks fail, and a failed attempt shows auto failed until you press OK or the PR changes.mergeBases (default develop), not a draft, and a head that isn't a long-lived branch (develop, main, master, staging, production, release*). Anything else is merged by hand; the pane says why.PR #1138 (feature/x → develop) merged. It states a fact; Claude decides what to do with it. Turn it off with nudge./prs opens a pane with every PR, failing check names, and the merge buttons (digit hotkeys 1–9). The band's buttons have no hotkeys, since a digit typed into an empty prompt would press them. /prs add <url|owner/repo#n> watches a PR Claude didn't open (owner/repo#n means github.com). /prs drop <n|url|owner/repo#n> stops watching one; a bare number works when only one PR has it.PRs are picked up from a successful Bash call running gh pr create whose output has a line that is exactly a PR URL on github.com or a host in hosts. Needs gh signed in.
claude plugin marketplace add DominickGiordano/claude-mods
claude plugin install pr-watch@claude-mods
| Option | Default | |
|---|---|---|
nudge | true | Send Claude a prompt when a watched PR merges. |
mergeBases | ["develop"] | Base branches the merge buttons may merge into. |
hosts | [] | GitHub Enterprise hosts to watch besides github.com. |
Set them under pluginConfigs["pr-watch@claude-mods"].options in settings, or from /config.
hooks/register.ts 365 lines1import type { EngineInterface, PluginState, Register, RenderElement } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4type Pr = PluginState['pr-watch']['prs'][number]
5type Api = EngineInterface
6type Config = { nudge: boolean; bases: readonly string[]; hosts: readonly string[] }
7
8const PANE = 'pr-watch'
9const MINUTE = 60_000
10const DONE_TTL_MS = 10 * MINUTE
11const ERROR_TTL_MS = 6 * 60 * MINUTE
12// A fresh PR's checks take a moment to register; until then an empty rollup means "not yet".
13const CHECKS_GRACE_MS = 3 * MINUTE
14const VIEWS_AT_ONCE = 4
15const FIELDS = 'number,title,state,isDraft,mergeable,mergeStateStatus,statusCheckRollup,baseRefName,headRefName,headRefOid,url'
16// Heads that are promotions or release lines: merging those ships somewhere, so it stays manual.
17const LONG_LIVED = /^(develop|main|master|staging|production|release.*)$/i
18const FAILED = ['FAILURE', 'ERROR', 'STALE', 'ACTION_REQUIRED', 'TIMED_OUT', 'CANCELLED', 'STARTUP_FAILURE']
19// UNSTABLE means a non-required check failed or is pending; auto waits for a fully clean state.
20const AUTO_STATES = ['CLEAN', 'HAS_HOOKS']
21
22const prs = atom({ plugin: 'pr-watch', key: 'prs' }, [])
23
24// $.state resets on /clear while the PRs stay open; `kept` mirrors every write so it can be
25// put back. Not from classic.SessionStart: sec-default bypasses user mods' classic.* hooks.
26let kept: Pr[] = []
27let cleared = false
28let working = false
29let polling = false
30// PRs whose nudge was submitted and hasn't settled; `nudged` is set only once it has.
31const inFlight = new Set<string>()
32
33export const register: Register = (on, options) => {
34 const config: Config = {
35 nudge: options.nudge !== false,
36 bases: Array.isArray(options.mergeBases) ? options.mergeBases : ['develop'],
37 hosts: ['github.com', ...(Array.isArray(options.hosts) ? options.hosts : [])],
38 }
39
40 on('session.start', async ($, e, next) => {
41 const result = await next(e)
42 kept = await read($, prs)
43 $.clock.every(MINUTE, () => void poll($, config))
44 await $.command.register({ name: 'prs', description: 'Watched PRs: open the list, add <url|owner/repo#n>, drop <n|url|owner/repo#n>', argumentHint: '[add <pr> | drop <pr>]' })
45 return result
46 })
47
48 on('session.end', { reason: 'clear' }, async ($, e, next) => {
49 cleared = true
50 $.clock.after(1000, () => void restore($))
51 return next(e)
52 })
53
54 on('turn.start', async ($, e, next) => {
55 working = true
56 await restore($)
57 return next(e)
58 })
59
60 on('turn.complete', async ($, e, next) => {
61 if (e.agentId === undefined) working = false
62 return next(e)
63 })
64
65 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
66 const result = await next(e)
67 if (result.deny !== undefined || result.isError || !/\bgh\s+pr\s+create\b/.test(String(e.command ?? ''))) return result
68 const out = result.text ?? String((result.result as { stdout?: unknown } | undefined)?.stdout ?? '')
69 const url = created(out, config.hosts)
70 if (url) await track($, url)
71 return result
72 })
73
74 on('command.run', { command: 'prs' }, async ($, e) => {
75 const [verb, arg = ''] = e.args.trim().split(/\s+/)
76 const list = await read($, prs)
77 if (verb === 'add') {
78 const url = urlOf(arg, config.hosts)
79 if (url) await track($, url)
80 else $.ui.toast(`pr-watch: not a PR on ${config.hosts.join(', ')}: ${arg}`)
81 return {}
82 }
83 if (verb === 'drop') {
84 const url = urlOf(arg, config.hosts)
85 const matches = list.filter(pr => pr.url === url || String(pr.number) === arg.replace('#', ''))
86 if (matches.length === 1) await save($, l => l.filter(pr => pr.url !== matches[0]?.url))
87 else $.ui.toast(matches.length === 0 ? `pr-watch: not watching ${arg}` : `pr-watch: ${arg} is ambiguous, use owner/repo#n`)
88 return {}
89 }
90 if (list.length === 0) {
91 $.ui.toast('pr-watch: no PRs watched')
92 return {}
93 }
94 await $.ui.open({ id: PANE, title: 'PRs', focus: true, closeOnEscape: true, rows: list.length * 3 + 1 })
95 return {}
96 })
97
98 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
99 const list = await read($, prs)
100 if (list.length === 0 || e.props.hasSurvey) return next(e)
101 const { Box, Text, Button } = $.ui.resolve(e)
102 const now = await $.clock.now()
103 const multi = new Set(list.map(pr => pr.repo)).size > 1
104 const row: RenderElement[] = [Text({ bold: true, children: 'PRs' })]
105 for (const pr of list) {
106 const [text, color] = label(pr, now)
107 row.push(Text({ color, dimColor: color === 'dim', children: `${nameOf(pr, multi)} ${text}` }))
108 // No digit hotkeys here: a bare digit typed into an empty prompt presses a band button.
109 if (canMerge(pr, config)) row.push(Button({ key: `merge:${pr.url}`, label: 'Merge', dimColor: true, onPress: () => void merge($, pr.url, config, false) }))
110 if (canAuto(pr, config)) row.push(Button({ key: `auto:${pr.url}`, label: pr.auto ? 'Cancel auto' : 'When green', dimColor: true, onPress: () => void patch($, pr.url, { auto: !pr.auto, autoFailed: null }) }))
111 if (pr.autoFailed) row.push(Button({ key: `dismiss:${pr.url}`, label: 'OK', dimColor: true, onPress: () => void patch($, pr.url, { autoFailed: null }) }))
112 }
113 const band = Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, paddingX: 1, children: row })
114 return Box({ flexDirection: 'column', children: [band, await next(e)] })
115 })
116
117 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
118 if (e.requestId !== PANE) return next(e)
119 const { Box, Text, Button } = $.ui.resolve(e)
120 const now = await $.clock.now()
121 const list = await read($, prs)
122 const multi = new Set(list.map(pr => pr.repo)).size > 1
123 let hotkey = 0
124 const rows = list.map(pr => {
125 const [text, color] = label(pr, now)
126 const head: RenderElement[] = [
127 Text({ bold: true, children: nameOf(pr, multi) }),
128 Text({ children: `${pr.head || '?'} → ${pr.base || '?'}` }),
129 Text({ color, dimColor: color === 'dim', children: text }),
130 ]
131 if (canMerge(pr, config)) head.push(Button({ key: `merge:${pr.url}`, label: 'Merge', ...digit(++hotkey), onPress: () => void merge($, pr.url, config, false) }))
132 if (canAuto(pr, config)) head.push(Button({ key: `auto:${pr.url}`, label: pr.auto ? 'Cancel auto' : 'Merge when green', ...digit(++hotkey), onPress: () => void patch($, pr.url, { auto: !pr.auto, autoFailed: null }) }))
133 const why = blocker(pr, config)
134 if (why) head.push(Text({ dimColor: true, children: why }))
135 const detail = pr.error ? `gh: ${pr.error.text}` : pr.autoFailed ? `auto failed: ${pr.autoFailed}` : pr.failed.length > 0 ? `failed: ${pr.failed.join(', ')}` : pr.title
136 return Box({ key: pr.url, flexDirection: 'column', children: [
137 Box({ flexDirection: 'row', columnGap: 2, children: head }),
138 Text({ dimColor: true, children: ` ${detail}` }),
139 Text({ dimColor: true, children: ` ${pr.url}` }),
140 ] })
141 })
142 return Box({ flexDirection: 'column', children: rows.length > 0 ? rows : [Text({ dimColor: true, children: 'No PRs watched.' })] })
143 })
144}
145
146async function restore($: Api) {
147 // Empty state beside a non-empty mirror only follows a reset: every write updates both.
148 if (!cleared || kept.length === 0 || (await read($, prs)).length > 0) return
149 await $.state.set({ plugin: 'pr-watch', key: 'prs' }, kept)
150}
151
152async function save($: Api, change: (list: Pr[]) => Pr[]) {
153 await restore($)
154 kept = await update($, prs, change)
155}
156
157// Never writes `nudged`: callers pass whole views whose copy may be stale; the nudge owns it.
158async function patch($: Api, url: string, fields: Partial<Pr>) {
159 await save($, list => list.map(pr => (pr.url === url ? { ...pr, ...fields, nudged: pr.nudged } : pr)))
160}
161
162async function track($: Api, url: string) {
163 const [, repo = '', number = '0'] = url.match(/\/\/[^/]+\/([^/]+\/[^/]+)\/pull\/(\d+)/) ?? []
164 const now = await $.clock.now()
165 const fresh: Pr = { url, repo, number: Number(number), title: '', base: '', head: '', headOid: '', draft: false, state: '', mergeable: '', mergeState: '', checks: 'none', failed: [], error: null, trackedAt: now, doneAt: null, auto: false, autoFailed: null, nudged: false }
166 await save($, list => (list.some(pr => pr.url === url) ? list : [...list, fresh]))
167 const viewed = await view($, (await read($, prs)).find(pr => pr.url === url) ?? fresh, now)
168 await save($, list => list.map(pr => (pr.url === url ? viewed : pr)))
169}
170
171async function poll($: Api, config: Config) {
172 if (polling) return
173 polling = true
174 try {
175 await restore($)
176 await tick($, config)
177 } finally {
178 polling = false
179 }
180}
181
182async function tick($: Api, config: Config) {
183 const now = await $.clock.now()
184 const live = (await read($, prs)).filter(pr => pr.doneAt === null || now - pr.doneAt < DONE_TTL_MS)
185 const viewed: Pr[] = []
186 for (let i = 0; i < live.length; i += VIEWS_AT_ONCE) {
187 viewed.push(...(await Promise.all(live.slice(i, i + VIEWS_AT_ONCE).map(pr => (pr.doneAt === null ? view($, pr, now) : pr)))))
188 }
189 const expired = viewed.filter(pr => pr.error !== null && now - pr.error.since >= ERROR_TTL_MS).map(pr => pr.url)
190 for (const pr of viewed.filter(pr => expired.includes(pr.url))) $.ui.toast(`pr-watch: stopped watching ${pr.repo}#${pr.number}, gh failing for 6h: ${pr.error?.text}`)
191 const byUrl = new Map(viewed.map(pr => [pr.url, pr]))
192 await save($, list => list.flatMap(pr => {
193 const fresh = byUrl.get(pr.url)
194 if (expired.includes(pr.url)) return []
195 if (!fresh) return pr.doneAt === null ? [pr] : []
196 return [{ ...fresh, auto: pr.auto, autoFailed: signature(fresh) === signature(pr) ? pr.autoFailed : null, nudged: pr.nudged || fresh.nudged }]
197 }))
198 for (const pr of (await read($, prs)).filter(pr => pr.auto && pr.error === null && pr.state === 'OPEN')) {
199 if (pr.checks === 'fail') {
200 await patch($, pr.url, { auto: false })
201 $.ui.toast(`pr-watch: #${pr.number} checks failed, merge when green cancelled`)
202 } else if (greenForAuto(pr)) {
203 await merge($, pr.url, config, true)
204 }
205 }
206 const merged = (await read($, prs)).filter(pr => pr.state === 'MERGED' && !pr.nudged && !inFlight.has(pr.url))
207 if (config.nudge && !working && merged.length > 0) nudge($, merged)
208}
209
210// Not awaited by the poll: submit resolves when the turn starts, and a poll held on it would stall.
211function nudge($: Api, merged: Pr[]) {
212 const urls = merged.map(pr => pr.url)
213 const names = merged.map(pr => `#${pr.number} (${clean(pr.head)} → ${clean(pr.base)})`)
214 const text = names.length === 1 ? `PR ${names[0]} merged.` : `PRs merged: ${names.join(', ')}.`
215 const unsent = (why: string) => {
216 for (const url of urls) inFlight.delete(url)
217 $.ui.log(`pr-watch: merge nudge not sent: ${why}`, { to: 'debug' })
218 $.ui.toast(`pr-watch: couldn't tell Claude about the merge: ${why}`)
219 }
220 for (const url of urls) inFlight.add(url)
221 $.prompt.submit({ text }).then(async sent => {
222 if (sent.drop !== undefined) return unsent(`dropped: ${sent.drop}`)
223 await save($, list => list.map(pr => (urls.includes(pr.url) ? { ...pr, nudged: true } : pr)))
224 for (const url of urls) inFlight.delete(url)
225 }, err => unsent(err instanceof Error ? err.message : String(err)))
226}
227
228async function view($: Api, pr: Pr, now: number): Promise<Pr> {
229 const failed = (text: string): Pr => ({ ...pr, error: { since: pr.error?.since ?? now, text } })
230 let v
231 try {
232 const run = await $.process.run(['gh', 'pr', 'view', pr.url, '--json', FIELDS])
233 if (run.exitCode !== 0) return failed(run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`)
234 v = JSON.parse(run.stdout)
235 } catch (err) {
236 return failed(err instanceof Error ? err.message : String(err))
237 }
238 const rollup: { name?: string; context?: string; status?: string; conclusion?: string | null; state?: string }[] = v.statusCheckRollup ?? []
239 const failures = rollup.filter(c => FAILED.includes(c.conclusion || c.state || '')).map(c => c.name ?? c.context ?? '?')
240 const pending = rollup.some(c => (c.status !== undefined && (c.status !== 'COMPLETED' || !c.conclusion)) || c.state === 'PENDING' || c.state === 'EXPECTED')
241 const empty = rollup.length === 0
242 const done = v.state === 'MERGED' || v.state === 'CLOSED'
243 return {
244 ...pr,
245 title: v.title,
246 base: v.baseRefName,
247 head: v.headRefName,
248 headOid: v.headRefOid,
249 draft: v.isDraft === true,
250 state: v.state,
251 mergeable: v.mergeable,
252 mergeState: v.mergeStateStatus,
253 checks: failures.length > 0 ? 'fail' : pending || (empty && now - pr.trackedAt < CHECKS_GRACE_MS) ? 'pending' : empty ? 'none' : 'pass',
254 failed: failures,
255 error: null,
256 doneAt: done ? (pr.doneAt ?? now) : null,
257 // Already merged when first seen: nothing happened this session to tell Claude about.
258 nudged: pr.nudged || (done && pr.state !== 'OPEN'),
259 }
260}
261
262async function merge($: Api, url: string, config: Config, auto: boolean) {
263 const seen = (await read($, prs)).find(pr => pr.url === url)
264 if (!seen) return
265 const pr = await view($, seen, await $.clock.now())
266 await patch($, url, { ...pr, auto: false })
267 const gate = auto ? (greenForAuto(pr) ? null : 'no longer green') : canMerge(pr, config) ? null : `checks ${pr.checks}`
268 const why = pr.error ? `gh failed: ${pr.error.text}` : pr.state !== 'OPEN' ? `PR is ${pr.state.toLowerCase()}` : (blocker(pr, config) ?? gate)
269 if (why) return mergeFailed($, pr, auto, why)
270 // cwd outside any repo: run in the session's checkout, --delete-branch also switches it to the
271 // base branch and deletes the local branch under the user. Run here, gh deletes the remote only.
272 const argv = ['gh', 'pr', 'merge', url, '--squash', '--delete-branch', '--match-head-commit', pr.headOid]
273 let run
274 try {
275 run = await $.process.run(argv, { cwd: '/', timeoutMs: 120_000 })
276 } catch (err) {
277 return mergeFailed($, pr, auto, err instanceof Error ? err.message : String(err))
278 }
279 if (run.exitCode !== 0) return mergeFailed($, pr, auto, run.stderr.trim() || `exit ${run.exitCode}`)
280 const after = await view($, pr, await $.clock.now())
281 await patch($, url, { ...after, auto: false })
282 $.ui.toast(after.state === 'MERGED' ? `pr-watch: merged #${pr.number} into ${pr.base}` : `pr-watch: merge requested for #${pr.number}`)
283}
284
285async function mergeFailed($: Api, pr: Pr, auto: boolean, why: string) {
286 if (auto) await patch($, pr.url, { autoFailed: why })
287 $.ui.toast(`pr-watch: merge #${pr.number} failed: ${why}`, { timeoutMs: 10_000 })
288}
289
290function blocker(pr: Pr, config: Config) {
291 if (pr.state !== 'OPEN' || pr.error) return null
292 if (pr.draft) return 'draft'
293 if (LONG_LIVED.test(pr.head)) return 'promotion: merge by hand'
294 if (!config.bases.includes(pr.base)) return `base ${pr.base} not in mergeBases`
295 return null
296}
297
298function mergeable(pr: Pr, config: Config) {
299 return pr.state === 'OPEN' && pr.error === null && blocker(pr, config) === null
300}
301
302function canMerge(pr: Pr, config: Config) {
303 return mergeable(pr, config) && (pr.checks === 'pass' || pr.checks === 'none')
304}
305
306function canAuto(pr: Pr, config: Config) {
307 return mergeable(pr, config) && (pr.checks === 'pass' || pr.checks === 'pending')
308}
309
310function greenForAuto(pr: Pr) {
311 return pr.checks === 'pass' && pr.mergeable === 'MERGEABLE' && AUTO_STATES.includes(pr.mergeState) && !pr.draft
312}
313
314function signature(pr: Pr) {
315 return [pr.state, pr.checks, pr.mergeState, pr.headOid].join('|')
316}
317
318function label(pr: Pr, now: number): [string, string] {
319 if (pr.error) return [`? gh failed ${ago(now - pr.error.since)}`, 'yellow']
320 if (pr.state === '') return ['…', 'dim']
321 if (pr.state === 'MERGED') return ['merged', 'dim']
322 if (pr.state === 'CLOSED') return ['closed', 'dim']
323 if (pr.autoFailed) return ['✗ auto failed', 'red']
324 if (pr.draft) return ['draft', 'yellow']
325 const auto = pr.auto ? ' · auto' : ''
326 if (pr.mergeable === 'CONFLICTING') return [`✗ conflicts${auto}`, 'red']
327 if (pr.checks === 'fail') return [`✗ CI failed${auto}`, 'red']
328 if (pr.checks === 'pending') return [`● CI running${auto}`, 'yellow']
329 const ci = pr.checks === 'pass' ? '✓ CI' : 'no CI'
330 if (pr.checks === 'pass' && pr.mergeable === 'MERGEABLE' && AUTO_STATES.includes(pr.mergeState)) return [`${ci} · mergeable${auto}`, 'green']
331 return [`${ci} · ● ${(pr.mergeState || pr.mergeable).toLowerCase()}${auto}`, 'yellow']
332}
333
334function nameOf(pr: Pr, multi: boolean) {
335 return multi ? `${pr.repo}#${pr.number}` : `#${pr.number}`
336}
337
338function ago(ms: number) {
339 const minutes = Math.floor(ms / MINUTE)
340 return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes}m ago` : `${Math.floor(minutes / 60)}h ago`
341}
342
343function digit(n: number) {
344 return n <= 9 ? { hotkey: String(n) } : {}
345}
346
347function clean(branch: string) {
348 return branch.replace(/[^\w./-]/g, '')
349}
350
351// A line that is exactly a PR URL (what gh pr create prints), or gh's "already exists:" line.
352function created(out: string, hosts: readonly string[]) {
353 for (const m of out.matchAll(/^(?:.*already exists:\s*)?(https?:\/\/([^\s/]+)\/[^\s/]+\/[^\s/]+\/pull\/\d+)\s*$/gm)) {
354 if (hosts.includes(m[2] ?? '')) return m[1]
355 }
356 return undefined
357}
358
359function urlOf(arg: string, hosts: readonly string[]) {
360 const url = created(arg, hosts)
361 if (url) return url
362 const short = arg.match(/^([\w.-]+)\/([\w.-]+)#(\d+)$/)
363 return short ? `https://github.com/${short[1]}/${short[2]}/pull/${short[3]}` : undefined
364}
365types/index.d.ts 30 lines1declare module 'claude-code' {
2 interface PluginState {
3 'pr-watch': {
4 prs: {
5 url: string
6 repo: string
7 number: number
8 title: string
9 base: string
10 head: string
11 headOid: string
12 draft: boolean
13 state: string
14 mergeable: string
15 mergeState: string
16 checks: 'pass' | 'fail' | 'pending' | 'none'
17 failed: string[]
18 // Set while polls fail: when the first failure happened and gh's message.
19 error: { since: number; text: string } | null
20 trackedAt: number
21 doneAt: number | null
22 auto: boolean
23 // Why the last merge-when-green attempt failed, until dismissed or the PR changes.
24 autoFailed: string | null
25 nudged: boolean
26 }[]
27 }
28 }
29}
30