SLOPSHOPPER

pr-ci-watch

After a git push or create_pull_request, checks the branch's PR every 2 minutes and wakes the session once its CI has finished (naming failures), when no…

newguardtoasttoolprocesstimer
v0.1.0no licenseupdated 2026-10-22brand-bridge-media/bbm-mods/claude-mods/pr-ci-watch
A shopper browsing a rack in a slop shop
README

bbm-mods

Read-only public mirror of the Claude Code mods (plugins) that Brand Bridge Media sessions run. The source of truth is claude-mods/ in the private bbm-infrastructure repo; every merge there that touches a mod overwrites this repo with a fresh snapshot. Edits made here are lost on the next sync.

Pull requests are not accepted. One opened by anyone but the owner is closed automatically and the owner is notified.

Install (what the cloud environment setup script runs):

claude plugin marketplace add brand-bridge-media/bbm-mods
claude plugin install fleet-tracker@bbm-mods --scope user
Source 3 files
hooks/register.ts 151 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Watch } from '../types'
5import { decide, findPr, prNumberFrom, parseCheckRuns, parsePr, parsePushes, parseRemote, POLL_MS } from './logic'
6import type { Observation, PushCmd } from './logic'
7
8// Why (LC-1896): a session must not depend on GitHub's PR events alone to learn its CI finished.
9// Decisions live in ./logic.ts; this file gathers facts (git, GitHub MCP) and acts.
10
11const DEFAULT_SERVER = 'github'
12const watches = atom({ plugin: 'pr-ci-watch', key: 'watches' } as const, [] as Watch[])
13
14// Wakes the host refused (submitted from inside a tool call) wait for the turn's end.
15const pendingWakes: string[] = []
16let ticking = false
17let busy = false
18
19function textOf(r: { content?: readonly unknown[] }): string {
20  return (r.content ?? []).map(c => String((c as { text?: unknown }).text ?? '')).join('')
21}
22
23function flush($: any) {
24  if (pendingWakes.length === 0) return
25  const lines = pendingWakes.splice(0)
26  try { $.ui.toast(lines[0]!.slice(0, 200)) } catch { /* a toast never changes a wake */ }
27  Promise.resolve()
28    .then(() => $.prompt.submit({ text: lines.join('\n') }))
29    .catch(() => { pendingWakes.unshift(...lines) })
30}
31
32async function mcp($: any, server: string, tool: string, args: Record<string, unknown>): Promise<string | undefined> {
33  try {
34    const r = await $.mcp.call(server, tool, args)
35    return r.isError ? undefined : textOf(r)
36  } catch { return undefined }
37}
38
39async function git($: any, args: string[]): Promise<string | undefined> {
40  try {
41    const r = await $.process.run(['git', ...args], { timeoutMs: 10_000 })
42    const out = String(r.stdout ?? '').trim()
43    return r.exitCode === 0 && out ? out : undefined
44  } catch { return undefined }
45}
46
47async function observe($: any, w: Watch): Promise<Observation> {
48  const pr = parsePr((await mcp($, w.server, 'pull_request_read', { method: 'get', owner: w.owner, repo: w.repo, pullNumber: w.number })) ?? '')
49  if (!pr) return { kind: 'error', detail: 'pull_request_read get unreadable' }
50  if (pr.merged || pr.state === 'closed') return { kind: 'pr', ...pr, runs: [] }
51  const runs = parseCheckRuns((await mcp($, w.server, 'pull_request_read',
52    { method: 'get_check_runs', owner: w.owner, repo: w.repo, pullNumber: w.number, perPage: 100 })) ?? '')
53  return { kind: 'pr', ...pr, runs }
54}
55
56async function tick($: any) {
57  if (busy) return
58  busy = true
59  try {
60    const now = await $.clock.now()
61    for (const w of await read($, watches)) {
62      const d = decide(w, await observe($, w), now)
63      if (d.wake) { pendingWakes.push(d.wake); flush($) }
64      const same = (x: Watch) => x.owner === w.owner && x.repo === w.repo && x.number === w.number && x.startedAt === w.startedAt
65      await update($, watches, l => (d.done ? l.filter(x => !same(x)) : l.map(x => (same(x) ? { ...x, ...d.patch } : x))))
66    }
67  } catch { /* a failed tick is retried by the next one */ } finally {
68    busy = false
69  }
70}
71
72/** Adds or replaces the watch for one PR (a new push restarts its clock), and starts the timer. */
73async function watch($: any, w: Omit<Watch, 'startedAt'>): Promise<string> {
74  const startedAt = await $.clock.now()
75  await update($, watches, l => [...l.filter(x => !(x.owner === w.owner && x.repo === w.repo && x.number === w.number)), { ...w, startedAt }])
76  if (!ticking) {
77    ticking = true
78    $.clock.every(POLL_MS, () => { void tick($) })
79  }
80  return `[mod:pr-ci-watch] Watching CI on ${w.owner}/${w.repo}#${w.number}: checked every 2 minutes; this session is ` +
81    'woken when it finishes (or registers nothing, or runs past an hour). No check-in needed for it.'
82}
83
84async function fromPush($: any, p: PushCmd): Promise<string | undefined> {
85  const at = p.dir ? ['-C', p.dir] : []
86  const remote = parseRemote((await git($, [...at, 'remote', 'get-url', p.remote ?? 'origin'])) ?? '')
87  if (!remote) return undefined
88  const branch = p.branch ?? (await git($, [...at, 'rev-parse', '--abbrev-ref', 'HEAD']))
89  const sha = await git($, [...at, 'rev-parse', p.src ?? 'HEAD'])
90  if (!branch || branch === 'HEAD' || !sha) return undefined
91  const number = findPr((await mcp($, DEFAULT_SERVER, 'list_pull_requests',
92    { ...remote, state: 'open', perPage: 100, fields: ['number', 'head', 'state'] })) ?? '', branch)
93  if (number === undefined) return undefined // no PR yet: create_pull_request starts the watch
94  return watch($, { ...remote, number, server: DEFAULT_SERVER, sha })
95}
96
97export const register: Register = on => {
98  on('session.start', async ($, e, next) => {
99    const started = await next(e)
100    await $.tool.register({
101      name: 'watch_pr',
102      description: 'Watch an existing PR\'s CI and be woken when it finishes. Pushes and create_pull_request are watched automatically; use this for a PR this session did not just push.',
103      inputSchema: {
104        type: 'object',
105        properties: { owner: { type: 'string' }, repo: { type: 'string' }, pullNumber: { type: 'number' } },
106        required: ['owner', 'repo', 'pullNumber'],
107      },
108    })
109    return started
110  }).catch(($, e, next) => next(e))
111
112  on('tool.call', { tool: 'mcp__pr-ci-watch__watch_pr' }, async ($, e: any) => {
113    const number = Number(e.pullNumber)
114    if (!e.owner || !e.repo || !Number.isInteger(number)) return { result: '[mod:pr-ci-watch] owner, repo and pullNumber are required.' }
115    return { result: await watch($, { owner: String(e.owner), repo: String(e.repo), number, server: DEFAULT_SERVER, sha: '' }) }
116  }).catch(() => ({ result: '[mod:pr-ci-watch] could not start the watch; check the PR\'s CI yourself.' }))
117
118  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
119    const ran = await next(e)
120    if (ran.deny !== undefined || ran.isError) return ran
121    const command = String((e as { command?: unknown }).command ?? '')
122    if (!/\bpush\b/.test(command)) return ran
123    const notes: string[] = []
124    for (const p of parsePushes(command)) {
125      const note = await fromPush($, p)
126      if (note && !notes.includes(note)) notes.push(note)
127    }
128    return notes.length ? { ...ran, context: [...(ran.context ?? []), ...notes] } : ran
129  // Fail open: a bug here must never change the push's outcome.
130  }).catch(($, e, next) => next(e))
131
132  on('tool.call', { tool: /^mcp__.*__create_pull_request$/ }, async ($, e, next) => {
133    const ran = await next(e)
134    if (ran.deny !== undefined || ran.isError) return ran
135    const text = String((ran as any).text ?? JSON.stringify((ran as any).result ?? ''))
136    const a = e as any
137    if (!a.owner || !a.repo) return ran
138    const number = prNumberFrom(text)
139    if (number === undefined) return ran
140    const server = String(a.tool).split('__')[1] ?? DEFAULT_SERVER
141    const note = await watch($, { owner: String(a.owner), repo: String(a.repo), number, server, sha: '' })
142    return { ...ran, context: [...(ran.context ?? []), note] }
143  }).catch(($, e, next) => next(e))
144
145  on('turn.complete', async ($, e, next) => {
146    const res = await next(e)
147    flush($)
148    return res
149  })
150}
151
hooks/logic.ts 191 lines
1// Pure decisions for pr-ci-watch. register.ts gathers facts (git, GitHub MCP) and acts on these.
2//
3// Why (LC-1896, 2026-10-22): PR #2041's required review failed at 17:33Z and the session that
4// owned it sat idle until the operator asked. GitHub's PR events never reached it (the Claude
5// App could not see the repo under its pre-move name) and its fallback check-in was 50 minutes
6// out for a CI that finishes in 4. The platform also lists gaps where events never arrive even
7// when they work. This mod watches CI itself after a push, the way dispatch-watch watches a run.
8
9import type { Watch } from '../types'
10
11export const POLL_MS = 2 * 60_000
12export const NO_CHECKS_MS = 15 * 60_000 // nothing registered by now: a workflow did not trigger
13export const LONG_MS = 60 * 60_000
14export const ADOPT_HEAD_MS = 10 * 60_000 // the PR still shows another head: watch whatever it shows
15export const MAX_ERRORS = 5
16
17export type CheckRun = { name: string; status: string; conclusion: string | null }
18
19export type Observation =
20  | { kind: 'pr'; state: string; merged: boolean; headSha: string; runs: CheckRun[] | undefined }
21  | { kind: 'error'; detail: string }
22
23export type Decision = { done: boolean; wake?: string; patch?: Partial<Watch> }
24
25const BAD = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'startup_failure', 'stale'])
26// Only these read as a pass. neutral, null or anything unrecognised is never reported as green.
27const GOOD = new Set(['success', 'skipped'])
28
29// ------------------------------------------------------------------ recognising a push
30
31export type PushCmd = { dir?: string; remote?: string; src?: string; branch?: string }
32
33/** Words of one simple command, quotes removed. Enough to find `git push`; anything stranger is not watched. */
34function words(cmd: string): string[] {
35  return (cmd.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map(w => w.replace(/^['"]|['"]$/g, ''))
36}
37
38/**
39 * The pushes in a Bash command. A false positive costs one PR lookup; a miss leaves the session
40 * where it is today (events or a check-in), so this stays deliberately small.
41 */
42export function parsePushes(command: string): PushCmd[] {
43  const out: PushCmd[] = []
44  let cdDir: string | undefined
45  for (const part of command.split(/&&|\|\||;|\n/)) {
46    const w = words(part.trim())
47    if (w[0] === 'cd' && w[1]) { cdDir = w[1]; continue }
48    const g = w.indexOf('git')
49    if (g < 0) continue
50    let i = g + 1
51    let dir = cdDir
52    for (; i < w.length; i++) {
53      if (w[i] === '-C' && w[i + 1]) dir = w[++i]
54      else if (w[i] === '-c' && w[i + 1]) i++
55      else if (!w[i]!.startsWith('-')) break
56    }
57    if (w[i] !== 'push') continue
58    const rest = w.slice(i + 1)
59    if (rest.some(t => t === '--dry-run' || t === '-n' || t === '--delete' || t === '-d' || t === '--tags')) continue
60    const pos = rest.filter(t => !t.startsWith('-'))
61    const spec = (pos[1] ?? '').replace(/^\+/, '')
62    if (spec.startsWith(':')) continue // deletes a branch
63    const [src, dst] = spec.includes(':') ? spec.split(':', 2) as [string, string] : [spec, spec]
64    const branch = dst && dst !== 'HEAD' ? dst.replace(/^refs\/heads\//, '') : undefined
65    out.push({ dir, remote: pos[0], src: src || undefined, branch })
66  }
67  return out
68}
69
70/** owner/repo from a remote URL (https, ssh, or a proxy path ending in owner/repo). */
71export function parseRemote(url: string): { owner: string; repo: string } | undefined {
72  const m = url.trim().match(/[/:]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/)
73  return m ? { owner: m[1]!, repo: m[2]! } : undefined
74}
75
76// ------------------------------------------------------------------ reading MCP results
77
78function json(text: string): unknown {
79  try { return JSON.parse(text) } catch { return undefined }
80}
81
82/** The open PR whose head branch is `branch`, from list_pull_requests text. */
83export function findPr(text: string, branch: string): number | undefined {
84  const v = json(text)
85  if (!Array.isArray(v)) return undefined
86  const hit = v.find(p => p && typeof p === 'object' && (p as any).head?.ref === branch && (p as any).state !== 'closed')
87  return hit && typeof (hit as any).number === 'number' ? (hit as any).number : undefined
88}
89
90/**
91 * The new PR's number from a create_pull_request result: the JSON `number` field, else the LAST
92 * /pull/N URL (the PR's own url comes after any URL quoted in the title or body).
93 */
94export function prNumberFrom(text: string): number | undefined {
95  const v = json(text) as any
96  if (v && typeof v === 'object' && Number.isInteger(v.number)) return v.number
97  const all = [...text.matchAll(/\/pull\/(\d+)/g)]
98  return all.length ? Number(all[all.length - 1]![1]) : undefined
99}
100
101/** state, merged and head sha from pull_request_read get text. */
102export function parsePr(text: string): { state: string; merged: boolean; headSha: string } | undefined {
103  const v = json(text) as any
104  if (!v || typeof v !== 'object' || typeof v.head?.sha !== 'string') return undefined
105  return { state: String(v.state ?? ''), merged: v.merged === true || typeof v.merged_at === 'string', headSha: v.head.sha }
106}
107
108/** get_check_runs text. undefined = not readable as a list of runs. */
109export function parseCheckRuns(text: string): CheckRun[] | undefined {
110  const v = json(text)
111  const list = Array.isArray(v) ? v : (v && typeof v === 'object' ? (v as { check_runs?: unknown }).check_runs : undefined)
112  if (!Array.isArray(list)) return undefined
113  const out: CheckRun[] = []
114  for (const r of list) {
115    if (!r || typeof r !== 'object' || typeof (r as any).name !== 'string' || typeof (r as any).status !== 'string') return undefined
116    out.push({ name: (r as any).name, status: (r as any).status.toLowerCase(),
117      conclusion: typeof (r as any).conclusion === 'string' ? (r as any).conclusion.toLowerCase() : null })
118  }
119  return out
120}
121
122// ------------------------------------------------------------------ the decision
123
124const label = (w: Watch) => `${w.owner}/${w.repo}#${w.number}`
125const short = (sha: string) => sha.slice(0, 7)
126const list = (rs: CheckRun[]) => rs.slice(0, 6).map(r => `${r.name} (${r.conclusion})`).join('; ') + (rs.length > 6 ? `; +${rs.length - 6} more` : '')
127
128export function decide(w: Watch, o: Observation, now: number): Decision {
129  const P = `[mod:pr-ci-watch] ${label(w)}`
130  if (o.kind === 'error') {
131    const errors = (w.errors ?? 0) + 1
132    if (errors < MAX_ERRORS) return { done: false, patch: { errors } }
133    return { done: true, wake: `${P}: could not read the PR or its checks ${errors} times in a row (${o.detail}); stopped watching. Check its CI yourself now.` }
134  }
135  if (o.merged) return { done: true, wake: `${P} merged. Do what the merge calls for (Jira, mirror, follow-ups).` }
136  if (o.state === 'closed') return { done: true, wake: `${P} was closed without merging.` }
137
138  const base: Partial<Watch> = { errors: 0 }
139  // Right after a push the PR can still show the previous head, whose finished runs would read as done.
140  // A watch made from create_pull_request or watch_pr has no pushed sha: it takes the PR's head.
141  if (!w.sha) {
142    base.sha = o.headSha
143  } else if (o.headSha !== w.sha) {
144    if (now - w.startedAt < ADOPT_HEAD_MS) return { done: false, patch: base }
145    base.sha = o.headSha // someone else pushed, or the push never landed: watch what the PR shows
146    base.lastSig = undefined
147  }
148  const sha = base.sha ?? w.sha
149  const runs = o.runs
150  // Unreadable check runs are a read error, never a quiet wait: otherwise the session could idle forever.
151  if (runs === undefined) {
152    const errors = (w.errors ?? 0) + 1
153    if (errors < MAX_ERRORS) return { done: false, patch: { ...base, errors } }
154    return { done: true, wake: `${P}: could not read the check runs ${errors} times in a row; stopped watching. Check its CI yourself now.` }
155  }
156
157  if (runs.length === 0) {
158    if (!w.warnedNone && now - w.startedAt >= NO_CHECKS_MS) {
159      return { done: false, patch: { ...base, warnedNone: true },
160        wake: `${P}: no checks have registered on ${short(sha)} after ${Math.round((now - w.startedAt) / 60_000)} min. A workflow may not have triggered; look at the PR's Actions.` }
161    }
162    return { done: false, patch: base }
163  }
164
165  const running = runs.filter(r => r.status !== 'completed')
166  if (running.length > 0) {
167    const patch = { ...base, lastSig: undefined }
168    if (!w.warnedLong && now - w.startedAt >= LONG_MS) {
169      return { done: false, patch: { ...patch, warnedLong: true },
170        wake: `${P}: CI on ${short(sha)} is still running after ${Math.round((now - w.startedAt) / 60_000)} min (${running.slice(0, 4).map(r => r.name).join(', ')}). A queued job may be waiting on a busy runner; check before assuming it is dead.` }
171    }
172    return { done: false, patch }
173  }
174
175  // Everything finished. Wait for the same picture twice: a slow workflow can still register a run.
176  const sig = `${sha}|` + runs.map(r => `${r.name}=${r.conclusion}`).sort().join(',')
177  if (w.lastSig !== sig) return { done: false, patch: { ...base, lastSig: sig } }
178
179  const bad = runs.filter(r => r.conclusion !== null && BAD.has(r.conclusion))
180  if (bad.length > 0) {
181    return { done: true, wake: `${P}: CI finished on ${short(sha)}: ${bad.length} of ${runs.length} checks failed: ${list(bad)}. ` +
182      'This PR is yours to drive: read the failure (delegate the log), fix and push.' }
183  }
184  const unclear = runs.filter(r => r.conclusion === null || !GOOD.has(r.conclusion))
185  if (unclear.length > 0) {
186    return { done: true, wake: `${P}: CI finished on ${short(sha)} but ${unclear.length} of ${runs.length} checks did not clearly pass: ` +
187      `${list(unclear)}. Read them before treating the PR as green.` }
188  }
189  return { done: true, wake: `${P}: CI finished on ${short(sha)}: all ${runs.length} checks passed. Do what the PR needs next (review threads, ready-for-review, auto-merge).` }
190}
191
types/index.d.ts 23 lines
1/** One PR whose CI this session is waiting on after a push. */
2export type Watch = {
3  owner: string
4  repo: string
5  number: number
6  /** GitHub MCP server the PR is read through (e.g. `github`). */
7  server: string
8  /** The head this session pushed; replaced by the PR's head after ADOPT_HEAD_MS. */
9  sha: string
10  startedAt: number
11  /** Signature of the last all-finished picture; the same one twice means done. */
12  lastSig?: string
13  errors?: number
14  warnedNone?: boolean
15  warnedLong?: boolean
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'pr-ci-watch': { watches: Watch[] }
21  }
22}
23