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…

███ ████ █ █ █████ ████ █ █ ▀ ▄▀▀▄ ▀
█ █ ██ █ █ █ █ █ █ ▀▀▀▀▀▀▀▀
███ ███ █ █ █ █ ████ █ ▀▀▀▀▀▀
█ █ █ ██ █ █ █ █ ▀▄▄▀
███ ████ █ █ █ █ █ █ ▄▀ ▀▄
▄▄▀▄▄▄▄▄▄▀▄▄

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.
/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).
| Command | What it does |
|---|---|
/watch 42 | Watch PR #42 in the session's repo (found with gh repo view). |
/watch owner/repo#42, /watch https://github.com/owner/repo/pull/42 | Watch a PR in a named repo. |
/watch | Open the sentry pane. |
/unwatch 42 | Stop 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.
Every intervalSeconds, each open watched PR is polled:
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./commits/<sha>/check-runs?per_page=100, paged).The truth rules:
success, neutral or skipped) and none are queued or in progress. Any completed run with another conclusion is failed.CI? and ERR, the error is toasted once and pr_state says CI UNKNOWN (check-runs unreadable: …).Wakes ($.prompt.submit, one line per PR, one prompt per poll):
| Transition | Prompt |
|---|---|
| CI failed | SENTRY: 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 requested | SENTRY: PR #12 CHANGES REQUESTED — address the review. |
| New unresolved thread | SENTRY: PR #12 2 OPEN THREADS (+1 new) — address them. |
| Merged / closed | SENTRY: 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 commandgh pr checks … --watchgh run watchuntil / while loops that run gh before their doneQuoted 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.ERR on the status line and keeps the watch, so it recovers by itself.pr_state returns { error }.The mod never throws.
userConfig)| Option | Default | Meaning |
|---|---|---|
intervalSeconds | 60 | Seconds between polls (15–3600). |
wake | actionable | actionable: submit the wake prompt on an actionable transition. never: toast and redraw only, never submit a prompt. |
The pane (/watch) shows a pixel watchtower. Its beacon takes the colour of the worst PR state:
gh errorEach 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
| Glyph | CI | REV |
|---|---|---|
▓ | green | approved |
? | unknown (check-runs unreadable) | |
▒ | running | |
░ | pending (no runs yet) | no decision / review required |
X | failed | changes 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.
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Data leaving the machine |
|---|---|---|---|---|---|
Only through gh, to the GitHub API; the mod makes no HTTP calls of its own | gh only: gh repo view, gh api graphql, gh api repos/…/check-runs | None read or written | No | Yes: the one-line wake on an actionable transition; set wake: never to turn it off | Repo 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. |
claude plugin validate sentry
claude plugin test sentryhooks/register.tsx 386 lines1// 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}
386hooks/gh.ts 142 lines1// 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}
142hooks/pixels.ts 121 lines1// 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}
121hooks/poll.ts 39 lines1// 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}
39hooks/truth.ts 318 lines1// 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}
318types/index.d.ts 44 lines1/** 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