SLOPSHOPPER

sentry

Watch PRs outside the model: polls CI, reviews and threads with gh, wakes the session only when something actionable happens, and denies sleep-poll loops while…

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-04pourya7/claude-code-mods/sentry
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sentry
│ ┃ SENTRY ✕ › fix the failing auth test and add an audit log call │ ┃ ▀ ▄▀▀▄ ▀ S E N T R Y │ ┃ ▀▀▀▀▀▀▀▀ ★ INSERT COIN ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀ WATCHING 0 ⎿ Read 6 lines │ ┃ ▀▄▄▀ POLL 60S / WAKE ACTIONABLE ⏺ Update(src/auth.ts) │ ┃ ▄▀ ▀▄ ⎿ Added 2 lines, removed 1 line │ ┃ ▄▄▀▄▄▄▄▄▄▀▄▄ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ NO PRS ON WATCH. /watch 42 TO INSERT COIN │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /watch │ ⎿ sentry: SENTRY: no PRs watched. /watch <pr> to insert coin. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · SENTRY
▀ ▄▀▀▄ ▀ S E N T R Y ▀▀▀▀▀▀▀▀ ★ INSERT COIN ▀▀▀▀▀▀ WATCHING 0 ▀▄▄▀ POLL 60S / WAKE ACTIONABLE ▄▀ ▀▄ ▄▄▀▄▄▄▄▄▄▀▄▄ NO PRS ON WATCH. /watch 42 TO INSERT COIN
README
 ███  ████ █   █ █████ ████  █   █      ▀ ▄▀▀▄ ▀
█     █    ██  █   █   █   █  █ █       ▀▀▀▀▀▀▀▀
 ███  ███  █ █ █   █   ████    █         ▀▀▀▀▀▀
    █ █    █  ██   █   █  █    █          ▀▄▄▀
 ███  ████ █   █   █   █   █   █         ▄▀  ▀▄
                                      ▄▄▀▄▄▄▄▄▄▀▄▄

sentry watching PR #12: a sleep-poll the model tries is denied, then CI fails, the status line flips to CIX and sentry wakes the session, which reads the failed log and fixes the bug

sentry — wait outside the model

The model does the waiting. Across 242 studied sessions there were ~1.8k poll loops, ~1.5k sleeps and ~1.2k CI-status calls, plus false-greens read right after a push. sentry watches your PRs on a timer with gh, never starts a model turn just to poll, and wakes the session only when something actionable happens.

Install

/plugin marketplace add pourya7/claude-code-mods
/plugin install sentry@claude-code-mods

It needs the GitHub CLI on PATH, signed in (gh auth login).

Commands

CommandWhat it does
/watch 42Watch PR #42 in the session's repo (found with gh repo view).
/watch owner/repo#42, /watch https://github.com/owner/repo/pull/42Watch a PR in a named repo.
/watchOpen the sentry pane.
/unwatch 42Stop watching PR #42 (or press [ UNWATCH ] in the pane).

The model also gets a pr_state tool (mcp__sentry__pr_state, input { pr? }). It returns the same truth snapshot that sentry polls, for one PR or for every watched PR. The model asks it instead of hand-rolling gh calls.

How it decides

Every intervalSeconds, each open watched PR is polled:

  • PR state. One GraphQL call reads headRefOid, state, mergeStateStatus, reviewDecision, isInMergeQueue, mergedAt and title. The same call reads the ids of unresolved reviewThreads (isResolved: false), paged 100 at a time, so a thread resolved and another opened between two polls still counts as a new thread.
  • CI. sentry reads every check-run on the head SHA (/commits/<sha>/check-runs?per_page=100, paged).

The truth rules:

  • No check-runs on the head yet means pending, never green.
  • CI is green only when every completed run passed (success, neutral or skipped) and none are queued or in progress. Any completed run with another conclusion is failed.
  • A head SHA that changed resets CI to pending.
  • Check-runs that cannot be read (for example a token without the Checks read permission) are unknown, never pending: the status line shows CI? and ERR, the error is toasted once and pr_state says CI UNKNOWN (check-runs unreadable: …).
  • The first poll is a baseline. A state is reported once, when the PR enters it, and never again for the same state on the same head.

Wakes ($.prompt.submit, one line per PR, one prompt per poll):

TransitionPrompt
CI failedSENTRY: PR #12 CI FAILED on abc1234 — failed checks: "lint", "unit". Investigate.
Ready (CI green + approved + no open threads)SENTRY: PR #12 READY on abc1234 — CI green, approved, no open threads.
Changes requestedSENTRY: PR #12 CHANGES REQUESTED — address the review.
New unresolved threadSENTRY: PR #12 2 OPEN THREADS (+1 new) — address them.
Merged / closedSENTRY: PR #12 MERGED. Watch ended. (polling stops)

Check names come from whoever wrote the workflow, so before they reach the prompt each is cut to plain characters (letters, digits, space . : / ( ) -) and 40 columns, quoted, and at most three are listed, then +n more.

Any other change, such as CI starting to run, a new head, approval before CI is green, a thread resolved or merge-queue entry, only redraws and toasts.

Poll denial. While any open PR is watched, these Bash shapes are denied with "sentry is watching PR #12 and will wake you; end your turn instead.":

  • sleep N with N ≥ 20 (20, 30s, 1m, …) run as a command
  • gh pr checks … --watch
  • gh run watch
  • until / while loops that run gh before their done

Quoted text is ignored, so git commit -m "retry: sleep 30 while loading" or gh pr create --title "Wait until gh is up" still runs. Nothing is denied while no PR is watched.

gh failures. A missing gh (or one that cannot start), a gh that runs past its 30 s limit (gh timed out after 30s (network?)) and an unauthenticated gh each produce a one-line error with the fix:

  • /watch replies with the error and adds nothing.
  • During polling, sentry toasts the error once, shows ERR on the status line and keeps the watch, so it recovers by itself.
  • pr_state returns { error }.

The mod never throws.

Configuration (userConfig)

OptionDefaultMeaning
intervalSeconds60Seconds between polls (15–3600).
wakeactionableactionable: submit the wake prompt on an actionable transition. never: toast and redraw only, never submit a prompt.

The UI

The pane (/watch) shows a pixel watchtower. Its beacon takes the colour of the worst PR state:

  • red: CI failed
  • plum: gh error
  • orange: review needs you
  • yellow: on watch
  • lime: ready
  • lavender: done
  • grey: idle

Each PR gets one row of lights.

  ▀ ▄▀▀▄ ▀    S E N T R Y
  ▀▀▀▀▀▀▀▀    ★ ALERT! CI FAILED
   ▀▀▀▀▀▀     WATCHING 1
    ▀▄▄▀      POLL 60S / WAKE ACTIONABLE
   ▄▀  ▀▄
▄▄▀▄▄▄▄▄▄▀▄▄

#12 Add login retry
CI ● REVIEW ● THREADS 1 ● QUEUE ● MERGED ●
abc1234 FAILED (lint) [ UNWATCH ]

With nothing watched, the pane reads NO PRS ON WATCH. /watch 42 TO INSERT COIN.

Status line (the worst PR, then +n more):

SENTRY #12 CIX REV░ 1T
GlyphCIREV
▓greenapproved
?unknown (check-runs unreadable)
▒running
░pending (no runs yet)no decision / review required
Xfailedchanges requested

A toast fires on every transition, e.g. SENTRY #12 CI FAILED or SENTRY #12 NEW HEAD, CI RESET.

The watch list lives in session state ($.state). It survives a hot reload of the mod but not a new session.

Permissions

NetworkRuns processesFilesCalls a modelAuto-submits promptsData leaving the machine
Only through gh, to the GitHub API; the mod makes no HTTP calls of its owngh only: gh repo view, gh api graphql, gh api repos/…/check-runsNone read or writtenNoYes: the one-line wake on an actionable transition; set wake: never to turn it offRepo name, PR number and head SHA, sent by gh to GitHub under your own gh login. The wake prompt goes to your own session's model.

Develop

claude plugin validate sentry
claude plugin test sentry
Source 6 files
hooks/register.tsx 386 lines
1// sentry: wait outside the model. Polls watched PRs with gh on a timer and
2// wakes the session only when something actionable happens.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, Timer } from 'claude-code'
5
6import type { SentrySnapshot, SentryWatch } from '../types'
7import {
8  checkRunsArgv,
9  describeGhFailure,
10  describeSpawnFailure,
11  parseJson,
12  parsePrRef,
13  prQueryArgv,
14  readCheckRunsPage,
15  readPullRequest,
16  REPO_VIEW_ARGV,
17} from './gh'
18import { halfBlockRows, LEVEL_COLOR, LEVEL_LABEL, PICO, SIGNATURE, TOWER } from './pixels'
19import { denyText, sleepPollShape } from './poll'
20import type { CheckRun } from './truth'
21import {
22  buildSnapshot,
23  isActionable,
24  shortSha,
25  statusLine,
26  summaryLine,
27  toastText,
28  transitions,
29  wakeLine,
30  worstLevel,
31} from './truth'
32
33const PANE = 'sentry'
34const TOOL = 'pr_state'
35const MAX_THREAD_PAGES = 10
36const MAX_CHECK_PAGES = 20
37const GH_TIMEOUT_MS = 30_000
38
39const watches = atom({ plugin: 'sentry', key: 'watches' } as const, [] as SentryWatch[])
40
41type Gh = { stdout: string } | { error: string }
42type Polled = { snapshot: SentrySnapshot } | { error: string }
43
44let timer: Timer | null = null
45let isPolling = false
46
47function isLive(watch: SentryWatch): boolean {
48  return watch.snapshot === null || watch.snapshot.state === 'OPEN'
49}
50
51function sameWatch(watch: SentryWatch, repo: string, number: number): boolean {
52  return watch.repo === repo && watch.number === number
53}
54
55/** Runs gh; every failure (missing, timed out, unauthenticated, any non-zero exit) becomes `{ error }`. */
56async function gh($: EngineInterface, argv: readonly string[]): Promise<Gh> {
57  const startedAt = await $.clock.now()
58  try {
59    const ran = await $.process.run(argv, { timeoutMs: GH_TIMEOUT_MS })
60    return ran.exitCode === 0 ? { stdout: ran.stdout } : { error: describeGhFailure(ran.stderr) }
61  } catch (error) {
62    const elapsedMs = (await $.clock.now()) - startedAt
63    return { error: describeSpawnFailure(error instanceof Error ? error.message : String(error), elapsedMs, GH_TIMEOUT_MS) }
64  }
65}
66
67async function sessionRepo($: EngineInterface): Promise<Gh> {
68  const ran = await gh($, REPO_VIEW_ARGV)
69  if ('error' in ran) return { error: `${ran.error} (or pass a PR URL)` }
70  const repo = ran.stdout.trim()
71  return repo === '' ? { error: 'gh found no GitHub repo here: pass a PR URL.' } : { stdout: repo }
72}
73
74/** Every check-run on the head SHA, 100 per page, or why they could not be read. */
75async function checkRuns($: EngineInterface, repo: string, sha: string): Promise<{ runs: CheckRun[] } | { error: string }> {
76  const runs: CheckRun[] = []
77  for (let page = 1; page <= MAX_CHECK_PAGES; page += 1) {
78    const ran = await gh($, checkRunsArgv(repo, sha, page))
79    if ('error' in ran) return { error: ran.error }
80    const read = readCheckRunsPage(parseJson(ran.stdout))
81    if (read === null) return { error: 'unreadable check-runs output' }
82    runs.push(...read.runs)
83    if (read.runs.length < 100 || runs.length >= read.total) return { runs }
84  }
85  return { runs }
86}
87
88/** One full poll of one PR: state, unresolved threads (paged) and head check-runs (paged). */
89async function pollPr($: EngineInterface, repo: string, number: number, previous: SentrySnapshot | null): Promise<Polled> {
90  let after: string | null = null
91  const unresolvedIds: string[] = []
92  let first = null
93  for (let page = 0; page < MAX_THREAD_PAGES; page += 1) {
94    const ran = await gh($, prQueryArgv(repo, number, after))
95    if ('error' in ran) return { error: ran.error }
96    const read = readPullRequest(parseJson(ran.stdout))
97    if ('error' in read) return { error: `PR #${number} in ${repo}: ${read.error}` }
98    first = first ?? read.pr
99    unresolvedIds.push(...read.unresolvedIds)
100    after = read.nextCursor
101    if (after === null) break
102  }
103  if (first === null) return { error: `PR #${number} in ${repo}: not found` }
104  const ci = first.headRefOid === '' ? { runs: [] } : await checkRuns($, repo, first.headRefOid)
105  return 'error' in ci
106    ? { snapshot: buildSnapshot(repo, first, unresolvedIds, null, previous, ci.error) }
107    : { snapshot: buildSnapshot(repo, first, unresolvedIds, ci.runs, previous) }
108}
109
110async function refreshStatus($: EngineInterface) {
111  const list = await read($, watches)
112  const snapshots = list.flatMap(watch => (watch.snapshot ? [watch.snapshot] : []))
113  const error = list.find(watch => watch.error !== null)?.error ?? null
114  $.ui.status(statusLine(snapshots, error))
115}
116
117/**
118 * Polls one watch, records the result and reports what changed: a toast on
119 * every transition, and the wake line when one is actionable. A poll whose
120 * check-runs could not be read records the PR but keeps the error (ERR on the
121 * status line, toasted once).
122 */
123async function pollAndRecord($: EngineInterface, watch: SentryWatch): Promise<string | null> {
124  const polled = await pollPr($, watch.repo, watch.number, watch.snapshot)
125  const checkedAt = await $.clock.now()
126  const error = 'error' in polled ? polled.error : polled.snapshot.ciError === null ? null : `CI unknown: ${polled.snapshot.ciError}`
127  const next: SentryWatch =
128    'error' in polled
129      ? { ...watch, error, checkedAt }
130      : { ...watch, snapshot: polled.snapshot, error, checkedAt }
131  await update($, watches, list => list.map(one => (sameWatch(one, watch.repo, watch.number) ? next : one)))
132
133  if (error !== null && error !== watch.error) $.ui.toast(`SENTRY #${watch.number}: ${error}`)
134  if ('error' in polled) return null
135  const found = transitions(watch.snapshot, polled.snapshot)
136  if (found.length === 0) return null
137  $.ui.toast(toastText(polled.snapshot, found))
138  return found.some(isActionable) ? wakeLine(polled.snapshot, found.filter(isActionable)) : null
139}
140
141/** The timer's work: poll every live watch, then wake once with every actionable line. */
142async function tick($: EngineInterface, wake: string) {
143  if (isPolling) return
144  isPolling = true
145  try {
146    const list = (await read($, watches)).filter(isLive)
147    if (list.length === 0) return
148    const lines: string[] = []
149    for (const watch of list) {
150      const line = await pollAndRecord($, watch)
151      if (line !== null) lines.push(line)
152    }
153    await refreshStatus($)
154    if (wake === 'actionable' && lines.length > 0) await $.prompt.submit({ text: lines.join('\n') })
155  } finally {
156    isPolling = false
157  }
158}
159
160async function watchCommand($: EngineInterface, argument: string): Promise<string> {
161  const ref = parsePrRef(argument)
162  if (ref === null) return `sentry: "${argument}" is not a PR. Use /watch 42, /watch owner/repo#42 or a PR URL.`
163  let repo = ref.repo
164  if (repo === null) {
165    const found = await sessionRepo($)
166    if ('error' in found) return `sentry: ${found.error}`
167    repo = found.stdout
168  }
169  const existing = (await read($, watches)).find(watch => sameWatch(watch, repo, ref.number)) ?? null
170  const polled = await pollPr($, repo, ref.number, existing?.snapshot ?? null)
171  if ('error' in polled) return `sentry: not watching PR #${ref.number}: ${polled.error}`
172  const checkedAt = await $.clock.now()
173  const ciError = polled.snapshot.ciError
174  const watch: SentryWatch = { repo, number: ref.number, snapshot: polled.snapshot, error: ciError === null ? null : `CI unknown: ${ciError}`, checkedAt }
175  await update($, watches, list => [...list.filter(one => !sameWatch(one, repo, ref.number)), watch])
176  await refreshStatus($)
177  const note = polled.snapshot.state === 'OPEN' ? 'I will wake you when it needs you.' : 'It is already closed.'
178  return `SENTRY ON WATCH: ${repo} ${summaryLine(polled.snapshot)}. ${note}`
179}
180
181async function removeWatch($: EngineInterface, repo: string, number: number) {
182  await update($, watches, list => list.filter(watch => !sameWatch(watch, repo, number)))
183  await refreshStatus($)
184}
185
186async function unwatchCommand($: EngineInterface, argument: string): Promise<string> {
187  const ref = parsePrRef(argument)
188  if (ref === null) return 'sentry: say which PR, e.g. /unwatch 42.'
189  const list = await read($, watches)
190  const isGone = (watch: SentryWatch) => watch.number === ref.number && (ref.repo === null || watch.repo === ref.repo)
191  if (!list.some(isGone)) return `sentry: not watching PR #${ref.number}.`
192  await update($, watches, all => all.filter(watch => !isGone(watch)))
193  await refreshStatus($)
194  return `sentry: stopped watching PR #${ref.number}.`
195}
196
197/** The `pr_state` tool: fresh truth for one PR or every watched one (records it; never wakes). */
198async function prState($: EngineInterface, argument: unknown) {
199  const wanted = argument === undefined || argument === null || argument === '' ? null : parsePrRef(String(argument))
200  if (argument !== undefined && argument !== null && argument !== '' && wanted === null) {
201    return { error: `"${String(argument)}" is not a PR number or URL.` }
202  }
203  const list = await read($, watches)
204  let targets = wanted === null ? list : list.filter(watch => watch.number === wanted.number && (wanted.repo === null || watch.repo === wanted.repo))
205  if (wanted !== null && targets.length === 0) {
206    let repo = wanted.repo
207    if (repo === null) {
208      const found = await sessionRepo($)
209      if ('error' in found) return { error: found.error }
210      repo = found.stdout
211    }
212    const polled = await pollPr($, repo, wanted.number, null)
213    if ('error' in polled) return { error: polled.error }
214    return { prs: [{ summary: summaryLine(polled.snapshot), isWatched: false, ...polled.snapshot }] }
215  }
216  if (targets.length === 0) return { prs: [], note: 'No PRs are watched. The person adds one with /watch <pr>.' }
217  for (const watch of targets) {
218    if (isLive(watch)) await pollAndRecord($, watch)
219  }
220  await refreshStatus($)
221  targets = (await read($, watches)).filter(watch => targets.some(one => sameWatch(one, watch.repo, watch.number)))
222  return {
223    prs: targets.map(watch =>
224      watch.snapshot
225        ? { summary: summaryLine(watch.snapshot), isWatched: true, error: watch.error, ...watch.snapshot }
226        : { repo: watch.repo, number: watch.number, isWatched: true, error: watch.error },
227    ),
228  }
229}
230
231export const register: Register = (on, options) => {
232  const intervalMs = Math.max(15, Number(options.intervalSeconds ?? 60)) * 1000
233  const wake = String(options.wake ?? 'actionable')
234
235  on('session.start', async ($, e, next) => {
236    await $.command.register({ name: 'watch', description: 'sentry: watch a PR (number or URL), or open the pane', argumentHint: '[pr]' })
237    await $.command.register({ name: 'unwatch', description: 'sentry: stop watching a PR', argumentHint: '<pr>' })
238    await $.tool.register({
239      name: TOOL,
240      description:
241        'The truth about a pull request, read by sentry with gh: head SHA, CI on that SHA (no check-runs yet = pending, never green), review decision, unresolved threads, merge queue, merged/closed. Call this instead of hand-rolling gh, sleep or watch loops. Leave pr out for every watched PR.',
242      inputSchema: {
243        type: 'object',
244        properties: { pr: { type: ['number', 'string'], description: 'PR number, owner/repo#n or PR URL' } },
245      },
246    })
247    timer?.cancel()
248    timer = $.clock.every(intervalMs, () => {
249      void tick($, wake)
250    })
251    await refreshStatus($)
252    return next(e)
253  })
254
255  on('command.run', { command: 'watch' }, async ($, e) => {
256    const argument = (e.args ?? '').trim()
257    if (argument === '') {
258      await $.ui.open({ id: PANE, title: 'SENTRY' })
259      const list = await read($, watches)
260      return { text: list.length === 0 ? 'SENTRY: no PRs watched. /watch <pr> to insert coin.' : `SENTRY: watching ${list.map(watch => `#${watch.number}`).join(', ')}.` }
261    }
262    return { text: await watchCommand($, argument) }
263  })
264
265  on('command.run', { command: 'unwatch' }, async ($, e) => ({ text: await unwatchCommand($, (e.args ?? '').trim()) }))
266
267  on('tool.call', { tool: 'mcp__sentry__pr_state' }, async ($, e) => {
268    const input = e as unknown as { pr?: unknown }
269    return { result: await prState($, input.pr) }
270  })
271
272  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
273    const shape = sleepPollShape(e.command)
274    if (shape === null) return next(e)
275    const live = (await read($, watches)).filter(isLive)
276    if (live.length === 0) return next(e)
277    return { deny: denyText(live.map(watch => watch.number)) }
278  })
279
280  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
281    const { Box, Button, Text } = $.ui.resolve(e)
282    const list = await read($, watches)
283    const snapshots = list.flatMap(watch => (watch.snapshot ? [watch.snapshot] : []))
284    const level = worstLevel(snapshots, list.some(watch => watch.error !== null))
285    const beacon = LEVEL_COLOR[level]
286    const width = Math.max(20, e.props.bodyColumns - 10)
287
288    const light = (label: string, color: string) => (
289      <Box flexDirection="row" marginRight={1}>
290        <Text color={PICO.lightGrey}>{label} </Text>
291        <Text color={color}>●</Text>
292      </Box>
293    )
294
295    return (
296      <Box flexDirection="column">
297        <Box flexDirection="row">
298          <Box key="tower" flexDirection="column" marginRight={2}>
299            {halfBlockRows(TOWER, beacon).map(runs => (
300              <Box flexDirection="row">
301                {runs.map(run => (
302                  <Text color={run.color} backgroundColor={run.backgroundColor}>
303                    {run.text}
304                  </Text>
305                ))}
306              </Box>
307            ))}
308          </Box>
309          <Box flexDirection="column">
310            <Text bold color={SIGNATURE}>
311              S E N T R Y
312            </Text>
313            <Text bold color={beacon}>
314              ★ {LEVEL_LABEL[level]}
315            </Text>
316            <Text color={PICO.lightGrey}>WATCHING {list.length}</Text>
317            <Text dimColor>POLL {Math.round(intervalMs / 1000)}S / WAKE {wake.toUpperCase()}</Text>
318          </Box>
319        </Box>
320        {list.length === 0 && (
321          <Box key="empty" marginTop={1}>
322            <Text color={PICO.lightGrey}>NO PRS ON WATCH. /watch 42 TO INSERT COIN</Text>
323          </Box>
324        )}
325        {list.map(watch => {
326          const snapshot = watch.snapshot
327          const ciColor = !snapshot
328            ? PICO.darkGrey
329            : snapshot.ci === 'green'
330              ? PICO.lime
331              : snapshot.ci === 'failed'
332                ? PICO.red
333                : snapshot.ci === 'running'
334                  ? PICO.yellow
335                  : PICO.darkGrey
336          const reviewColor =
337            snapshot?.reviewDecision === 'APPROVED'
338              ? PICO.lime
339              : snapshot?.reviewDecision === 'CHANGES_REQUESTED'
340                ? PICO.red
341                : snapshot?.reviewDecision === 'REVIEW_REQUIRED'
342                  ? PICO.yellow
343                  : PICO.darkGrey
344          const threadColor = !snapshot ? PICO.darkGrey : snapshot.openThreads > 0 ? PICO.orange : PICO.lime
345          const queueColor = snapshot?.isInMergeQueue ? PICO.blue : PICO.darkGrey
346          const mergedColor =
347            snapshot?.state === 'MERGED' ? PICO.lime : snapshot?.state === 'CLOSED' ? PICO.red : PICO.darkGrey
348          const title = snapshot ? snapshot.title : watch.repo
349          return (
350            <Box key={`pr-${watch.number}`} flexDirection="column" marginTop={1}>
351              <Box flexDirection="row">
352                <Text bold color={SIGNATURE}>
353                  #{watch.number}{' '}
354                </Text>
355                <Text wrap="truncate-end">{title.length > width ? `${title.slice(0, width - 1)}~` : title}</Text>
356              </Box>
357              <Box flexDirection="row">
358                {light('CI', ciColor)}
359                {light('REVIEW', reviewColor)}
360                {light(`THREADS${snapshot && snapshot.openThreads > 0 ? ` ${snapshot.openThreads}` : ''}`, threadColor)}
361                {light('QUEUE', queueColor)}
362                {light('MERGED', mergedColor)}
363              </Box>
364              <Box flexDirection="row">
365                <Text dimColor>
366                  {snapshot ? `${shortSha(snapshot.headSha)} ${snapshot.ci.toUpperCase()}` : 'NO DATA YET'}
367                  {snapshot && snapshot.failedChecks.length > 0 ? ` (${snapshot.failedChecks.slice(0, 3).join(', ')})` : ''}{' '}
368                </Text>
369                <Button
370                  key={`unwatch-${watch.number}`}
371                  label="UNWATCH"
372                  dimColor
373                  onPress={() => removeWatch($, watch.repo, watch.number)}
374                />
375              </Box>
376              {watch.error !== null && (
377                <Text color={PICO.red}>{watch.error}</Text>
378              )}
379            </Box>
380          )
381        })}
382      </Box>
383    )
384  })
385}
386
hooks/gh.ts 142 lines
1// gh: argv builders, output readers and failure messages. Pure: no `$` here.
2import type { CheckRun, PullRequestPayload } from './truth'
3
4export type PrRef = { number: number; repo: string | null }
5
6const URL_REF = /^(?:https?:\/\/)?(?:www\.)?github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)(?:[/?#].*)?$/i
7const SLUG_REF = /^([\w.-]+\/[\w.-]+)#(\d+)$/
8const NUMBER_REF = /^#?(\d+)$/
9
10/** `12`, `#12`, `owner/repo#12` or a PR URL; null for anything else. */
11export function parsePrRef(argument: string): PrRef | null {
12  const text = argument.trim()
13  const url = URL_REF.exec(text)
14  const slug = SLUG_REF.exec(text)
15  const bare = NUMBER_REF.exec(text)
16  const [repo, digits] = url ? [url[1], url[2]] : slug ? [slug[1], slug[2]] : bare ? [null, bare[1]] : [null, undefined]
17  const number = Number(digits)
18  if (digits === undefined || !Number.isInteger(number) || number <= 0) return null
19  return { number, repo: repo ?? null }
20}
21
22export const REPO_VIEW_ARGV = ['gh', 'repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'] as const
23
24const PR_QUERY = `query($owner: String!, $name: String!, $number: Int!, $after: String) {
25  repository(owner: $owner, name: $name) {
26    pullRequest(number: $number) {
27      number title url state headRefOid mergeStateStatus reviewDecision isInMergeQueue mergedAt
28      reviewThreads(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id isResolved } }
29    }
30  }
31}`
32
33/** One GraphQL call: the PR's state and one page of its review threads. */
34export function prQueryArgv(repo: string, number: number, after: string | null): string[] {
35  const [owner = '', name = ''] = repo.split('/')
36  const argv = ['gh', 'api', 'graphql', '-f', `query=${PR_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${number}`]
37  if (after !== null) argv.push('-f', `after=${after}`)
38  return argv
39}
40
41/** One page of the check-runs on a head SHA (the latest run per check). */
42export function checkRunsArgv(repo: string, sha: string, page: number): string[] {
43  return ['gh', 'api', `repos/${repo}/commits/${sha}/check-runs?per_page=100&page=${page}`]
44}
45
46/**
47 * JSON.parse that survives the raw control characters GitHub sometimes leaves
48 * inside check-run output strings (each becomes a space).
49 */
50export function parseJson(text: string): unknown {
51  try {
52    // eslint-disable-next-line no-control-regex
53    return JSON.parse(text.replace(/[\u0000-\u001f]/g, ' '))
54  } catch {
55    return null
56  }
57}
58
59function field(value: unknown, key: string): unknown {
60  return typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined
61}
62
63function cursorOf(info: unknown): string {
64  const cursor = field(info, 'endCursor')
65  return typeof cursor === 'string' ? cursor : 'last'
66}
67
68export type PullRequestPage =
69  | { pr: PullRequestPayload; unresolvedIds: string[]; nextCursor: string | null }
70  | { error: string }
71
72export function readPullRequest(json: unknown): PullRequestPage {
73  if (typeof json !== 'object' || json === null) return { error: 'unreadable gh output' }
74  const pr = field(field(field(json, 'data'), 'repository'), 'pullRequest')
75  if (typeof pr !== 'object' || pr === null) return { error: 'not found' }
76  const threads = field(pr, 'reviewThreads')
77  const nodes = field(threads, 'nodes')
78  const info = field(threads, 'pageInfo')
79  const unresolvedIds: string[] = []
80  if (Array.isArray(nodes)) {
81    nodes.forEach((node, index) => {
82      if (field(node, 'isResolved') !== false) return
83      const id = field(node, 'id')
84      unresolvedIds.push(typeof id === 'string' && id !== '' ? id : `unknown-${cursorOf(info)}-${index}`)
85    })
86  }
87  const hasNext = field(info, 'hasNextPage') === true
88  const cursor = field(info, 'endCursor')
89  return {
90    pr: {
91      number: Number(field(pr, 'number')),
92      title: String(field(pr, 'title') ?? ''),
93      url: String(field(pr, 'url') ?? ''),
94      state: String(field(pr, 'state') ?? 'OPEN'),
95      headRefOid: String(field(pr, 'headRefOid') ?? ''),
96      mergeStateStatus: (field(pr, 'mergeStateStatus') as string | null) ?? null,
97      reviewDecision: (field(pr, 'reviewDecision') as string | null) ?? null,
98      isInMergeQueue: field(pr, 'isInMergeQueue') === true,
99      mergedAt: (field(pr, 'mergedAt') as string | null) ?? null,
100    },
101    unresolvedIds,
102    nextCursor: hasNext && typeof cursor === 'string' ? cursor : null,
103  }
104}
105
106export function readCheckRunsPage(json: unknown): { total: number; runs: CheckRun[] } | null {
107  const list = field(json, 'check_runs')
108  const total = field(json, 'total_count')
109  if (!Array.isArray(list) || typeof total !== 'number') return null
110  return {
111    total,
112    runs: list.map(run => ({
113      name: String(field(run, 'name') ?? ''),
114      status: String(field(run, 'status') ?? ''),
115      conclusion: (field(run, 'conclusion') as string | null) ?? null,
116    })),
117  }
118}
119
120const UNAUTHENTICATED = /gh auth login|not logged in|authentication|bad credentials|HTTP 401|GH_TOKEN/i
121
122/** A gh run that exited non-zero, said in one line. */
123export function describeGhFailure(stderr: string): string {
124  if (UNAUTHENTICATED.test(stderr)) return 'gh is not authenticated: run `gh auth login`, then /watch again.'
125  const first = stderr.trim().split('\n')[0]?.trim() ?? ''
126  return first === '' ? 'gh failed with no message' : `gh failed: ${first.slice(0, 200)}`
127}
128
129const TIMED_OUT = /timed? ?out|timeout|ETIMEDOUT|still running/i
130
131/**
132 * A gh run that rejected: it timed out (ran `elapsedMs` of a `timeoutMs`
133 * budget, or says so) or it could not start at all.
134 */
135export function describeSpawnFailure(message: string, elapsedMs = 0, timeoutMs = Number.POSITIVE_INFINITY): string {
136  if (elapsedMs >= timeoutMs || TIMED_OUT.test(message)) {
137    const seconds = Number.isFinite(timeoutMs) ? `${Math.round(timeoutMs / 1000)}s` : 'its time limit'
138    return `gh timed out after ${seconds} (network?); sentry will try again next poll.`
139  }
140  return `gh could not run (${message.slice(0, 120)}): install the GitHub CLI (cli.github.com) and put it on PATH.`
141}
142
hooks/pixels.ts 121 lines
1// Pixel art: the PICO-8 palette, the watchtower sprite and a half-block renderer.
2import type { Level } from './truth'
3
4export const PICO = {
5  black: '#000000',
6  navy: '#1D2B53',
7  plum: '#7E2553',
8  green: '#008751',
9  brown: '#AB5236',
10  darkGrey: '#5F574F',
11  lightGrey: '#C2C3C7',
12  white: '#FFF1E8',
13  red: '#FF004D',
14  orange: '#FFA300',
15  yellow: '#FFEC27',
16  lime: '#00E436',
17  blue: '#29ADFF',
18  lavender: '#83769C',
19  pink: '#FF77A8',
20  peach: '#FFCCAA',
21} as const
22
23/** Sentry's signature colour. */
24export const SIGNATURE = PICO.yellow
25
26export const LEVEL_COLOR: Record<Level, string> = {
27  failed: PICO.red,
28  error: PICO.plum,
29  attention: PICO.orange,
30  waiting: PICO.yellow,
31  ready: PICO.lime,
32  done: PICO.lavender,
33  idle: PICO.darkGrey,
34}
35
36export const LEVEL_LABEL: Record<Level, string> = {
37  failed: 'ALERT! CI FAILED',
38  error: 'NO SIGNAL (GH)',
39  attention: 'HEADS UP: REVIEW',
40  waiting: 'ON WATCH...',
41  ready: 'ALL CLEAR! READY',
42  done: 'GAME OVER: DONE',
43  idle: 'INSERT COIN',
44}
45
46/**
47 * The watchtower, 12 x 12 pixels (6 terminal rows). `*` is the beacon,
48 * coloured by the worst PR; `.` is transparent.
49 */
50export const TOWER: readonly string[] = [
51  '..*..**..*..',
52  '....*yy*....',
53  '..nnnnnnnn..',
54  '...nnnnnn...',
55  '...bwbbwb...',
56  '...bbbbbb...',
57  '....b..b....',
58  '....bbbb....',
59  '....b..b....',
60  '...b....b...',
61  '..b......b..',
62  'gggggggggggg',
63]
64
65const KEYS: Record<string, string> = {
66  k: PICO.black,
67  n: PICO.navy,
68  b: PICO.brown,
69  g: PICO.green,
70  w: PICO.white,
71  y: PICO.yellow,
72}
73
74/** One run of same-styled cells in a terminal row. */
75export type PixelRun = { text: string; color?: string; backgroundColor?: string }
76
77function colorAt(grid: readonly string[], row: number, column: number, beacon: string): string | undefined {
78  const key = grid[row]?.[column] ?? '.'
79  if (key === '.') return undefined
80  if (key === '*') return beacon
81  return KEYS[key]
82}
83
84/**
85 * Two pixel rows per terminal row: `▀` with the top pixel as `color` and the
86 * bottom as `backgroundColor`; `▄` when only the bottom is set; a space when
87 * neither. Adjacent cells with the same style merge into one run.
88 */
89export function halfBlockRows(grid: readonly string[], beacon: string): PixelRun[][] {
90  const width = Math.max(0, ...grid.map(line => line.length))
91  const rows: PixelRun[][] = []
92  for (let top = 0; top < grid.length; top += 2) {
93    const runs: PixelRun[] = []
94    for (let column = 0; column < width; column += 1) {
95      const upper = colorAt(grid, top, column, beacon)
96      const lower = colorAt(grid, top + 1, column, beacon)
97      const cell: PixelRun =
98        upper !== undefined
99          ? lower !== undefined
100            ? { text: '▀', color: upper, backgroundColor: lower }
101            : { text: '▀', color: upper }
102          : lower !== undefined
103            ? { text: '▄', color: lower }
104            : { text: ' ' }
105      const last = runs[runs.length - 1]
106      if (last && last.color === cell.color && last.backgroundColor === cell.backgroundColor && last.text[0] === cell.text) {
107        last.text += cell.text
108      } else {
109        runs.push(cell)
110      }
111    }
112    rows.push(runs)
113  }
114  return rows
115}
116
117/** The plain-text capture of a sprite (what a monochrome terminal shows). */
118export function plainRows(grid: readonly string[]): string[] {
119  return halfBlockRows(grid, PICO.yellow).map(runs => runs.map(run => run.text).join(''))
120}
121
hooks/poll.ts 39 lines
1// The Bash shapes that hand-roll waiting on CI, which sentry does instead.
2
3// Where a new command starts: the start of the line, after a separator or
4// pipe (; & | ( { ` or a newline), or after a shell keyword that opens a body.
5const COMMAND_START = String.raw`(?:^|[;&|({\x60\n]|\b(?:do|then|else)\b)\s*`
6
7const SLEEP = new RegExp(`${COMMAND_START}sleep\\s+(\\d+(?:\\.\\d+)?)([smhd]?)\\b`, 'g')
8const CHECKS_WATCH = /\bgh\s+pr\s+checks\b[^;&|]*\s--watch\b/
9const RUN_WATCH = /\bgh\s+run\s+watch\b/
10const LOOP = new RegExp(`${COMMAND_START}(?:until|while)\\b([\\s\\S]*?)\\bdone\\b`, 'g')
11const GH_COMMAND = new RegExp(`${COMMAND_START}gh\\s`)
12const QUOTED = /\$'(?:[^'\\]|\\[\s\S])*'|'[^']*'|"(?:[^"\\]|\\[\s\S])*"/g
13
14const UNIT_SECONDS: Record<string, number> = { '': 1, s: 1, m: 60, h: 3600, d: 86400 }
15
16/** The command with every quoted string emptied, so words in messages and titles never match. */
17export function withoutQuotes(command: string): string {
18  return command.replace(QUOTED, "''")
19}
20
21/** Names the sleep-poll shape a command has, or null when it has none. */
22export function sleepPollShape(command: string): string | null {
23  const text = withoutQuotes(command)
24  for (const match of text.matchAll(SLEEP)) {
25    const seconds = Number(match[1]) * (UNIT_SECONDS[match[2] ?? ''] ?? 1)
26    if (seconds >= 20) return `sleep ${match[1]}${match[2] ?? ''}`
27  }
28  if (CHECKS_WATCH.test(text)) return 'gh pr checks --watch'
29  if (RUN_WATCH.test(text)) return 'gh run watch'
30  for (const match of text.matchAll(LOOP)) {
31    if (GH_COMMAND.test(match[1] ?? '')) return 'a loop that polls gh'
32  }
33  return null
34}
35
36export function denyText(numbers: readonly number[]): string {
37  return `sentry is watching PR ${numbers.map(number => `#${number}`).join(', ')} and will wake you; end your turn instead.`
38}
39
hooks/truth.ts 318 lines
1// The truth rules: what gh output means, which changes matter, and how to say so.
2import type { SentryCi, SentryLifecycle, SentrySnapshot } from '../types'
3
4/** One check-run as the GitHub REST API reports it. */
5export type CheckRun = { name: string; status: string; conclusion: string | null }
6
7/** The PR fields read from GraphQL. */
8export type PullRequestPayload = {
9  number: number
10  title: string
11  url: string
12  state: string
13  headRefOid: string
14  mergeStateStatus: string | null
15  reviewDecision: string | null
16  isInMergeQueue: boolean | null
17  mergedAt: string | null
18}
19
20export type CiVerdict = { ci: SentryCi; failedChecks: string[]; runningChecks: number; checkCount: number }
21
22export type TransitionKind =
23  | 'ci-failed'
24  | 'ready'
25  | 'changes-requested'
26  | 'new-thread'
27  | 'merged'
28  | 'closed'
29  | 'ci-green'
30  | 'ci-running'
31  | 'head-moved'
32  | 'approved'
33  | 'review-changed'
34  | 'threads-resolved'
35  | 'queued'
36  | 'dequeued'
37
38export type Transition = { kind: TransitionKind; delta?: number }
39
40/** Worst first: the beacon shows the first level any PR is at. */
41export type Level = 'failed' | 'error' | 'attention' | 'waiting' | 'ready' | 'done' | 'idle'
42
43const LEVEL_ORDER: readonly Level[] = ['failed', 'error', 'attention', 'waiting', 'ready', 'done', 'idle']
44const PASSED = new Set(['success', 'neutral', 'skipped'])
45const ACTIONABLE = new Set<TransitionKind>(['ci-failed', 'ready', 'changes-requested', 'new-thread', 'merged', 'closed'])
46
47export function shortSha(sha: string): string {
48  return sha.slice(0, 7)
49}
50
51const CHECK_NAME_MAX = 40
52const CHECK_NAMES_SHOWN = 3
53
54/**
55 * A check-run name made safe to show and to put in a wake prompt: anyone who
56 * can push a workflow names it, so keep a plain character set and a short cap. Idempotent.
57 */
58export function checkName(name: string): string {
59  const safe = name
60    .replace(/[^\w .:/()~-]+/g, ' ')
61    .replace(/\s+/g, ' ')
62    .trim()
63  if (safe === '') return 'unnamed'
64  return safe.length > CHECK_NAME_MAX ? `${safe.slice(0, CHECK_NAME_MAX - 1)}~` : safe
65}
66
67/** At most three quoted, sanitised check names, then `+n more`: data, never instructions. */
68export function checkList(names: readonly string[]): string {
69  const shown = names.slice(0, CHECK_NAMES_SHOWN).map(name => `"${checkName(name)}"`)
70  const more = names.length > CHECK_NAMES_SHOWN ? ` +${names.length - CHECK_NAMES_SHOWN} more` : ''
71  return `${shown.join(', ')}${more}`
72}
73
74/**
75 * CI for one head SHA. No runs is pending (never green); any completed run
76 * that did not pass is failed; any run not completed is running; else green.
77 */
78export function ciFromRuns(runs: readonly CheckRun[]): CiVerdict {
79  const failedChecks: string[] = []
80  let runningChecks = 0
81  for (const run of runs) {
82    if (run.status !== 'completed') runningChecks += 1
83    else if (!PASSED.has(run.conclusion ?? '')) failedChecks.push(checkName(run.name))
84  }
85  const ci: SentryCi =
86    runs.length === 0 ? 'pending' : failedChecks.length > 0 ? 'failed' : runningChecks > 0 ? 'running' : 'green'
87  return { ci, failedChecks, runningChecks, checkCount: runs.length }
88}
89
90function lifecycle(state: string): SentryLifecycle {
91  return state === 'MERGED' || state === 'CLOSED' ? state : 'OPEN'
92}
93
94/**
95 * A snapshot from one poll. `runs` null means the check-runs could not be
96 * read (`ciError` says why): the last CI stands only while the head is the
97 * same one.
98 */
99export function buildSnapshot(
100  repo: string,
101  pr: PullRequestPayload,
102  openThreadIds: readonly string[],
103  runs: readonly CheckRun[] | null,
104  previous: SentrySnapshot | null,
105  ciError: string | null = null,
106): SentrySnapshot {
107  const sameHead = previous !== null && previous.headSha === pr.headRefOid
108  const verdict: CiVerdict =
109    runs !== null
110      ? ciFromRuns(runs)
111      : sameHead && previous !== null
112        ? {
113            ci: previous.ci,
114            failedChecks: previous.failedChecks,
115            runningChecks: previous.runningChecks,
116            checkCount: previous.checkCount,
117          }
118        : { ci: 'pending', failedChecks: [], runningChecks: 0, checkCount: 0 }
119  return {
120    repo,
121    number: pr.number,
122    title: pr.title,
123    url: pr.url,
124    state: lifecycle(pr.state),
125    headSha: pr.headRefOid,
126    mergeStateStatus: pr.mergeStateStatus ?? '',
127    reviewDecision: pr.reviewDecision ?? '',
128    isInMergeQueue: pr.isInMergeQueue === true,
129    mergedAt: pr.mergedAt,
130    openThreads: openThreadIds.length,
131    openThreadIds: [...openThreadIds],
132    ...verdict,
133    ciError: runs === null ? (ciError ?? 'check-runs unreadable') : null,
134  }
135}
136
137export function isReady(snapshot: SentrySnapshot): boolean {
138  return (
139    snapshot.state === 'OPEN' &&
140    snapshot.ci === 'green' &&
141    snapshot.reviewDecision === 'APPROVED' &&
142    snapshot.openThreads === 0
143  )
144}
145
146export function isActionable(transition: Transition): boolean {
147  return ACTIONABLE.has(transition.kind)
148}
149
150/**
151 * Unresolved threads opened and resolved between two polls, by thread id. A
152 * snapshot stored before ids were kept falls back to comparing counts.
153 */
154function threadChange(previous: SentrySnapshot, next: SentrySnapshot): { opened: number; resolved: number } {
155  const before = Array.isArray(previous.openThreadIds) ? previous.openThreadIds : null
156  const after = Array.isArray(next.openThreadIds) ? next.openThreadIds : null
157  if (before === null || after === null) {
158    const delta = next.openThreads - previous.openThreads
159    return { opened: Math.max(0, delta), resolved: Math.max(0, -delta) }
160  }
161  const was = new Set(before)
162  const now = new Set(after)
163  return {
164    opened: after.filter(id => !was.has(id)).length,
165    resolved: before.filter(id => !now.has(id)).length,
166  }
167}
168
169/**
170 * What changed between two polls of one PR. The first observation is a
171 * baseline (no transitions), so a state is reported once, when it is entered.
172 */
173export function transitions(previous: SentrySnapshot | null, next: SentrySnapshot): Transition[] {
174  if (previous === null) return []
175  if (next.state === 'MERGED') return previous.state === 'MERGED' ? [] : [{ kind: 'merged' }]
176  if (next.state === 'CLOSED') return previous.state === 'CLOSED' ? [] : [{ kind: 'closed' }]
177
178  const found: Transition[] = []
179  const sameHead = previous.headSha === next.headSha
180  const ready = isReady(next)
181
182  if (!sameHead) found.push({ kind: 'head-moved' })
183
184  if (next.ci === 'failed') {
185    if (!(sameHead && previous.ci === 'failed')) found.push({ kind: 'ci-failed' })
186  } else if (sameHead && previous.ci !== next.ci) {
187    if (next.ci === 'green') {
188      if (!ready) found.push({ kind: 'ci-green' })
189    } else {
190      found.push({ kind: 'ci-running' })
191    }
192  }
193
194  if (ready && !(sameHead && isReady(previous))) found.push({ kind: 'ready' })
195
196  if (previous.reviewDecision !== next.reviewDecision) {
197    if (next.reviewDecision === 'CHANGES_REQUESTED') found.push({ kind: 'changes-requested' })
198    else if (next.reviewDecision === 'APPROVED') {
199      if (!ready) found.push({ kind: 'approved' })
200    } else found.push({ kind: 'review-changed' })
201  }
202
203  const threads = threadChange(previous, next)
204  if (threads.opened > 0) found.push({ kind: 'new-thread', delta: threads.opened })
205  if (threads.resolved > 0) found.push({ kind: 'threads-resolved' })
206
207  if (next.isInMergeQueue !== previous.isInMergeQueue) {
208    found.push({ kind: next.isInMergeQueue ? 'queued' : 'dequeued' })
209  }
210  return found
211}
212
213function plural(count: number, word: string): string {
214  return `${count} ${word}${count === 1 ? '' : 'S'}`
215}
216
217function wakePhrase(snapshot: SentrySnapshot, transition: Transition): string | null {
218  const sha = shortSha(snapshot.headSha)
219  switch (transition.kind) {
220    case 'ci-failed':
221      return snapshot.failedChecks.length > 0
222        ? `CI FAILED on ${sha} — failed checks: ${checkList(snapshot.failedChecks)}. Investigate.`
223        : `CI FAILED on ${sha}. Investigate.`
224    case 'ready':
225      return `READY on ${sha} — CI green, approved, no open threads.`
226    case 'changes-requested':
227      return 'CHANGES REQUESTED — address the review.'
228    case 'new-thread':
229      return `${plural(snapshot.openThreads, 'OPEN THREAD')} (+${transition.delta ?? 1} new) — address them.`
230    case 'merged':
231      return 'MERGED. Watch ended.'
232    case 'closed':
233      return 'CLOSED without merging. Watch ended.'
234    default:
235      return null
236  }
237}
238
239/** The one-line prompt that wakes the session, or null when nothing is actionable. */
240export function wakeLine(snapshot: SentrySnapshot, found: readonly Transition[]): string | null {
241  const phrases = found.map(one => wakePhrase(snapshot, one)).filter((one): one is string => one !== null)
242  return phrases.length === 0 ? null : `SENTRY: PR #${snapshot.number} ${phrases.join(' ')}`
243}
244
245const TOAST_LABEL: Record<TransitionKind, string> = {
246  'ci-failed': 'CI FAILED',
247  ready: 'READY!',
248  'changes-requested': 'CHANGES REQUESTED',
249  'new-thread': 'NEW THREAD',
250  merged: 'MERGED',
251  closed: 'CLOSED',
252  'ci-green': 'CI GREEN',
253  'ci-running': 'CI RUNNING',
254  'head-moved': 'NEW HEAD, CI RESET',
255  approved: 'APPROVED',
256  'review-changed': 'REVIEW CHANGED',
257  'threads-resolved': 'THREAD RESOLVED',
258  queued: 'IN MERGE QUEUE',
259  dequeued: 'LEFT MERGE QUEUE',
260}
261
262/** The toast for one poll's transitions of one PR. */
263export function toastText(snapshot: SentrySnapshot, found: readonly Transition[]): string {
264  return `SENTRY #${snapshot.number} ${found.map(one => TOAST_LABEL[one.kind]).join(' / ')}`
265}
266
267export function levelOf(snapshot: SentrySnapshot): Level {
268  if (snapshot.state !== 'OPEN') return 'done'
269  if (snapshot.ci === 'failed') return 'failed'
270  if (snapshot.reviewDecision === 'CHANGES_REQUESTED' || snapshot.openThreads > 0) return 'attention'
271  if (isReady(snapshot)) return 'ready'
272  return 'waiting'
273}
274
275/** The worst level over every watched PR (an error counts as `error`). */
276export function worstLevel(snapshots: readonly SentrySnapshot[], hasError = false): Level {
277  const levels: Level[] = snapshots.map(levelOf)
278  if (hasError) levels.push('error')
279  let worst: Level = 'idle'
280  for (const level of levels) if (LEVEL_ORDER.indexOf(level) < LEVEL_ORDER.indexOf(worst)) worst = level
281  return worst
282}
283
284const CI_GLYPH: Record<SentryCi, string> = { green: '▓', running: '▒', pending: '░', failed: 'X' }
285
286function reviewGlyph(decision: string): string {
287  return decision === 'APPROVED' ? '▓' : decision === 'CHANGES_REQUESTED' ? 'X' : '░'
288}
289
290/** The status line for the worst PR, e.g. `SENTRY #12 CI▓ REV░ 0T +1`. */
291export function statusLine(snapshots: readonly SentrySnapshot[], error: string | null): string | undefined {
292  if (snapshots.length === 0) return error === null ? undefined : 'SENTRY ERR: SEE /watch'
293  const worst = [...snapshots].sort(
294    (a, b) => LEVEL_ORDER.indexOf(levelOf(a)) - LEVEL_ORDER.indexOf(levelOf(b)),
295  )[0] as SentrySnapshot
296  const more = snapshots.length > 1 ? ` +${snapshots.length - 1}` : ''
297  const flag = error === null ? '' : ' ERR'
298  if (worst.state !== 'OPEN') return `SENTRY #${worst.number} ${worst.state}${more}${flag}`
299  return `SENTRY #${worst.number} CI${worst.ciError != null ? '?' : CI_GLYPH[worst.ci]} REV${reviewGlyph(worst.reviewDecision)} ${worst.openThreads}T${more}${flag}`
300}
301
302/** A one-line text summary of a snapshot, for command replies and `pr_state`. */
303export function summaryLine(snapshot: SentrySnapshot): string {
304  const ci =
305    snapshot.ciError != null
306      ? `CI UNKNOWN (check-runs unreadable: ${snapshot.ciError})`
307      : snapshot.ci === 'failed'
308      ? `CI FAILED (${snapshot.failedChecks.length > 0 ? checkList(snapshot.failedChecks) : 'unnamed'})`
309      : snapshot.ci === 'green'
310        ? `CI GREEN (${snapshot.checkCount} checks)`
311        : snapshot.ci === 'running'
312          ? `CI RUNNING (${snapshot.runningChecks}/${snapshot.checkCount} left)`
313          : 'CI PENDING (no check-runs yet)'
314  const review = snapshot.reviewDecision === '' ? 'NO REVIEW DECISION' : snapshot.reviewDecision.replaceAll('_', ' ')
315  const queue = snapshot.isInMergeQueue ? ', IN MERGE QUEUE' : ''
316  return `PR #${snapshot.number} ${snapshot.state} on ${shortSha(snapshot.headSha)}: ${ci}, ${review}, ${plural(snapshot.openThreads, 'OPEN THREAD')}${queue}${isReady(snapshot) ? ' — READY' : ''}`
317}
318
types/index.d.ts 44 lines
1/** CI on the head SHA: no runs yet is `pending`, never green. */
2export type SentryCi = 'pending' | 'running' | 'failed' | 'green'
3
4/** The PR's lifecycle as GitHub reports it. */
5export type SentryLifecycle = 'OPEN' | 'CLOSED' | 'MERGED'
6
7/** One truth snapshot of a PR, built from gh output. */
8export type SentrySnapshot = {
9  repo: string
10  number: number
11  title: string
12  url: string
13  state: SentryLifecycle
14  headSha: string
15  mergeStateStatus: string
16  reviewDecision: string
17  isInMergeQueue: boolean
18  mergedAt: string | null
19  ci: SentryCi
20  checkCount: number
21  runningChecks: number
22  failedChecks: string[]
23  openThreads: number
24  /** The ids of the unresolved review threads, so a swap (one resolved, one opened) still reads as new. */
25  openThreadIds: string[]
26  /** Why the head's check-runs could not be read this poll, or null when they were. */
27  ciError: string | null
28}
29
30/** One watched PR: the last snapshot, or the last error. */
31export type SentryWatch = {
32  repo: string
33  number: number
34  snapshot: SentrySnapshot | null
35  error: string | null
36  checkedAt: number
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    sentry: { watches: SentryWatch[] }
42  }
43}
44