SLOPSHOPPER

pr-watch

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-02DominickGiordano/claude-mods/pr-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-watch
› fix the failing auth test and add an audit log call ╭──────────────────────────╮ │ pr-watch │ ⏺ Read(src/auth.ts) │ pr-watch: no PRs watched │ ⎿ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

pr-watch

Watches the pull requests Claude opens in a session, so you stop typing "merged, keep going".

  • Band row above the prompt: 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 ✓.
  • Merge re-reads the PR, then runs 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.
  • Which PRs get buttons: base in 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.
  • Nudge (on by default): when a PR merges and Claude is idle, Claude gets one prompt, e.g. 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.

Install

claude plugin marketplace add DominickGiordano/claude-mods
claude plugin install pr-watch@claude-mods

Options

OptionDefault
nudgetrueSend 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.

Source 2 files
hooks/register.ts 365 lines
1import 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}
365
types/index.d.ts 30 lines
1declare 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