Live pane tracking the GitHub PRs touched in a session: CI, review, conflicts, merge. One shared poller for every session on the machine.

A Claude Code mod that keeps a live pane of the GitHub pull requests a session touches: CI, review, conflicts, merge. You see when the PR Claude opened goes green, gets a review or hits a conflict, without asking.

The pane in a real session, docked beside the conversation: this repo's first PR, a minute after it merged. While CI runs, a card looks like this:
╭──────────────────────────────────────────────╮
│ ● CI RUNNING acme/app │
│ #12 Retry uploads with exponential backoff │
│ feat/retry → main +120 −14 @octocat │
│ │
│ ✓ Open ━━━━━━ ⠹ CI ┈┈┈┈┈┈ ○ Review ┈┈ ○ Merge │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1/3 │
│ Running build · 1 queued · 1/3 passed │
│ open ↗ · checked just now ✕ │
╰──────────────────────────────────────────────╯
Needs Claude Code 2.1.287 or later and the GitHub CLI logged in (gh auth login).
/plugin install pr-watch --marketplace ElirazKed/claude-code-pr-watch
Answer y to add the marketplace and press Enter for user scope. It's active at once, with no restart. Or in two steps:
/plugin marketplace add ElirazKed/claude-code-pr-watch
/plugin install pr-watch@claude-code-pr-watch
gh pr create), pushes to (git push to a branch with a PR), comments on, reviews, merges or follows CI for (gh pr checks), through gh or GitHub MCP write tools. Only open PRs are picked up; a merged or closed one never gets a card, and one you stopped watching stays stopped./pr-watch <url>, or paste a PR link in a prompt. These are watched in any state.gh pr view/diff, a fetched link, a GitHub MCP read). Claude asks once, and only if the PR looks like your own work rather than background reading./pr-watch opens the pane, and /pr-watch stop <number|url|all> stops watching. Finished PRs stay until you dismiss them, d dismisses them all, and r refreshes.
Each card shows the state that matters most first: merge conflicts, then failing or running CI (naming the checks), then review, then whether GitHub will let it merge. A toast appears when the state changes or a review lands, and the status line follows the focused PR.
A card says who reviewed and what is left to address:
╭──────────────────────────────────────────────╮
│ ↺ CHANGES REQUESTED acme/app │
│ #12 Retry uploads with exponential backoff │
│ feat/retry → main +120 −14 @octocat │
│ │
│ ✓ Open ━━━ ✓ CI ━━━ ↺ Review ┈┈┈ ○ Merge │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3/3 │
│ Changes requested │
│ ✗ bob requested changes · ✓ alice approved │
│ 3 unresolved threads (1 outdated) │
│ open ↗ · checked just now ✕ │
╰──────────────────────────────────────────────╯
copilot-pull-request-reviewer is copilot). When the line is too long for the card, the verbs are dropped first (✓ alice · 💬 nadav), and then the reviewers past what fits become +2 more./clear or a reload doesn't toast them again.A card offers a merge button once GitHub would merge the PR now (open, not a draft, no conflicts, required checks and reviews done) and your account can write to the repo. The method is your default on GitHub if the repo allows it, otherwise the first the repo allows of squash, rebase and merge, and the button says which: Squash & merge, Rebase & merge or Merge. When the repo allows more than one, a small ⇄ rebase button beside it switches.
y) runs anything; Cancel (n) goes back.gh pr merge --auto). With auto-merge on, the card says so (Auto-merge on · squash) and has a Cancel auto-merge button, which needs no confirmation.gh pr merge runs as the gh account that can see the repo, the one the poller reads it with.A card whose CI failed (at least one check failing on its latest run, counted the way the card counts them) has a Fix with Claude button. It collects what failed and puts it in your prompt box as a draft: read it, change it if you like, and press Enter to send it to this session. Nothing reaches Claude until you do.
gh run view --job <id> --log-failed, for the first 3 failing jobs. Each log loses its colour codes, timestamps and runner group markers, and keeps its last 120 lines, where the error usually is; all the logs together stay under about 12 KB. The draft says when a log was trimmed.y) sends it; Cancel (n) sends nothing.Every session on the machine shares one poller, so 30 sessions watching 30 PRs cost about one GitHub call every 20–60 seconds, not 30.
~/.cache/pr-watch/sessions/. The file's modified time is its heartbeat.~/.cache/pr-watch/results.json, and the other sessions read that.Both intervals are settings in /config:
| Setting | Default | Range |
|---|---|---|
poll_active_seconds: while a watched PR's CI runs or it's about to merge | 20 | 10–600 |
poll_idle_seconds: while nothing is moving | 60 | 10–3600 |
A change applies from the next round, with no reload needed. Sessions check on a 10s tick, so an interval is rounded up to the next tick. The session that runs a round uses its own settings, so keep them the same across Claude Code configs that share the machine.
gh accounts logged in, a PR the active account can't see is retried with the others. The cache remembers which login sees which owner, never a token.claude --plugin-dir . # load it from this folder for one session
claude plugin validate . # what it hooks and calls, and anything the engine would refuse
claude plugin test . # the *.test.ts suites
tsc -p . type-checks once Claude Code has loaded the mod, which writes .claude-plugin/types/.
main is protected: changes land through a pull request, once the test check (validate + tests, in .github/workflows/ci.yml) passes. History stays linear, so merge with squash or rebase; merge commits are turned off.
hooks/register.tsx 1138 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Busy, FixAsk, MergeAsk, Reviews, TrackedPr, Verdict } from '../types'
5import { PALETTE, barCells, barRuns, fullBarCells, ruleCells, spinnerCell } from './look'
6import {
7 MERGE_LABEL,
8 STEPS,
9 ago,
10 cwdOf,
11 derive,
12 findPrUrls,
13 isActive,
14 isBusy,
15 mergeArgv,
16 mergeError,
17 mergeOf,
18 mergeQuestion,
19 metaOf,
20 nextMethod,
21 pickMethod,
22 placeholder,
23 shellCode,
24 stepIndex,
25 touched,
26} from './pr'
27import { buildQuery, checksQuery, chunks, parseChecks, parseReply } from './batch'
28import type { RateLimit } from './batch'
29import { failingChecks, fixHeading, fixPrompt, logError, logProblems, toFetch } from './fix'
30import type { Failure, Handoff } from './fix'
31import { cells, newReviews, reviewToasts, reviewsOf, threadsBeside, verdictLine } from './review'
32import type { GhPr, MergeRun, PrRef } from './pr'
33
34const PANE = 'pr-watch'
35const TITLE = 'Pull requests'
36// A slim dock beside the transcript (a width you drag it to wins over this).
37const OPEN = { id: PANE, title: TITLE, columns: 50, rows: 14 } as const
38const prs = atom({ plugin: 'pr-watch', key: 'prs' } as const, [])
39const now = atom({ plugin: 'pr-watch', key: 'now' } as const, 0)
40const suggested = atom({ plugin: 'pr-watch', key: 'suggested' } as const, [])
41// PRs the person stopped watching: Claude touching them again doesn't bring them back.
42const dropped = atom({ plugin: 'pr-watch', key: 'dropped' } as const, [])
43const merging = atom({ plugin: 'pr-watch', key: 'merging' } as const, {})
44// Methods GitHub refused for a repo: not offered again this session.
45const refused = atom({ plugin: 'pr-watch', key: 'refused' } as const, {})
46const fixing = atom({ plugin: 'pr-watch', key: 'fixing' } as const, {})
47const WATCH_TOOL = 'mcp__pr-watch__watch'
48const FRAME_MS = 90
49
50// Module memory: lost on reload, which only costs one extra poll.
51let isTicking = false
52let hasHinted = false
53// What the pane last drew, so the animation can blit frames of the same size.
54const barWidth = new Map<string, number>()
55let anim: { cancel: () => void } | null = null
56let frame = 0
57
58// Rows kept by an older version of this mod lack the newer fields.
59function full(pr: TrackedPr): TrackedPr {
60 return { ...placeholder(pr), ...pr, checks: { ...placeholder(pr).checks, ...pr.checks } }
61}
62
63const TOAST_ICON: Record<string, string> = { success: '✓', error: '✗', warning: '◷', merged: '🎉', suggestion: '⇢' }
64const VERDICT_COLOUR: Record<Verdict, 'success' | 'error' | 'suggestion' | undefined> = {
65 approved: 'success',
66 changes: 'error',
67 commented: 'suggestion',
68 requested: undefined,
69}
70
71// ---- One poller for the whole machine -------------------------------------------------
72// Every session lists the PRs it watches under ~/.cache/pr-watch/sessions/ (the file's mtime
73// is its heartbeat); whichever session finds the shared results due takes a short lease and
74// fetches every live session's PRs in one batched query, and the rest read results.json.
75// So N sessions cost one GitHub call per round, not N.
76
77type Entry = { at: number; pr?: GhPr; error?: string }
78type Shared = {
79 fetchedAt: number
80 nextAt: number
81 prs: Record<string, Entry>
82 // Which gh login sees an owner's private repos (a login, never a token).
83 accounts: Record<string, string>
84 rate: RateLimit | null
85}
86
87// How often a round runs while some PR moves, and otherwise: the `poll_active_seconds` and
88// `poll_idle_seconds` options, kept current by `config.set`.
89const MIN_POLL_MS = 10_000
90const pace = { fastMs: 20_000, slowMs: 60_000 }
91const seconds = (value: unknown, fallback: number) =>
92 typeof value === 'number' && Number.isFinite(value) ? Math.max(MIN_POLL_MS, value * 1_000) : fallback
93// A session whose file has not been touched for this long has exited.
94const ALIVE_MS = 60_000
95const LEASE_MS = 30_000
96// A PR no account can see is asked about again only this often.
97const UNREACHABLE_MS = 300_000
98const LOW_RATE = 300
99const PRUNE_MS = 86_400_000
100
101// Survives /clear (the module stays loaded), unlike the session id.
102const INSTANCE = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
103let dir: string | null = null
104let loginsCache: Promise<{ active: string; others: string[] }> | null = null
105const tokens = new Map<string, Promise<string | null>>()
106let prunedAt = 0
107
108const empty = (): Shared => ({ fetchedAt: 0, nextAt: 0, prs: {}, accounts: {}, rate: null })
109
110async function gh($: EngineInterface, argv: string[], token?: string | null, cwd?: string, timeoutMs = 20_000) {
111 return $.process.run(['gh', ...argv], {
112 cwd,
113 timeoutMs,
114 env: token ? { GH_TOKEN: token } : undefined,
115 })
116}
117
118async function root($: EngineInterface): Promise<string> {
119 dir ??= `${(await $.env.get('HOME')) ?? '/tmp'}/.cache/pr-watch`
120
121 return dir
122}
123
124async function readJson<T>($: EngineInterface, path: string): Promise<T | null> {
125 try {
126 return JSON.parse(String(await $.fs.read(path))) as T
127 } catch {
128 return null
129 }
130}
131
132async function writeJson($: EngineInterface, path: string, value: unknown) {
133 try {
134 await $.fs.write(path, JSON.stringify(value))
135 } catch {
136 // The cache is an optimisation: a session that cannot write it still polls for itself.
137 }
138}
139
140async function readShared($: EngineInterface): Promise<Shared> {
141 return { ...empty(), ...(await readJson<Shared>($, `${await root($)}/results.json`)) }
142}
143
144// This session's watch list; written every tick, so its mtime says the session is alive.
145async function announce($: EngineInterface, urls: readonly string[]) {
146 await writeJson($, `${await root($)}/sessions/${INSTANCE}.json`, { urls })
147}
148
149async function liveUrls($: EngineInterface, at: number): Promise<string[]> {
150 const base = `${await root($)}/sessions`
151 let entries: Awaited<ReturnType<EngineInterface['fs']['list']>> = []
152 try {
153 entries = await $.fs.list(base)
154 } catch {
155 return []
156 }
157 const live = entries.filter(f => f.kind === 'file' && f.name.endsWith('.json') && at - f.mtimeMs < ALIVE_MS)
158 const lists = await Promise.all(live.map(f => readJson<{ urls?: string[] }>($, `${base}/${f.name}`)))
159 if (at - prunedAt > 3_600_000) {
160 prunedAt = at
161 const dead = entries.filter(f => f.kind === 'file' && at - f.mtimeMs > PRUNE_MS).map(f => `${base}/${f.name}`)
162 if (dead.length > 0) void $.process.run(['rm', '-f', ...dead], { timeoutMs: 5_000 }).catch(() => undefined)
163 }
164
165 return [...new Set(lists.flatMap(list => list?.urls ?? []))]
166}
167
168// A lease, not a lock: plain files have no atomic create, so claim, wait, and re-read. Two
169// sessions racing past this costs one duplicate round, never a missed one.
170async function takeLease($: EngineInterface, at: number): Promise<boolean> {
171 const path = `${await root($)}/lease.json`
172 const held = await readJson<{ owner: string; until: number }>($, path)
173 if (held !== null && held.owner !== INSTANCE && held.until > at) return false
174 await writeJson($, path, { owner: INSTANCE, until: at + LEASE_MS })
175 await $.clock.sleep(150)
176
177 return (await readJson<{ owner: string }>($, path))?.owner === INSTANCE
178}
179
180async function releaseLease($: EngineInterface) {
181 await writeJson($, `${await root($)}/lease.json`, { owner: INSTANCE, until: 0 })
182}
183
184async function logins($: EngineInterface) {
185 loginsCache ??= (async () => {
186 const status = await gh($, ['auth', 'status', '--hostname', 'github.com'])
187 const text = `${status.stdout}\n${status.stderr}`
188 const all = [...text.matchAll(/account (\S+)[^\n]*\n\s*-\s*Active account: (true|false)/g)]
189 const active = all.find(m => m[2] === 'true')?.[1] ?? ''
190
191 return { active, others: all.map(m => m[1] ?? '').filter(login => login !== '' && login !== active) }
192 })()
193
194 return loginsCache
195}
196
197// The active account needs no token; another account's comes from gh, once per process.
198async function tokenFor($: EngineInterface, login: string): Promise<string | null> {
199 if (login === '' || login === (await logins($)).active) return null
200 if (!tokens.has(login)) {
201 tokens.set(
202 login,
203 gh($, ['auth', 'token', '--hostname', 'github.com', '--user', login]).then(r => (r.exitCode === 0 ? r.stdout.trim() : null)),
204 )
205 }
206
207 return (await tokens.get(login)) ?? null
208}
209
210const ownerOf = (ref: PrRef) => ref.repo.split('/')[0] ?? ''
211
212async function query($: EngineInterface, refs: readonly PrRef[], token: string | null) {
213 const out = { found: new Map<string, GhPr>(), missing: [] as PrRef[], failed: new Map<string, string>(), rate: null as RateLimit | null }
214 for (const part of chunks(refs)) {
215 const res = await gh($, ['api', 'graphql', '-f', `query=${buildQuery(part)}`], token)
216 const reply = parseReply(res.stdout, res.stderr, part)
217 if (reply.error !== null) for (const ref of part) out.failed.set(ref.url, reply.error)
218 for (const [url, pr] of reply.found) out.found.set(url, pr)
219 out.missing.push(...reply.missing)
220 out.rate = reply.rate ?? out.rate
221 }
222
223 return out
224}
225
226// One batched query per gh account; PRs the expected account cannot see are retried with
227// each other logged-in account, and the one that sees them is remembered per owner.
228async function fetchRefs($: EngineInterface, refs: readonly PrRef[], accounts: Record<string, string>) {
229 const found = new Map<string, GhPr>()
230 const errors = new Map<string, string>()
231 let rate: RateLimit | null = null
232 const byLogin = new Map<string, PrRef[]>()
233 for (const ref of refs) byLogin.set(accounts[ownerOf(ref)] ?? '', [...(byLogin.get(accounts[ownerOf(ref)] ?? '') ?? []), ref])
234
235 let missing: PrRef[] = []
236 for (const [login, group] of byLogin) {
237 const r = await query($, group, await tokenFor($, login))
238 r.found.forEach((pr, url) => found.set(url, pr))
239 r.failed.forEach((err, url) => errors.set(url, err))
240 missing.push(...r.missing)
241 rate = r.rate ?? rate
242 }
243 if (missing.length > 0) {
244 const { active, others } = await logins($)
245 for (const login of [active, ...others]) {
246 const untried = missing.filter(ref => (accounts[ownerOf(ref)] ?? '') !== login && (login !== active || ownerOf(ref) in accounts))
247 if (untried.length === 0) continue
248 const r = await query($, untried, await tokenFor($, login))
249 r.found.forEach((pr, url) => {
250 found.set(url, pr)
251 const ref = untried.find(x => x.url === url)
252 if (ref !== undefined) accounts[ownerOf(ref)] = login
253 })
254 missing = missing.filter(ref => !r.found.has(ref.url))
255 if (missing.length === 0) break
256 }
257 for (const ref of missing) errors.set(ref.url, 'Not found, or no gh account can see it')
258 }
259
260 return { found, errors, rate }
261}
262
263const refFromUrl = (url: string): PrRef | null => {
264 const m = url.match(/^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)$/)
265
266 return m === null ? null : { url, repo: m[1] ?? '', number: Number(m[2]) }
267}
268
269// Fetches `refs` now and merges them into the shared results, which it returns.
270async function publish($: EngineInterface, refs: readonly PrRef[], at: number, isRound = false): Promise<Shared> {
271 const before = await readShared($)
272 const { found, errors, rate } = await fetchRefs($, refs, before.accounts)
273 // Re-read: another session may have written while this one was asking GitHub.
274 const shared = await readShared($)
275 shared.accounts = { ...shared.accounts, ...before.accounts }
276 found.forEach((pr, url) => (shared.prs[url] = { at, pr }))
277 errors.forEach((error, url) => (shared.prs[url] = { ...shared.prs[url], at, error }))
278 shared.rate = rate ?? shared.rate
279 if (isRound) {
280 const isMoving = [...found.values()].some(pr => derive(pr).isMoving)
281 shared.fetchedAt = at
282 shared.nextAt = at + (isMoving ? pace.fastMs : pace.slowMs)
283 if (shared.rate !== null && shared.rate.remaining < LOW_RATE) {
284 shared.nextAt = Math.max(shared.nextAt, Date.parse(shared.rate.resetAt) || 0)
285 }
286 }
287 await writeJson($, `${await root($)}/results.json`, shared)
288
289 return shared
290}
291
292// A session's tick: say what it watches, then either run the round (lease held) or read it.
293async function tick($: EngineInterface, own: readonly PrRef[], at: number): Promise<Shared> {
294 await announce($, own.map(ref => ref.url))
295 const shared = await readShared($)
296 if (at < shared.nextAt || !(await takeLease($, at))) return shared
297 try {
298 const urls = new Set([...(await liveUrls($, at)), ...own.map(ref => ref.url)])
299 const due = [...urls].flatMap(url => {
300 const entry = shared.prs[url]
301 const isUnreachable = entry?.error !== undefined && entry.pr === undefined && at - entry.at < UNREACHABLE_MS
302 const isFinished = entry?.pr !== undefined && entry.pr.state !== 'OPEN'
303 const ref = refFromUrl(url)
304
305 return ref === null || isUnreachable || isFinished ? [] : [ref]
306 })
307
308 return await publish($, due, at, true)
309 } finally {
310 await releaseLease($)
311 }
312}
313
314// ---- This session's cards -------------------------------------------------------------
315
316const refOf = (pr: TrackedPr): PrRef => ({ url: pr.url, repo: pr.repo, number: pr.number })
317
318// Folds the shared results into this session's cards: newer entries only, and a toast when
319// a card's state changes or a review lands. A new review's toast says it all: the state change
320// it caused (a review or ready headline) doesn't toast as well; CI, conflicts and a merge still do.
321async function apply($: EngineInterface, shared: Shared) {
322 const at = await $.clock.now()
323 const list = (await read($, prs)).map(full)
324 // The person's own reviews never toast, whichever of their gh accounts wrote them.
325 const { active, others } = await logins($)
326 const moved: string[] = []
327 const next = list.map(current => {
328 const entry = shared.prs[current.url]
329 if (entry === undefined || (current.checkedAt !== null && entry.at <= current.checkedAt)) return current
330 if (entry.pr === undefined) return { ...current, error: entry.error ?? null, checkedAt: current.checkedAt ?? entry.at }
331 const { fresh, seen } = newReviews(entry.pr, current.reviewSeen, [active, ...others], entry.pr.author?.login)
332 const pr: TrackedPr = {
333 ...current,
334 title: entry.pr.title,
335 state: entry.pr.state,
336 ...derive(entry.pr, current.reviews),
337 ...metaOf(entry.pr),
338 merge: mergeOf(entry.pr, current.merge),
339 reviews: reviewsOf(entry.pr) ?? current.reviews,
340 reviewSeen: seen,
341 checkedAt: entry.at,
342 error: entry.error ?? null,
343 }
344 const said = reviewToasts(fresh, pr.number)
345 for (const text of said) $.ui.toast(text)
346 if (current.checkedAt !== null && current.pill !== pr.pill && pr.error === null) {
347 const isReviewCaused = said.length > 0 && (pr.stage === 'review' || (pr.stage === 'ready' && pr.tone !== 'error'))
348 if (!isReviewCaused) $.ui.toast(`${TOAST_ICON[pr.tone] ?? '•'} PR #${pr.number} · ${pr.headline}`)
349 moved.push(pr.url)
350 }
351
352 return pr
353 })
354 await update($, prs, () => next)
355 await update($, now, () => at)
356 // What a Fix with Claude press said belongs to the failure it was about.
357 if (moved.length > 0) {
358 await update($, fixing, all => Object.fromEntries(Object.entries(all).filter(([url, ask]) => !moved.includes(url) || isBusy(ask.busy, at))))
359 }
360 await refreshStatus($)
361 await syncAnimation($)
362}
363
364// Fetches these PRs now (a new watch, the Refresh button) rather than waiting for the round.
365async function fetchNow($: EngineInterface, refs: PrRef[]) {
366 if (refs.length === 0) return
367 await apply($, await publish($, refs, await $.clock.now()))
368}
369
370async function setAsk($: EngineInterface, url: string, ask: MergeAsk | null) {
371 await update($, merging, all => {
372 const { [url]: _, ...rest } = all
373
374 return ask === null ? rest : { ...rest, [url]: ask }
375 })
376}
377
378// One question at a time across the pane, so y and n answer the one on screen.
379async function oneQuestion($: EngineInterface) {
380 await update($, merging, all => Object.fromEntries(Object.entries(all).map(([url, a]) => [url, { ...a, asking: undefined }])))
381 await update($, fixing, all => Object.fromEntries(Object.entries(all).map(([url, a]) => [url, { ...a, asking: undefined }])))
382}
383
384// Marks a card's merge row busy for a run, unless a run already holds it: false then, so a
385// second press (a double click, `y` twice) starts nothing. One write, so two can't both win.
386async function claimMerge($: EngineInterface, url: string, ask: MergeAsk & Required<Pick<MergeAsk, 'busy'>>) {
387 let isClaimed = false
388 await update($, merging, all => {
389 isClaimed = !isBusy(all[url]?.busy, ask.busy.at)
390
391 return isClaimed ? { ...all, [url]: ask } : all
392 })
393
394 return isClaimed
395}
396
397const BUSY: Record<MergeRun['action'], string> = {
398 merge: 'Merging…',
399 auto: 'Turning on auto-merge…',
400 'cancel-auto': 'Cancelling auto-merge…',
401}
402
403// Runs gh pr merge as the account the poller found sees the repo (its token, never kept),
404// then asks GitHub again at once so the card shows what happened.
405async function runMerge($: EngineInterface, pr: TrackedPr, run: MergeRun) {
406 const method = run.action === 'cancel-auto' ? undefined : run.method
407 const busy = { action: run.action, at: await $.clock.now() }
408 if (!(await claimMerge($, pr.url, { method, busy }))) return
409 let ask: MergeAsk = { method }
410 try {
411 const login = (await readShared($)).accounts[ownerOf(pr)] ?? ''
412 const res = await gh($, mergeArgv(pr.url, run), await tokenFor($, login))
413 if (res.exitCode !== 0) {
414 const { message, isMethodRefused } = mergeError(res.stderr || res.stdout)
415 ask = { method: isMethodRefused ? undefined : method, error: message }
416 if (isMethodRefused && method !== undefined) {
417 await update($, refused, all => ({ ...all, [pr.repo]: [...new Set([...(all[pr.repo] ?? []), method])] }))
418 }
419 }
420 } catch {
421 ask = { method, error: 'gh pr merge did not finish' }
422 }
423 // Only while the row is still this run's: not on a dismissed card, nor over a later press.
424 await update($, merging, all => (all[pr.url]?.busy?.at === busy.at ? { ...all, [pr.url]: ask } : all))
425 await fetchNow($, [refOf(pr)])
426}
427
428async function setFix($: EngineInterface, url: string, ask: FixAsk | null) {
429 await update($, fixing, all => {
430 const { [url]: _, ...rest } = all
431
432 return ask === null ? rest : { ...rest, [url]: ask }
433 })
434}
435
436// A collection's outcome, only while the row is still its own (see runMerge).
437async function settleFix($: EngineInterface, url: string, at: number, ask: FixAsk) {
438 await update($, fixing, all => (all[url]?.busy?.at === at ? { ...all, [url]: ask } : all))
439}
440
441// claimMerge for the Fix with Claude row: a second press while the logs come starts nothing.
442async function claimFix($: EngineInterface, url: string, busy: Busy) {
443 let isClaimed = false
444 await update($, fixing, all => {
445 isClaimed = !isBusy(all[url]?.busy, busy.at)
446
447 return isClaimed ? { ...all, [url]: { busy } } : all
448 })
449
450 return isClaimed
451}
452
453// Collects what failed on a card, as the gh account the poller found sees the repo: the
454// failing checks as GitHub has them now, and the failed steps' logs of the first few Actions
455// jobs. A part that won't come is said on the card; the rest still goes, names and links.
456async function collectFailure($: EngineInterface, pr: TrackedPr) {
457 const busy = { at: await $.clock.now() }
458 if (!(await claimFix($, pr.url, busy))) return
459 try {
460 const token = await tokenFor($, (await readShared($)).accounts[ownerOf(pr)] ?? '')
461 let failures: Failure[]
462 let problem: string | null = null
463 try {
464 const res = await gh($, ['api', 'graphql', '-f', `query=${checksQuery(refOf(pr))}`], token)
465 const reply = parseChecks(res.stdout, res.stderr, refOf(pr))
466 if ('error' in reply) throw new Error(reply.error)
467 failures = failingChecks(reply.checks, pr.repo)
468 } catch (err) {
469 // The names the card shows, and the PR's checks page, are still worth handing over.
470 problem = `Couldn't read the checks: ${err instanceof Error ? err.message : 'gh api graphql failed'}`
471 failures = pr.checks.failing.map(name => ({ name, workflow: null, conclusion: 'failed', url: null, summary: null, jobId: null }))
472 }
473 if (failures.length === 0) {
474 await settleFix($, pr.url, busy.at, { error: 'No check is failing on the latest runs now' })
475 await fetchNow($, [refOf(pr)])
476
477 return
478 }
479 const wanted = new Set(toFetch(failures))
480 const handoffs = await Promise.all(
481 failures.map(async (failure): Promise<Handoff> => {
482 if (!wanted.has(failure) || failure.jobId === null) return { failure }
483 try {
484 const argv = ['run', 'view', '--job', String(failure.jobId), '--log-failed', '-R', pr.repo]
485 const res = await gh($, argv, token, undefined, 60_000)
486
487 return res.exitCode === 0 ? { failure, log: res.stdout } : { failure, error: logError(res.stderr || res.stdout) }
488 } catch {
489 return { failure, error: 'gh run view did not finish' }
490 }
491 }),
492 )
493 // Dismissed while the logs came (its card, and so its draft, are gone), or pressed again
494 // once this run looked stale: the later press drafts.
495 if ((await read($, fixing))[pr.url]?.busy?.at !== busy.at) return
496 const error = [problem, logProblems(handoffs)].filter(Boolean).join('; ') || undefined
497 await handOver($, pr, fixPrompt(pr, handoffs), error)
498 } catch {
499 await settleFix($, pr.url, busy.at, { error: "Couldn't hand the failure over" })
500 }
501}
502
503// A draft in the prompt box, to read and send with Enter: nothing reaches Claude unseen. Where
504// the box won't take one (none on this surface, or a hook kept it out), the card asks instead,
505// and only Confirm sends it. A dialog holding the keys just goes away, so that one waits.
506async function handOver($: EngineInterface, pr: TrackedPr, text: string, error: string | undefined) {
507 const box = await $.prompt.read()
508 // Over an earlier draft of this hand-off, or an empty box; after anything the person typed.
509 const heading = fixHeading(pr)
510 const at = box.text.trim() === '' || box.text.startsWith(heading) ? 0 : box.text.indexOf(`\n\n${heading}`)
511 const filled =
512 at === -1
513 ? await $.prompt.fill({ text: `\n\n${text}`, mode: 'append' })
514 : await $.prompt.fill({ text: at === 0 ? text : `${box.text.slice(0, at)}\n\n${text}`, mode: 'replace' })
515 if (filled.isFilled) {
516 await setFix($, pr.url, { done: 'Drafted in the prompt box: read it, then press Enter', error })
517 } else if (filled.refusal === 'dialog') {
518 await setFix($, pr.url, { error: 'A dialog holds the prompt box; close it and press again' })
519 } else {
520 await oneQuestion($)
521 await setFix($, pr.url, { asking: text, error })
522 }
523}
524
525async function sendFix($: EngineInterface, pr: TrackedPr, text: string) {
526 await setFix($, pr.url, { done: 'Sent to Claude' })
527 // Not awaited: the prompt runs as a turn of its own once the session is idle. A hook that
528 // keeps it out, or a call that fails, takes back the "sent".
529 void $.prompt.submit({ text, asUser: true }).then(
530 async res => {
531 if (res.drop !== undefined) await setFix($, pr.url, { error: `Not sent: ${res.drop}` })
532 },
533 async () => setFix($, pr.url, { error: "Couldn't send it to Claude" }).catch(() => undefined),
534 ).catch(() => undefined)
535}
536
537async function refreshStatus($: EngineInterface) {
538 const list = await read($, prs)
539 const focus = list.find(isActive) ?? list[list.length - 1]
540 $.ui.status(focus === undefined ? undefined : `PR #${focus.number}: ${focus.headline}`)
541}
542
543// `isExplicit`: the person named the PR, so it is watched whatever its state. Otherwise (a PR
544// Claude acted on) only an open PR the person hasn't stopped watching is: GitHub is asked
545// first, so a merged or closed one never gets a card; the person can still watch it by name.
546async function track($: EngineInterface, refs: PrRef[], isExplicit = false) {
547 const known = new Set((await read($, prs)).map(pr => pr.url))
548 if (isExplicit) await update($, dropped, list => list.filter(url => !refs.some(ref => ref.url === url)))
549 else for (const url of await read($, dropped)) known.add(url)
550 const fresh = refs.filter(ref => !known.has(ref.url))
551 if (fresh.length === 0) return
552 if (isExplicit) {
553 await show($, fresh)
554 void fetchNow($, fresh).catch(() => undefined)
555
556 return
557 }
558 void (async () => {
559 const shared = await publish($, fresh, await $.clock.now())
560 const open = fresh.filter(ref => shared.prs[ref.url]?.pr?.state === 'OPEN')
561 if (open.length === 0) return
562 await show($, open)
563 await apply($, shared)
564 })().catch(() => undefined)
565}
566
567async function show($: EngineInterface, fresh: PrRef[]) {
568 await update($, prs, list => [...list, ...fresh.filter(r => !list.some(pr => pr.url === r.url)).map(placeholder)])
569 const opened = await $.ui.open(OPEN)
570 if (!opened.isPlaced && !hasHinted) {
571 hasHinted = true
572 $.ui.toast(`pr-watch: tracking PR #${fresh[0]?.number} — run /pr-watch to open the panel`)
573 }
574 await syncAnimation($)
575}
576
577// Runs the spinner and shimmer only while some PR is moving; blits skip the render pass.
578async function syncAnimation($: EngineInterface) {
579 const isMoving = (await read($, prs)).some(pr => full(pr).isMoving)
580 if (isMoving && anim === null) {
581 anim = $.clock.every(FRAME_MS, async () => {
582 frame += 1
583 const moving = (await read($, prs)).map(full).filter(pr => pr.isMoving)
584 if (moving.length === 0) {
585 anim?.cancel()
586 anim = null
587
588 return
589 }
590 for (const pr of moving) {
591 void $.ui.blit({ requestId: PANE, key: `spin:${pr.url}`, cells: spinnerCell(frame + pr.number) })
592 const width = barWidth.get(pr.url)
593 if (width !== undefined && pr.checks.pending > 0) {
594 void $.ui.blit({ requestId: PANE, key: `bar:${pr.url}`, cells: barCells(pr.checks, width, frame) })
595 }
596 }
597 })
598 } else if (!isMoving && anim !== null) {
599 anim.cancel()
600 anim = null
601 }
602}
603
604async function dismiss($: EngineInterface, url: string) {
605 await update($, prs, list => list.filter(pr => pr.url !== url))
606 await update($, dropped, list => (list.includes(url) ? list : [...list, url]))
607 await setAsk($, url, null)
608 await setFix($, url, null)
609 barWidth.delete(url)
610 await syncAnimation($)
611 if ((await read($, prs)).length === 0) await $.ui.close({ id: PANE })
612 await refreshStatus($)
613}
614
615async function dismissFinished($: EngineInterface) {
616 for (const pr of await read($, prs)) {
617 if (!isActive(pr)) await dismiss($, pr.url)
618 }
619}
620
621// After a /clear: put the watched PRs back if the session's state started over, and seat the
622// pane again, since no session.start follows to do it.
623async function restore($: EngineInterface, carried: TrackedPr[]) {
624 const list = await read($, prs)
625 if (list.length === 0) await update($, prs, () => carried)
626 if (!(await $.ui.panes()).some(pane => pane.id === PANE)) await $.ui.open(OPEN)
627 await refreshStatus($)
628 await syncAnimation($)
629}
630
631export const register: Register = (on, options) => {
632 pace.fastMs = seconds(options.poll_active_seconds, 20_000)
633 pace.slowMs = seconds(options.poll_idle_seconds, 60_000)
634
635 // A change in /config applies from the next round, no reload needed.
636 on('config.set', async ($, e, next) => {
637 const done = await next(e)
638 const field = e.key.match(/^pr-watch(?:@[\w.-]+)?\.(poll_active_seconds|poll_idle_seconds)$/)?.[1]
639 if (field === undefined || done.deny !== undefined) return done
640 if (field === 'poll_active_seconds') pace.fastMs = seconds(done.value, pace.fastMs)
641 else pace.slowMs = seconds(done.value, pace.slowMs)
642
643 return done
644 }).catch(($, e, next) => next(e))
645
646 on('session.start', async ($, e, next) => {
647 await $.command.register({
648 name: 'pr-watch',
649 description: 'Open the PR pane; /pr-watch <url> watches a PR, /pr-watch stop <number|url|all> stops',
650 argumentHint: '[<pr url> | stop <number|url|all>]',
651 })
652 await $.tool.register({
653 name: 'watch',
654 description:
655 "Adds GitHub pull requests to the user's PR watch pane, which shows their live CI, review and merge status. " +
656 'Call it only after the user agreed to watch the PR.',
657 inputSchema: {
658 type: 'object',
659 properties: { urls: { type: 'array', items: { type: 'string' }, description: 'PR URLs: https://github.com/<owner>/<repo>/pull/<n>' } },
660 required: ['urls'],
661 },
662 })
663 // One tick for the session; the round itself is shared by every session on the machine.
664 $.clock.every(10_000, async () => {
665 const active = (await read($, prs)).map(full).filter(isActive)
666 if (active.length === 0 || isTicking) return
667 isTicking = true
668 try {
669 await apply($, await tick($, active.map(refOf), await $.clock.now()))
670 } finally {
671 isTicking = false
672 }
673 })
674 if ((await read($, prs)).length > 0) {
675 void $.ui.open(OPEN)
676 await refreshStatus($)
677 await syncAnimation($)
678 }
679
680 return next(e)
681 })
682
683 // A /clear ends the conversation with no session.start after it; the engine may close the
684 // pane and start the state over, so carry the PRs across and restore once it has settled.
685 on('session.end', async ($, e, next) => {
686 if (e.reason !== 'clear') return next(e)
687 const carried = await read($, prs)
688 const ended = await next(e)
689 if (carried.length > 0) $.clock.after(500, () => void restore($, carried))
690
691 return ended
692 }).catch(($, e, next) => next(e))
693
694 on('command.run', { command: 'pr-watch' }, async ($, e) => {
695 // `/pr-watch stop 2344` (or a URL, or `all`) stops watching without opening the pane.
696 const stop = e.args.trim().match(/^(?:stop|unwatch|rm)\s+(.+)$/i)
697 if (stop !== null) {
698 const wanted = stop[1]?.trim() ?? ''
699 const urls = new Set(findPrUrls(wanted).map(r => r.url))
700 const nums = new Set(wanted.split(/[\s,]+/).map(w => Number(w.replace(/^#/, ''))).filter(n => n > 0))
701 const gone = (await read($, prs)).filter(pr => wanted === 'all' || urls.has(pr.url) || nums.has(pr.number))
702 for (const pr of gone) await dismiss($, pr.url)
703
704 return { text: gone.length > 0 ? `Stopped watching ${gone.map(pr => `#${pr.number}`).join(', ')}.` : `Not watching ${wanted}.` }
705 }
706 const refs = findPrUrls(e.args)
707 await $.ui.open(OPEN)
708 if (refs.length > 0) {
709 await track($, refs, true)
710
711 return { text: `Watching ${refs.map(r => `#${r.number}`).join(', ')}.` }
712 }
713
714 return { text: 'PR panel opened.' }
715 })
716
717 // Watching is harmless and the user asked for it, so the tool needs no permission prompt.
718 on('tool.check', { tool: WATCH_TOOL }, () => ({ decision: 'allow' }))
719
720 on('tool.call', async ($, e, next) => {
721 if (String(e.tool) === WATCH_TOOL) {
722 // The mod's own tool. (Its name is not in the generated tool types, hence no matcher.)
723 const input = e as unknown as { urls?: unknown }
724 const urls = Array.isArray(input.urls) ? input.urls.filter((u): u is string => typeof u === 'string') : []
725 const refs = findPrUrls(urls.join(' '))
726 if (refs.length === 0) return { result: 'No GitHub pull request URL given; expected https://github.com/<owner>/<repo>/pull/<n>.' }
727 await track($, refs, true)
728
729 return { result: `Watching ${refs.map(r => `#${r.number}`).join(', ')} in the PR pane.` }
730 }
731 const ran = await next(e)
732 if (ran.deny !== undefined) return ran
733
734 // A PR Claude acted on is watched without asking; one it only read is offered.
735 const touch = touched(String(e.tool), e as unknown as Record<string, unknown>, ran.text ?? '')
736 let { acted, read: seen } = touch
737 if (touch.resolve !== null && e.tool === 'Bash' && !ran.isError) {
738 // `git push`, `gh pr checks`: ask gh which PR that was, from the same directory.
739 const num = shellCode(e.command).match(/\bgh\s+pr\s+[\w-]+\s+#?(\d+)\b/)?.[1]
740 const res = await gh($, ['pr', 'view', ...(num ? [num] : []), '--json', 'url', '-q', '.url'], null, cwdOf(e.command))
741 const refs = res.exitCode === 0 ? findPrUrls(res.stdout) : []
742 if (touch.resolve === 'acted') acted = refs
743 else seen = refs
744 }
745 if (acted.length > 0) await track($, acted)
746
747 const watched = new Set([...(await read($, prs)).map(pr => pr.url), ...acted.map(ref => ref.url)])
748 const asked = new Set([...(await read($, suggested)), ...(await read($, dropped))])
749 const fresh = seen.filter(ref => !watched.has(ref.url) && !asked.has(ref.url))
750 if (fresh.length === 0) return ran
751 await update($, suggested, list => [...list, ...fresh.map(ref => ref.url)])
752 const named = fresh.map(ref => `${ref.repo}#${ref.number} (${ref.url})`).join(', ')
753
754 return {
755 ...ran,
756 context: [
757 ...(ran.context ?? []),
758 `pr-watch: you read ${named}, which the user's live PR pane is not watching. Only if it looks like the user's ` +
759 `own work in this session (a PR they are shepherding, not background reading or research), ask in one short ` +
760 `line whether to add it to the PR pane, and call ${WATCH_TOOL} with its URL if they agree. Otherwise say nothing ` +
761 `about it. Either way, do not bring it up again.`,
762 ],
763 }
764 }).catch(($, e, next) => next(e))
765
766 // PR links the person pastes into a prompt.
767 on('prompt.submit', async ($, e, next) => {
768 const refs = findPrUrls(e.text)
769 if (refs.length > 0) await track($, refs, true)
770
771 return next(e)
772 }).catch(($, e, next) => next(e))
773
774 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
775 const { Box, Text, Button, Link } = $.ui.resolve(e)
776 const Raster = e.surface === 'terminal' ? $.ui.resolve(e).Raster : null
777 const list = (await read($, prs)).map(full)
778 const at = await read($, now)
779 const asks = await read($, merging)
780 const refusedBy = await read($, refused)
781 const fixes = await read($, fixing)
782 const width = Math.max(24, e.props.bodyColumns)
783 const inner = width - 4 // card border + padding
784 const open = list.filter(isActive).length
785 const done = list.length - open
786
787 const header = (
788 <Box flexDirection="column" marginBottom={1}>
789 <Box justifyContent="space-between">
790 <Text bold>
791 <Text color="merged">⎇</Text> Pull requests
792 </Text>
793 <Text>
794 {open > 0 && (
795 <Text>
796 <Text color="warning" bold>{open}</Text>
797 <Text dimColor> open</Text>
798 </Text>
799 )}
800 {open > 0 && done > 0 && <Text dimColor> · </Text>}
801 {done > 0 && (
802 <Text>
803 <Text color="merged" bold>{done}</Text>
804 <Text dimColor> done</Text>
805 </Text>
806 )}
807 </Text>
808 </Box>
809 {Raster !== null ? (
810 <Raster key="rule" columns={width} rows={1} cells={ruleCells(width)} />
811 ) : (
812 <Text color="merged" dimColor>{'─'.repeat(width)}</Text>
813 )}
814 </Box>
815 )
816
817 if (list.length === 0) {
818 return (
819 <Box flexDirection="column">
820 {header}
821 <Box flexDirection="column" alignItems="center" paddingY={1}>
822 <Text color="merged">╭─────╮</Text>
823 <Text color="merged">│ ⎇ ◌ │</Text>
824 <Text color="merged">╰─────╯</Text>
825 <Text bold>Nothing to watch yet</Text>
826 <Text dimColor>PRs appear when Claude runs gh pr …</Text>
827 <Text dimColor>or with /pr-watch <url></Text>
828 </Box>
829 </Box>
830 )
831 }
832
833 // Merge, or auto-merge, behind a question; or auto-merge's state with a way to turn it off.
834 const mergeRow = (pr: TrackedPr) => {
835 const ask = asks[pr.url] ?? {}
836 const methods = pr.merge.methods.filter(m => !(refusedBy[pr.repo] ?? []).includes(m))
837 const method = pickMethod(methods, ask.method)
838 const error = ask.error !== undefined && (
839 <Text color="error" wrap="wrap">
840 {`✗ ${ask.error}`}
841 </Text>
842 )
843 if (isBusy(ask.busy, at)) return <Text color="suggestion">{`◌ ${BUSY[ask.busy.action]}`}</Text>
844 if (pr.merge.isAuto) {
845 return (
846 <Box flexDirection="column">
847 {error}
848 <Box justifyContent="space-between">
849 <Text color="suggestion">{`⇢ Auto-merge on${pr.merge.autoMethod === null ? '' : ` · ${pr.merge.autoMethod}`}`}</Text>
850 {pr.merge.canCancelAuto && (
851 <Button
852 key={`cancel-auto:${pr.url}`}
853 label="Cancel auto-merge"
854 onPress={() => runMerge($, pr, { action: 'cancel-auto' })}
855 />
856 )}
857 </Box>
858 </Box>
859 )
860 }
861 const action = pr.merge.canMerge ? 'merge' : pr.merge.canAuto ? 'auto' : null
862 if (method === null || action === null) return error || null
863 if (ask.asking === action) {
864 return (
865 <Box flexDirection="column">
866 <Text bold wrap="wrap">
867 {mergeQuestion(method, pr, action === 'auto')}
868 </Text>
869 <Box gap={2}>
870 <Button key={`confirm:${pr.url}`} label="Confirm" hotkey="y" plain onPress={() => runMerge($, pr, { action, method })} />
871 <Button key={`cancel:${pr.url}`} label="Cancel" hotkey="n" plain dimColor onPress={() => setAsk($, pr.url, { method })} />
872 </Box>
873 </Box>
874 )
875 }
876
877 return (
878 <Box flexDirection="column">
879 {error}
880 <Box gap={2}>
881 <Button
882 key={`${action}:${pr.url}`}
883 label={action === 'merge' ? MERGE_LABEL[method] : `Auto-merge · ${method}`}
884 variant="primary"
885 onPress={async () => {
886 await oneQuestion($)
887 await setAsk($, pr.url, { method, asking: action })
888 }}
889 />
890 {methods.length > 1 && (
891 <Button
892 key={`method:${pr.url}`}
893 label={`⇄ ${nextMethod(methods, method)}`}
894 plain
895 dimColor
896 onPress={() => setAsk($, pr.url, { method: nextMethod(methods, method) })}
897 />
898 )}
899 </Box>
900 </Box>
901 )
902 }
903
904 // On a card whose CI failed: hand the failure to Claude, and what came of the last press.
905 const fixRow = (pr: TrackedPr) => {
906 if (pr.checks.failed === 0) return null
907 const ask = fixes[pr.url] ?? {}
908 const error = ask.error !== undefined && (
909 <Text color="error" wrap="wrap">
910 {`✗ ${ask.error}`}
911 </Text>
912 )
913 if (isBusy(ask.busy, at)) return <Text color="suggestion">◌ Collecting logs…</Text>
914 const text = ask.asking
915 if (text !== undefined) {
916 return (
917 <Box flexDirection="column">
918 {error}
919 <Text bold wrap="wrap">
920 {`Send CI failure of #${pr.number} to Claude?`}
921 </Text>
922 <Box gap={2}>
923 <Button key={`fix-confirm:${pr.url}`} label="Confirm" hotkey="y" plain onPress={() => sendFix($, pr, text)} />
924 <Button key={`fix-cancel:${pr.url}`} label="Cancel" hotkey="n" plain dimColor onPress={() => setFix($, pr.url, null)} />
925 </Box>
926 </Box>
927 )
928 }
929
930 return (
931 <Box flexDirection="column">
932 {error}
933 {ask.done !== undefined && (
934 <Text color="success" wrap="wrap">
935 {`✓ ${ask.done}`}
936 </Text>
937 )}
938 <Box>
939 <Button key={`fix:${pr.url}`} label="Fix with Claude" onPress={() => collectFailure($, pr)} />
940 </Box>
941 </Box>
942 )
943 }
944
945 // Who reviewed, and the threads still open: "✓ alice approved · 💬 nadav commented" and
946 // "3 unresolved threads (1 outdated)", each only when there is something to say.
947 const reviewLines = (reviews: Reviews, threads: string) => {
948 const pieces = verdictLine(reviews, inner)
949
950 return (
951 <Box flexDirection="column">
952 {pieces.length > 0 && (
953 <Text wrap="truncate-end">
954 {pieces.map((piece, i) => {
955 // A pending request, or "+2 more": dim.
956 const colour = piece.verdict === null ? undefined : VERDICT_COLOUR[piece.verdict]
957
958 return (
959 <Text>
960 {i > 0 && <Text dimColor> · </Text>}
961 <Text color={colour} dimColor={colour === undefined}>
962 {piece.text}
963 </Text>
964 </Text>
965 )
966 })}
967 </Text>
968 )}
969 {threads !== '' && (
970 <Text color="warning" wrap="truncate-end">
971 {threads}
972 </Text>
973 )}
974 </Box>
975 )
976 }
977
978 const card = (pr: TrackedPr) => {
979 const step = stepIndex(pr.stage)
980 const isMerged = pr.stage === 'merged'
981 const doneColour = isMerged ? 'merged' : 'success'
982 const icon = [...pr.pill][0] ?? '●'
983 // Labels take 25 cells and the glyphs/gaps 10 (one more for a wide icon, 💬); connectors
984 // share what is left.
985 const link = Math.min(6, Math.max(1, Math.floor((inner - 34 - cells(icon)) / 3)))
986 const label = `${pr.checks.passed}/${pr.checks.total}`
987 const barW = Math.max(6, inner - label.length - 1)
988 const hasBar = (pr.state === 'OPEN' && pr.checks.total > 0) || isMerged
989 const threads = pr.state === 'OPEN' && pr.reviews !== null ? threadsBeside(pr.headline, pr.reviews) : { line: '', note: '' }
990
991 if (hasBar && !isMerged) barWidth.set(pr.url, barW)
992
993 const glyph = (i: number) => {
994 if (i < step) return <Text color={doneColour}>✓</Text>
995 if (i > step) return <Text dimColor>○</Text>
996 if (pr.isMoving && Raster !== null) {
997 return <Raster key={`spin:${pr.url}`} columns={1} rows={1} cells={spinnerCell(frame + pr.number)} />
998 }
999
1000 return <Text color={pr.tone} bold>{pr.isMoving ? '◌' : icon}</Text>
1001 }
1002
1003 return (
1004 <Box
1005 key={`card:${pr.url}`}
1006 flexDirection="column"
1007 borderStyle="round"
1008 borderColor={pr.tone}
1009 borderDimColor
1010 hover={{ borderDimColor: false }}
1011 paddingX={1}
1012 marginBottom={1}
1013 >
1014 <Box justifyContent="space-between">
1015 <Text backgroundColor={pr.tone} color="inverseText" bold>
1016 {' '}
1017 {pr.pill}{' '}
1018 </Text>
1019 <Text dimColor wrap="truncate-start">
1020 {pr.repo}
1021 </Text>
1022 </Box>
1023
1024 <Box>
1025 <Text wrap="truncate-end">
1026 <Text color={pr.tone} bold>
1027 #{pr.number}
1028 </Text>{' '}
1029 <Text bold>{pr.title || 'Loading…'}</Text>
1030 </Text>
1031 </Box>
1032 {pr.branch !== '' && (
1033 <Text wrap="truncate-end">
1034 <Text color="suggestion">{pr.branch}</Text>
1035 <Text dimColor> → </Text>
1036 <Text dimColor>{pr.base}</Text>
1037 <Text dimColor>{' '}</Text>
1038 <Text color="success">+{pr.additions}</Text> <Text color="error">−{pr.deletions}</Text>
1039 {pr.author !== '' && <Text dimColor>{' '}@{pr.author}</Text>}
1040 </Text>
1041 )}
1042
1043 <Box marginTop={1}>
1044 {STEPS.map((name, i) => (
1045 <Box>
1046 {glyph(i)}
1047 <Text
1048 color={i < step ? doneColour : i === step ? pr.tone : undefined}
1049 dimColor={i > step}
1050 bold={i === step}
1051 >
1052 {' '}
1053 {name}
1054 </Text>
1055 {i < STEPS.length - 1 && (
1056 <Text color={i < step ? doneColour : undefined} dimColor={i >= step}>
1057 {' '}
1058 {(i < step ? '━' : '┈').repeat(link)}{' '}
1059 </Text>
1060 )}
1061 </Box>
1062 ))}
1063 </Box>
1064
1065 {hasBar && (
1066 <Box>
1067 {Raster !== null ? (
1068 <Raster
1069 key={isMerged ? `done:${pr.url}` : `bar:${pr.url}`}
1070 columns={barW}
1071 rows={1}
1072 cells={isMerged ? fullBarCells(barW, PALETTE.mauve) : barCells(pr.checks, barW, frame)}
1073 />
1074 ) : (
1075 <Text>
1076 {(isMerged ? [{ text: '━'.repeat(barW), colour: 'merged' as const }] : barRuns(pr.checks, barW)).map(r => (
1077 <Text color={r.colour}>{r.text}</Text>
1078 ))}
1079 </Text>
1080 )}
1081 {!isMerged && <Text dimColor> {label}</Text>}
1082 </Box>
1083 )}
1084
1085 <Text color={pr.tone} wrap="wrap">
1086 {pr.headline}
1087 {isMerged && pr.mergedAt !== null && <Text dimColor> · {ago(at - Date.parse(pr.mergedAt))}</Text>}
1088 {threads.note !== '' && <Text dimColor> · {threads.note}</Text>}
1089 </Text>
1090 {pr.checks.failing.length > 2 &&
1091 pr.checks.failing.slice(2, 5).map(name => (
1092 <Text color="error" dimColor wrap="truncate-end">
1093 {' '}✗ {name}
1094 </Text>
1095 ))}
1096 {pr.state === 'OPEN' && pr.reviews !== null && reviewLines(pr.reviews, threads.line)}
1097 {pr.error !== null && (
1098 <Text color="error" wrap="truncate-end">
1099 ⚠ {pr.error}
1100 </Text>
1101 )}
1102 {pr.state === 'OPEN' && fixRow(pr)}
1103 {pr.state === 'OPEN' && mergeRow(pr)}
1104
1105 <Box justifyContent="space-between">
1106 <Text>
1107 <Link href={pr.url} label="open ↗" />
1108 {pr.checkedAt !== null && <Text dimColor> · checked {ago(at - pr.checkedAt)}</Text>}
1109 </Text>
1110 <Button
1111 key={`dismiss:${pr.url}`}
1112 label={isActive(pr) ? '✕' : 'Dismiss'}
1113 plain={isActive(pr) ? true : undefined}
1114 role="dismiss"
1115 variant={isActive(pr) ? undefined : 'primary'}
1116 dimColor={isActive(pr)}
1117 onPress={() => dismiss($, pr.url)}
1118 />
1119 </Box>
1120 </Box>
1121 )
1122 }
1123
1124 return (
1125 <Box flexDirection="column">
1126 {header}
1127 {list.map(card)}
1128 <Box gap={2}>
1129 <Button key="refresh" label="Refresh" hotkey="r" plain dimColor onPress={() => fetchNow($, list.filter(isActive).map(refOf))} />
1130 {done > 0 && (
1131 <Button key="dismiss-finished" label="Dismiss finished" hotkey="d" plain dimColor onPress={() => dismissFinished($)} />
1132 )}
1133 </Box>
1134 </Box>
1135 )
1136 })
1137}
1138hooks/look.ts 125 lines1// The pane's true-colour paint: gradient bars, a shimmer over pending checks and a
2// hue-cycling spinner, all packed as Raster cells so the animation can blit frames.
3import type { Checks } from '../types'
4
5type Rgb = readonly [number, number, number]
6type Cell = readonly [codePoint: number, fg: number, bg: number]
7
8const DEFAULT_BG = 0x01000000
9
10// Catppuccin-flavoured, a shade deeper so they still read on a light terminal.
11export const PALETTE = {
12 green: [[64, 160, 43], [166, 227, 161]],
13 red: [[210, 15, 57], [243, 139, 168]],
14 amber: [[223, 142, 29], [249, 226, 175]],
15 mauve: [[136, 57, 239], [203, 166, 247]],
16 track: [[88, 91, 112], [88, 91, 112]],
17} as const satisfies Record<string, readonly [Rgb, Rgb]>
18
19const SHINE: Rgb = [255, 248, 220]
20const RULE: readonly Rgb[] = [
21 [203, 166, 247],
22 [137, 180, 250],
23 [148, 226, 213],
24 [245, 194, 231],
25]
26
27const SPIN = [...'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏']
28
29function lerp(a: Rgb, b: Rgb, t: number): Rgb {
30 const k = Math.min(1, Math.max(0, t))
31
32 return [0, 1, 2].map(i => Math.round(a[i]! + (b[i]! - a[i]!) * k)) as unknown as Rgb
33}
34
35function hex([r, g, b]: Rgb): number {
36 return (r << 16) | (g << 8) | b
37}
38
39function gradientAt(stops: readonly Rgb[], t: number): Rgb {
40 const span = (stops.length - 1) * Math.min(1, Math.max(0, t))
41 const i = Math.min(stops.length - 2, Math.floor(span))
42
43 return lerp(stops[i]!, stops[i + 1]!, span - i)
44}
45
46export function encode(cells: readonly Cell[]): string {
47 const words = new Uint32Array(cells.length * 3)
48 cells.forEach(([cp, fg, bg], i) => words.set([cp, fg, bg], i * 3))
49 const bytes = new Uint8Array(words.buffer)
50 let binary = ''
51 for (const byte of bytes) binary += String.fromCharCode(byte)
52
53 return btoa(binary)
54}
55
56const cell = (glyph: string, rgb: Rgb): Cell => [glyph.codePointAt(0)!, hex(rgb), DEFAULT_BG]
57
58// Splits `width` cells among the counts, every non-zero count keeping at least one.
59export function allot(counts: readonly number[], width: number): number[] {
60 const total = counts.reduce((a, b) => a + b, 0)
61 if (total === 0) return counts.map(() => 0)
62 const raw = counts.map(n => (n / total) * width)
63 const out = raw.map((r, i) => (counts[i]! > 0 ? Math.max(1, Math.floor(r)) : 0))
64 let left = width - out.reduce((a, b) => a + b, 0)
65 const order = raw.map((r, i) => [r - Math.floor(r), i] as const).sort((a, b) => b[0] - a[0])
66 for (let k = 0; left > 0 && k < order.length * 4; k++) {
67 const i = order[k % order.length]![1]
68 if (counts[i]! > 0) {
69 out[i]! += 1
70 left -= 1
71 }
72 }
73 while (left < 0) {
74 const i = out.indexOf(Math.max(...out))
75 out[i]! -= 1
76 left += 1
77 }
78
79 return out
80}
81
82// One row of `━`: passed, failed, then pending (with a light sweeping across it).
83export function barCells(checks: Checks, width: number, frame: number): string {
84 const [passed = 0, failed = 0, pending = 0] = allot([checks.passed, checks.failed, checks.pending], width)
85 const cells: Cell[] = []
86 const run = (n: number, [from, to]: readonly [Rgb, Rgb], shine?: number) => {
87 for (let i = 0; i < n; i++) {
88 let rgb = lerp(from, to, n === 1 ? 1 : i / (n - 1))
89 if (shine !== undefined) rgb = lerp(rgb, SHINE, Math.max(0, 1 - Math.abs(i - shine) / 3) * 0.85)
90 cells.push(cell('━', rgb))
91 }
92 }
93 run(passed, PALETTE.green)
94 run(failed, PALETTE.red)
95 run(pending, PALETTE.amber, (frame % (pending + 8)) - 4)
96
97 return encode(cells)
98}
99
100export function fullBarCells(width: number, colours: readonly [Rgb, Rgb]): string {
101 return encode(Array.from({ length: width }, (_, i) => cell('━', lerp(colours[0], colours[1], i / Math.max(1, width - 1)))))
102}
103
104export function ruleCells(width: number): string {
105 return encode(Array.from({ length: width }, (_, i) => cell('─', gradientAt(RULE, i / Math.max(1, width - 1)))))
106}
107
108export function spinnerCell(frame: number, colours: readonly [Rgb, Rgb] = PALETTE.amber): string {
109 const glyph = SPIN[frame % SPIN.length]!
110 const pulse = (Math.sin(frame / 3) + 1) / 2
111
112 return encode([cell(glyph, lerp(colours[0], colours[1], pulse))])
113}
114
115// Text fallback for surfaces without Raster: the same bar as coloured runs.
116export function barRuns(checks: Checks, width: number): { text: string; colour: 'success' | 'error' | 'warning' }[] {
117 const [passed = 0, failed = 0, pending = 0] = allot([checks.passed, checks.failed, checks.pending], width)
118
119 return [
120 { text: '━'.repeat(passed), colour: 'success' as const },
121 { text: '━'.repeat(failed), colour: 'error' as const },
122 { text: '━'.repeat(pending), colour: 'warning' as const },
123 ].filter(run => run.text.length > 0)
124}
125hooks/pr.ts 510 lines1import type { Busy, Checks, MergeMethod, Mergeable, Reviews, Stage, Tone, TrackedPr } from '../types'
2import { reviewsOf, toAddress, unresolved } from './review'
3import type { ReviewFacts } from './review'
4
5const PR_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/g
6
7export type PrRef = { url: string; repo: string; number: number }
8
9export function findPrUrls(text: string): PrRef[] {
10 const found = new Map<string, PrRef>()
11 for (const [, repo = '', num] of text.matchAll(PR_URL)) {
12 const number = Number(num)
13 const url = `https://github.com/${repo}/pull/${number}`
14 found.set(url, { url, repo, number })
15 }
16
17 return [...found.values()]
18}
19
20// `gh pr view 12 -R owner/repo` style references that carry no URL.
21export function refFromGhCommand(command: string): PrRef | null {
22 const sub = command.match(/\bgh\s+pr\s+[\w-]+\s+(?:[^|;&]*?\s)?#?(\d+)\b/)
23 const repo = command.match(/(?:-R|--repo)[\s=]+([\w.-]+\/[\w.-]+)/)
24 const name = repo?.[1]
25 if (sub === null || name === undefined) return null
26 const number = Number(sub[1])
27
28 return { url: `https://github.com/${name}/pull/${number}`, repo: name, number }
29}
30
31const toolName = (tool: string) => tool.replace(/^mcp__[^_]+(?:_[^_]+)*?__/, '')
32
33// A command's shell code: heredoc bodies and quoted strings blanked, so a `gh pr create` in a
34// PR body or a commit message doesn't count, while `GH_TOKEN=$(…) gh pr create` and
35// `git -c k="…" push` do.
36export function shellCode(command: string): string {
37 return command
38 .replace(/<<-?\s*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, (_m, _q, _tag, rest: string) => ` ${rest}`)
39 .replace(/'[^']*'|"(?:\\[\s\S]|[^"\\])*"/g, '""')
40}
41
42// `gh pr` subcommands that act on a PR. `checks` counts: following a PR's CI is shepherding it.
43const ACTS = new Set(['create', 'merge', 'ready', 'edit', 'comment', 'review', 'close', 'reopen', 'checkout', 'update-branch', 'checks'])
44// Single-PR read subcommands gh resolves to the current branch's PR when given no number.
45const READS_ONE = new Set(['view', 'diff'])
46const GIT_PUSH = /\bgit\b[^;&|\n]*?\spush\b/
47const GH_API_WRITE = /\bgh\s+api\b[^;&|\n]*?(?:(?:-X|--method)[\s=]*(?:POST|PATCH|PUT|DELETE)\b|\s(?:-f|-F|--field|--raw-field|--input)\s)/i
48const API_PULL = /\brepos\/([\w.-]+\/[\w.-]+)\/pulls\/(\d+)/g
49
50export type Touched = {
51 // PRs Claude acted on (opened, pushed to, commented, reviewed, merged, followed CI): watched.
52 acted: PrRef[]
53 // PRs Claude only read: offered, if they look like the person's own work.
54 read: PrRef[]
55 // The command named no PR (`git push`, `gh pr checks`), so gh must say which one it meant.
56 resolve: 'acted' | 'read' | null
57}
58
59// GitHub MCP tools that change a PR start with one of these (`merge_pull_request`,
60// `request_copilot_review`) or end in `_write`; `get_`, `list_`, `search_`, `_read` only read.
61const WRITE_VERBS = new Set(['create', 'update', 'merge', 'add', 'submit', 'request', 'push', 'enable', 'resolve', 'close', 'reopen', 'mark', 'dismiss'])
62
63const NONE: Touched = { acted: [], read: [], resolve: null }
64
65const uniq = (refs: PrRef[]) => [...new Map(refs.map(ref => [ref.url, ref])).values()]
66
67// A changelog full of links is not "dealing with" those PRs.
68const capped = (refs: PrRef[]) => (refs.length > 3 ? refs.slice(0, 1) : refs)
69
70export function touched(tool: string, input: Record<string, unknown>, output: string): Touched {
71 if (tool === 'Bash') return touchedByCommand(typeof input.command === 'string' ? input.command : '', output)
72 const name = toolName(tool)
73 const isPrTool = /pull|(^|_)prs?(_|$)/i.test(name)
74 if (!isPrTool) return { ...NONE, read: capped(findPrUrls(JSON.stringify(input))) }
75
76 const refs = findPrUrls(JSON.stringify(input))
77 const repo = [input.owner, input.repo].every(v => typeof v === 'string') ? `${input.owner}/${input.repo}` : null
78 const num = Number(input.pull_number ?? input.pullNumber ?? input.number ?? NaN)
79 if (repo !== null && Number.isInteger(num) && num > 0) refs.push({ url: `https://github.com/${repo}/pull/${num}`, repo, number: num })
80 const words = name.toLowerCase().split(/[_-]/)
81 const isWrite = WRITE_VERBS.has(words[0] ?? '') || words.includes('write')
82 if (!isWrite) {
83 return { ...NONE, read: capped(uniq([...refs, ...findPrUrls(output)])) }
84 }
85 // A new PR's own link comes first in the reply; its body may quote others.
86 const made = refs.length === 0 ? findPrUrls(output).slice(0, 1) : []
87
88 return { ...NONE, acted: uniq([...refs, ...made]) }
89}
90
91function touchedByCommand(command: string, output: string): Touched {
92 const code = shellCode(command)
93 const subs = [...code.matchAll(/\bgh\s+pr\s+([\w-]+)/g)].map(m => m[1] ?? '')
94 const apiRefs = [...code.matchAll(API_PULL)].map(([, repo = '', n]) => ({ url: `https://github.com/${repo}/pull/${Number(n)}`, repo, number: Number(n) }))
95 // `gh pr checks 12 -R o/r`; not `gh pr list --limit 30 -R o/r`.
96 const isOnePr = subs.some(sub => sub !== 'create' && (ACTS.has(sub) || READS_ONE.has(sub)))
97 const fromFlags = isOnePr ? refFromGhCommand(code) : null
98 const named = uniq([...findPrUrls(code), ...apiRefs, ...(fromFlags ? [fromFlags] : [])])
99 const isPush = GIT_PUSH.test(code)
100 if (subs.some(sub => ACTS.has(sub)) || isPush || GH_API_WRITE.test(code)) {
101 // `gh pr create` prints the new PR; `gh pr comment` the comment's link on its PR.
102 const acted = uniq([...named, ...findPrUrls(output)])
103 const resolve = acted.length === 0 && (isPush || isOnePr) ? 'acted' : null
104
105 return { acted, read: [], resolve }
106 }
107 if (!/\bgh\s/.test(code)) return NONE
108 const read = capped(uniq([...named, ...findPrUrls(output)]))
109
110 return { acted: [], read, resolve: read.length === 0 && subs.some(sub => READS_ONE.has(sub)) ? 'read' : null }
111}
112
113// The directory a `cd dir && gh pr ...` command ran in, if it says.
114export function cwdOf(command: string): string | undefined {
115 return command.match(/^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*&&/)?.[1]?.replace(/^["']|["']$/g, '')
116}
117
118export type CheckItem = {
119 __typename?: string
120 name?: string
121 context?: string
122 status?: string
123 conclusion?: string
124 state?: string
125 workflow?: string
126 startedAt?: string | null
127 createdAt?: string | null
128 // Asked for only when a failure is handed to Claude (fix.ts), not by the poller. A GitHub
129 // Actions check run's databaseId is its job's id.
130 databaseId?: number | null
131 detailsUrl?: string | null
132 title?: string | null
133 summary?: string | null
134 targetUrl?: string | null
135 description?: string | null
136}
137
138export const isStatusContext = (item: CheckItem) => item.__typename === 'StatusContext' || item.state !== undefined
139
140// GitHub keeps every run of a check on the head commit: each pull_request event (a label,
141// an edit, a re-run) starts another, and the rollup lists them all. The PR page shows the
142// latest of each, so fold to that, or a failure that a later run fixed stays red.
143export function latestRuns(items: readonly CheckItem[]): CheckItem[] {
144 const latest = new Map<string, { item: CheckItem; at: number; order: number }>()
145 items.forEach((item, order) => {
146 const isContext = isStatusContext(item)
147 const key = isContext ? `status:${item.context ?? ''}` : `run:${item.workflow ?? ''}/${item.name ?? ''}`
148 // A run not started yet (queued, waiting) is the newest of its check.
149 const stamp = isContext ? item.createdAt : item.startedAt
150 const at = stamp == null ? (isContext ? 0 : Number.POSITIVE_INFINITY) : Date.parse(stamp) || 0
151 const held = latest.get(key)
152 if (held === undefined || at > held.at || (at === held.at && order > held.order)) latest.set(key, { item, at, order })
153 })
154
155 return [...latest.values()].sort((a, b) => a.order - b.order).map(entry => entry.item)
156}
157
158const PASSED = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
159
160export function emptyChecks(): Checks {
161 return { passed: 0, failed: 0, pending: 0, total: 0, failing: [], running: [], queued: 0 }
162}
163
164export function verdictOf(item: CheckItem): 'passed' | 'pending' | 'failed' {
165 if (isStatusContext(item)) {
166 return item.state === 'SUCCESS' ? 'passed' : item.state === 'PENDING' || item.state === 'EXPECTED' ? 'pending' : 'failed'
167 }
168
169 return item.status !== 'COMPLETED' ? 'pending' : PASSED.has(item.conclusion ?? '') ? 'passed' : 'failed'
170}
171
172export function summarizeChecks(items: readonly CheckItem[] | null | undefined): Checks {
173 const checks = emptyChecks()
174 for (const item of latestRuns(items ?? [])) {
175 checks.total += 1
176 const isContext = isStatusContext(item)
177 const verdict = verdictOf(item)
178 checks[verdict] += 1
179 const name = item.name ?? item.context ?? 'check'
180 if (verdict === 'failed') checks.failing.push(name)
181 if (verdict === 'pending') {
182 // A status context only says PENDING once its CI has picked it up; EXPECTED is still waiting.
183 const isStarted = isContext ? item.state === 'PENDING' : item.status === 'IN_PROGRESS'
184 if (isStarted) checks.running.push(name)
185 else checks.queued += 1
186 }
187 }
188
189 return checks
190}
191
192// "Running build, e2e +1 · 2 queued": what CI is doing right now.
193export function activity(checks: Checks): string {
194 const names = checks.running.slice(0, 2).join(', ')
195 const more = checks.running.length > 2 ? ` +${checks.running.length - 2}` : ''
196 const running = checks.running.length > 0 ? `Running ${names}${more}` : ''
197 const queued = checks.queued > 0 ? `${checks.queued} queued` : ''
198
199 return [running, queued].filter(Boolean).join(' · ')
200}
201
202// Its reviews (review.ts) ride along too.
203export type GhPr = ReviewFacts & {
204 number: number
205 title: string
206 url: string
207 state: 'OPEN' | 'MERGED' | 'CLOSED'
208 isDraft: boolean
209 reviewDecision: string | null
210 mergeStateStatus: string | null
211 // MERGEABLE, CONFLICTING or UNKNOWN: the direct signal, when DIRTY is still being computed.
212 mergeable?: string | null
213 autoMergeRequest: { enabledAt?: string | null; mergeMethod?: string | null } | null
214 viewerCanEnableAutoMerge?: boolean
215 viewerCanDisableAutoMerge?: boolean
216 // Its repository's merge settings, asked for in the same query.
217 repository?: RepoMerge
218 statusCheckRollup: CheckItem[] | null
219 mergedAt: string | null
220 closedAt?: string | null
221 additions: number
222 deletions: number
223 headRefName: string
224 baseRefName: string
225 author: { login: string } | null
226}
227
228export type RepoMerge = {
229 squashMergeAllowed?: boolean
230 rebaseMergeAllowed?: boolean
231 mergeCommitAllowed?: boolean
232 viewerDefaultMergeMethod?: string | null
233 // READ, TRIAGE, WRITE, MAINTAIN or ADMIN: merging takes WRITE.
234 viewerPermission?: string | null
235}
236
237export function metaOf(pr: GhPr) {
238 return {
239 branch: pr.headRefName,
240 base: pr.baseRefName,
241 additions: pr.additions,
242 deletions: pr.deletions,
243 author: pr.author?.login ?? '',
244 mergedAt: pr.mergedAt,
245 }
246}
247
248const METHODS: readonly MergeMethod[] = ['squash', 'rebase', 'merge']
249const ALLOWS = { squash: 'squashMergeAllowed', rebase: 'rebaseMergeAllowed', merge: 'mergeCommitAllowed' } as const
250const MAY_MERGE = new Set(['WRITE', 'MAINTAIN', 'ADMIN'])
251// GitHub merges these now: HAS_HOOKS and UNSTABLE (an optional check failing) only warn.
252const MERGES_NOW = new Set(['CLEAN', 'HAS_HOOKS', 'UNSTABLE'])
253
254const asMethod = (value: string | null | undefined): MergeMethod | null => METHODS.find(m => m === value?.toLowerCase()) ?? null
255
256// The methods the repo allows, the viewer's default first while the repo still allows it.
257export function methodsOf(repo: RepoMerge | undefined): MergeMethod[] {
258 const allowed = METHODS.filter(m => repo?.[ALLOWS[m]] === true)
259 const preferred = asMethod(repo?.viewerDefaultMergeMethod)
260
261 return preferred !== null && allowed.includes(preferred) ? [preferred, ...allowed.filter(m => m !== preferred)] : allowed
262}
263
264const isConflicted = (pr: GhPr) => pr.mergeStateStatus === 'DIRTY' || pr.mergeable === 'CONFLICTING'
265
266export function noMerge(): Mergeable {
267 return { methods: [], canMerge: false, canAuto: false, isAuto: false, autoMethod: null, canCancelAuto: false }
268}
269
270// An entry written by an older version of this mod (one that won the poll lease) has no merge
271// facts: the card keeps what it last knew of them rather than lose its merge row for a round.
272// What the entry says of the PR itself still counts: no button on a draft or a conflicted
273// branch, and no merge now unless GitHub would merge it now.
274export function mergeOf(pr: GhPr, kept: Mergeable): Mergeable {
275 if (pr.state !== 'OPEN' || pr.repository !== undefined) return mergeableOf(pr)
276 if (pr.isDraft || isConflicted(pr)) return { ...kept, canMerge: false, canAuto: false }
277
278 return { ...kept, canMerge: kept.canMerge && MERGES_NOW.has(pr.mergeStateStatus ?? '') }
279}
280
281export function mergeableOf(pr: GhPr): Mergeable {
282 if (pr.state !== 'OPEN') return noMerge()
283 const methods = methodsOf(pr.repository)
284 const isAuto = pr.autoMergeRequest != null
285 const isEligible = !pr.isDraft && !isConflicted(pr) && !isAuto && methods.length > 0
286 const canMerge = isEligible && MAY_MERGE.has(pr.repository?.viewerPermission ?? '') && MERGES_NOW.has(pr.mergeStateStatus ?? '')
287 // Held back by checks or review, not by a stale branch.
288 const isWaiting =
289 pr.mergeStateStatus === 'BLOCKED' || (pr.mergeStateStatus !== 'BEHIND' && summarizeChecks(pr.statusCheckRollup).pending > 0)
290
291 return {
292 methods,
293 canMerge,
294 // False already where the repo doesn't allow auto-merge.
295 canAuto: isEligible && !canMerge && isWaiting && pr.viewerCanEnableAutoMerge === true,
296 isAuto,
297 autoMethod: asMethod(pr.autoMergeRequest?.mergeMethod),
298 canCancelAuto: isAuto && pr.viewerCanDisableAutoMerge === true,
299 }
300}
301
302// The method a card offers: the one the person switched to, while it is still on offer.
303export function pickMethod(methods: readonly MergeMethod[], chosen: MergeMethod | undefined): MergeMethod | null {
304 return chosen !== undefined && methods.includes(chosen) ? chosen : (methods[0] ?? null)
305}
306
307export function nextMethod(methods: readonly MergeMethod[], current: MergeMethod): MergeMethod {
308 return methods[(methods.indexOf(current) + 1) % methods.length] ?? current
309}
310
311export const MERGE_LABEL: Record<MergeMethod, string> = { squash: 'Squash & merge', rebase: 'Rebase & merge', merge: 'Merge' }
312const MERGE_VERB: Record<MergeMethod, string> = { squash: 'Squash-merge', rebase: 'Rebase-merge', merge: 'Merge' }
313
314// "Squash-merge #12 into main?": what Confirm will do.
315export function mergeQuestion(method: MergeMethod, pr: { number: number; base: string }, isAuto: boolean): string {
316 const into = `${MERGE_VERB[method]} #${pr.number} into ${pr.base || 'its base'}`
317
318 return isAuto ? `${into} once checks and review pass?` : `${into}?`
319}
320
321export type MergeRun = { action: 'merge' | 'auto'; method: MergeMethod } | { action: 'cancel-auto' }
322
323export function mergeArgv(url: string, run: MergeRun): string[] {
324 if (run.action === 'cancel-auto') return ['pr', 'merge', url, '--disable-auto']
325
326 return ['pr', 'merge', url, ...(run.action === 'auto' ? ['--auto'] : []), `--${run.method}`]
327}
328
329// A refusal of the method itself, not of this PR's state, in GitHub's words: the repo's
330// settings ("Merge method squash merging is not allowed on this repository", "Squash merges
331// are not allowed on this repository.", "Merge commits are not allowed…"), a ruleset's
332// ("Rebase is not an allowed merge method"), or linear history's.
333const METHOD_REFUSED = new RegExp(
334 [
335 String.raw`\bmerge method \w+ (?:merging )?is not allowed`,
336 String.raw`\b(?:squash|rebase) merg(?:es|ing) (?:are|is) not allowed`,
337 String.raw`\bmerge commits are not allowed`,
338 String.raw`\bis not an allowed merge method`,
339 String.raw`\bmust not contain merge commits`,
340 ].join('|'),
341 'i',
342)
343
344// What gh said, in plain words: without its ✗, the "GraphQL:" prefix, the mutation's name
345// (camelCase, `(mergePullRequest)`; a branch or check named in brackets stays), or the hints
346// about gh's own flags.
347export function mergeError(stderr: string): { message: string; isMethodRefused: boolean } {
348 const lines = stderr
349 .split('\n')
350 .map(line => line.trim().replace(/^[X✗!]\s+/, '').replace(/^GraphQL:\s*/, '').replace(/\s*\([a-z]+[A-Z]\w*\)$/, ''))
351 .filter(line => line !== '' && !/^To (?:have|use|merge)\b/.test(line))
352 const message = lines.join(' ') || 'gh pr merge failed'
353
354 return { message, isMethodRefused: METHOD_REFUSED.test(message) }
355}
356
357// gh runs well inside this (its own timeouts stop it sooner; a collection's longest is a token,
358// the checks and a log in turn, 20 + 20 + 60 s): a busy mark older than this was left by a
359// module reload mid-run, so the row shows its buttons again.
360export const BUSY_MS = 120_000
361
362export function isBusy(busy: Busy | undefined, now: number): busy is Busy {
363 return typeof busy?.at === 'number' && now - busy.at < BUSY_MS
364}
365
366export function ago(ms: number): string {
367 const s = Math.max(0, Math.round(ms / 1000))
368 if (s < 10) return 'just now'
369 if (s < 60) return `${s}s ago`
370 if (s < 3600) return `${Math.floor(s / 60)}m ago`
371 if (s < 86_400) return `${Math.floor(s / 3600)}h ago`
372
373 return `${Math.floor(s / 86_400)}d ago`
374}
375
376export type Derived = {
377 stage: Stage
378 headline: string
379 pill: string
380 tone: Tone
381 isMoving: boolean
382 checks: Checks
383}
384
385// `kept`: the card's last review facts, for an entry an older version of this mod wrote (none
386// of its own), so the headline doesn't flip back to "Waiting for review" for a round.
387export function derive(pr: GhPr, kept: Reviews | null = null): Derived {
388 const checks = summarizeChecks(pr.statusCheckRollup)
389 const ci = `${activity(checks)} · ${checks.passed}/${checks.total} passed`
390 const at = (stage: Stage, tone: Tone, pill: string, headline: string, isMoving = false): Derived => ({
391 stage,
392 tone,
393 pill,
394 headline,
395 isMoving,
396 checks,
397 })
398
399 if (pr.state === 'MERGED') return at('merged', 'merged', '✓ MERGED', 'Merged')
400 if (pr.state === 'CLOSED') return at('closed', 'subtle', '✕ CLOSED', 'Closed without merging')
401 const names = checks.failing.slice(0, 2).join(', ')
402 const failing = `${names}${checks.failing.length > 2 ? ` +${checks.failing.length - 2}` : ''}`
403 if (pr.isDraft) {
404 const draft = isConflicted(pr) ? 'Draft · merge conflicts' : 'Draft'
405
406 return at('draft', 'subtle', '✎ DRAFT', checks.pending > 0 ? `${draft} · ${ci}` : draft, checks.pending > 0)
407 }
408 // A conflict blocks the merge whatever CI says, and checks on a conflicted head are often
409 // stale (GitHub cannot build the merge commit), so it outranks CI and review. CI stays in
410 // the line, and on the stepper, as the second thing to know.
411 if (isConflicted(pr)) {
412 const stage = checks.failed > 0 || checks.pending > 0 ? 'checks' : 'ready'
413 const status =
414 checks.failed > 0 ? ` · failing: ${failing}` : checks.pending > 0 ? ` · ${activity(checks)}` : ''
415
416 return at(stage, 'error', '⚠ CONFLICTS', `Merge conflicts with base${status}`, checks.pending > 0)
417 }
418 if (checks.failed > 0) {
419 // Still running checks keep the card live: the spinner, the shimmer and the fast poll.
420 return checks.pending > 0
421 ? at('checks', 'error', '✗ CI FAILING', `Failing: ${failing} · ${checks.pending} still running`, true)
422 : at('checks', 'error', '✗ CI FAILED', `Failing: ${failing}`)
423 }
424 if (checks.pending > 0) return at('checks', 'warning', '● CI RUNNING', ci, true)
425 const reviews = reviewsOf(pr) ?? kept
426 // "2 unresolved threads", or nothing: open conversations, which a repo may require resolved.
427 const threads = reviews === null ? '' : unresolved(reviews)
428 const also = threads === '' ? '' : ` · ${threads}`
429 if (pr.reviewDecision === 'CHANGES_REQUESTED') return at('review', 'error', '↺ CHANGES REQUESTED', 'Changes requested')
430 // Comments waiting for the author, where nobody has approved yet: a PR still in review, or
431 // one held back (BLOCKED) in a repo that needs no approval.
432 const isWaitingOnAuthor = pr.reviewDecision === 'REVIEW_REQUIRED' || (pr.reviewDecision !== 'APPROVED' && pr.mergeStateStatus === 'BLOCKED')
433 if (reviews !== null && threads !== '' && isWaitingOnAuthor) return at('review', 'warning', '💬 COMMENTS', toAddress(reviews))
434 if (pr.reviewDecision === 'REVIEW_REQUIRED') {
435 const isLookedAt = reviews?.reviewers.some(r => r.verdict !== 'requested') ?? false
436
437 return at('review', 'warning', '◷ IN REVIEW', isLookedAt ? 'Reviewed · waiting for approval' : 'Waiting for review')
438 }
439
440 const isAuto = Boolean(pr.autoMergeRequest)
441 switch (pr.mergeStateStatus) {
442 case 'BEHIND':
443 return at('ready', 'warning', '↓ BEHIND', isAuto ? 'Behind base · auto-merge on' : 'Branch is behind base')
444 case 'BLOCKED': {
445 // Approved, held back, threads open: the likely cause where the repo requires
446 // conversations resolved (GitHub says which rule only to admins).
447 const why = threads !== '' ? `Blocked${also}` : isAuto ? 'Blocked' : 'Blocked by branch rules'
448
449 return at('ready', 'warning', '⏸ BLOCKED', isAuto ? `${why} · auto-merge on` : why)
450 }
451 case 'UNKNOWN':
452 return at('ready', 'subtle', '◌ CHECKING', 'Computing mergeability…', true)
453 default: {
454 if (isAuto) return at('ready', 'suggestion', '⇢ AUTO-MERGING', 'Merging as soon as GitHub allows', true)
455 const ready = pr.reviewDecision === 'APPROVED' ? 'Approved' : 'Ready to merge'
456
457 return at('ready', 'success', '✓ READY', threads === '' ? 'Ready to merge' : `${ready}${also}`)
458 }
459 }
460}
461
462export function placeholder(ref: PrRef): TrackedPr {
463 return {
464 ...ref,
465 title: '',
466 state: 'UNKNOWN',
467 stage: 'unknown',
468 headline: 'Fetching from GitHub…',
469 pill: '◌ LOADING',
470 tone: 'subtle',
471 isMoving: true,
472 checks: emptyChecks(),
473 branch: '',
474 base: '',
475 additions: 0,
476 deletions: 0,
477 author: '',
478 mergedAt: null,
479 checkedAt: null,
480 error: null,
481 merge: noMerge(),
482 reviews: null,
483 reviewSeen: null,
484 }
485}
486
487export function isActive(pr: TrackedPr): boolean {
488 return pr.state === 'OPEN' || pr.state === 'UNKNOWN'
489}
490
491export const STEPS = ['Open', 'CI', 'Review', 'Merge'] as const
492
493// Which step the PR is on: steps before it are done, it carries the tone.
494export function stepIndex(stage: Stage): number {
495 switch (stage) {
496 case 'draft':
497 case 'unknown':
498 return 0
499 case 'checks':
500 return 1
501 case 'review':
502 return 2
503 case 'ready':
504 case 'closed':
505 return 3
506 case 'merged':
507 return 4
508 }
509}
510hooks/batch.ts 175 lines1// One GraphQL query for many PRs: an aliased field per PR, so a tick costs one call whatever
2// the count, plus the rate limit it left. `gh pr view` fields, shaped back the way it gives them.
3import type { CheckItem, GhPr, PrRef, RepoMerge } from './pr'
4
5// 20 PRs a query, run one after another: a query of 40 busy PRs can outlast GitHub's own
6// timeout (a 502 or 504) and lose the whole round, while two of 20 cost the same points and
7// each answers in a few seconds. 100 checks for each stays far inside the node cap. The
8// repo's merge settings ride on each PR's own repository field: plain scalars, which cost
9// nothing in rate-limit points or nodes. (Whether the repo allows auto-merge needs no field:
10// GitHub's viewerCanEnableAutoMerge is false where it doesn't.)
11export const BATCH_SIZE = 20
12
13// The head commit's checks, with whatever more a caller asks of each kind, and of the commit.
14const checksOf = (run = '', status = '', commit = '') => `commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes {
15 __typename
16 ... on CheckRun { name status conclusion startedAt ${run}checkSuite { workflowRun { workflow { name } } } }
17 ... on StatusContext { context state createdAt${status} }
18 } } }${commit} } } }`
19
20// Who reviewed and the open threads (review.ts): small pages, logins and states, and of the
21// newest threads only whether they are resolved or outdated, never a comment's text. Each
22// connection costs a PR one more request in GitHub's reckoning (100 make a point), so a batch
23// of 20 costs 1 point (up to 24 would).
24const REVIEWS = `latestReviews(first: 10) { totalCount nodes { author { __typename login } state submittedAt } }
25 latestOpinionatedReviews(first: 10) { nodes { author { __typename login } state submittedAt } }
26 reviewRequests(first: 10) { totalCount nodes { requestedReviewer {
27 __typename ... on User { login } ... on Bot { login } ... on Mannequin { login } ... on Team { slug }
28 } } }
29 reviewThreads(last: 50) { totalCount nodes { isResolved isOutdated } }`
30
31const FRAGMENT = `fragment pr on PullRequest {
32 number title url state isDraft reviewDecision mergeStateStatus mergeable mergedAt closedAt
33 additions deletions headRefName baseRefName author { login } autoMergeRequest { enabledAt mergeMethod }
34 viewerCanEnableAutoMerge viewerCanDisableAutoMerge
35 ${REVIEWS}
36 ${checksOf()}
37}
38fragment repo on Repository {
39 squashMergeAllowed rebaseMergeAllowed mergeCommitAllowed viewerDefaultMergeMethod viewerPermission
40}`
41
42export function buildQuery(refs: readonly PrRef[]): string {
43 const fields = refs.map((ref, i) => {
44 const [owner = '', name = ''] = ref.repo.split('/')
45
46 return `p${i}: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { pullRequest(number: ${ref.number}) { ...pr } ...repo }`
47 })
48
49 return `query {\n rateLimit { remaining resetAt }\n ${fields.join('\n ')}\n}\n${FRAGMENT}`
50}
51
52export type RateLimit = { remaining: number; resetAt: string }
53
54export type BatchResult = {
55 found: Map<string, GhPr>
56 // Not visible to this token: another account may see them.
57 missing: PrRef[]
58 rate: RateLimit | null
59 // The whole call failed (network, auth, a limit): nothing in it is known.
60 error: string | null
61}
62
63// A failed check run's title and summary, asked apart from the rest (checksQuery).
64type GqlAbout = { databaseId?: number | null; title?: string | null; summary?: string | null }
65
66type GqlPr = Omit<GhPr, 'statusCheckRollup'> & {
67 commits: {
68 nodes: {
69 commit: {
70 statusCheckRollup: { contexts: { nodes: GqlCheck[] } } | null
71 checkSuites?: { nodes: ({ checkRuns: { nodes: GqlAbout[] } | null } | null)[] } | null
72 }
73 }[]
74 }
75}
76
77type GqlRepo = RepoMerge & { pullRequest: GqlPr | null }
78
79type GqlReply = {
80 data?: Record<string, unknown> | null
81 errors?: { type?: string; message?: string }[]
82}
83
84type GqlCheck = NonNullable<GhPr['statusCheckRollup']>[number] & {
85 checkSuite?: { workflowRun?: { workflow?: { name?: string } | null } | null } | null
86}
87
88// A check run's workflow name, lifted out of its suite, so two workflows' same-named jobs
89// stay apart when runs are folded to the latest of each; and its title and summary, where
90// they were asked apart.
91function flattenCheck(node: GqlCheck, about: ReadonlyMap<number, GqlAbout>) {
92 const { checkSuite, ...check } = node
93 const workflow = checkSuite?.workflowRun?.workflow?.name
94 const text = typeof check.databaseId === 'number' ? about.get(check.databaseId) : undefined
95 const named = workflow === undefined ? check : { ...check, workflow }
96
97 return text === undefined ? named : { ...named, title: text.title ?? null, summary: text.summary ?? null }
98}
99
100const firstLine = (text: string) => text.trim().split('\n')[0] ?? ''
101
102// `gh api graphql` exits 1 when any alias errs, yet still prints every alias it resolved.
103export function parseReply(stdout: string, stderr: string, refs: readonly PrRef[]): BatchResult {
104 const result: BatchResult = { found: new Map(), missing: [], rate: null, error: null }
105 let reply: GqlReply
106 try {
107 reply = JSON.parse(stdout) as GqlReply
108 } catch {
109 result.error = firstLine(stderr) || 'gh api graphql failed'
110
111 return result
112 }
113 const data = reply.data
114 if (data == null) {
115 result.error = reply.errors?.[0]?.message ?? (firstLine(stderr) || 'GitHub returned no data')
116
117 return result
118 }
119 result.rate = (data.rateLimit as RateLimit | undefined) ?? null
120 refs.forEach((ref, i) => {
121 const { pullRequest: pr, ...repository } = (data[`p${i}`] as GqlRepo | null | undefined) ?? { pullRequest: null }
122 if (pr == null) {
123 result.missing.push(ref)
124
125 return
126 }
127 const { commits, ...rest } = pr
128 const commit = commits.nodes[0]?.commit
129 const nodes = commit?.statusCheckRollup?.contexts.nodes ?? []
130 const runs = (commit?.checkSuites?.nodes ?? []).flatMap(suite => suite?.checkRuns?.nodes ?? [])
131 const about = new Map(runs.flatMap(run => (typeof run.databaseId === 'number' ? [[run.databaseId, run] as const] : [])))
132 result.found.set(ref.url, { ...rest, repository, statusCheckRollup: nodes.map(node => flattenCheck(node, about)) })
133 })
134
135 return result
136}
137
138export function chunks<T>(items: readonly T[], size = BATCH_SIZE): T[][] {
139 const out: T[][] = []
140 for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
141
142 return out
143}
144
145// The conclusions a check run fails with (pr.ts's verdictOf: all but SUCCESS, NEUTRAL, SKIPPED).
146const FAILED = 'ACTION_REQUIRED, CANCELLED, FAILURE, STALE, STARTUP_FAILURE, TIMED_OUT'
147// The failed runs' titles and summaries, by the commit's check suites: a summary may run to
148// 65 KB, so a passing run's is never asked. Joined to its check by databaseId.
149const FAILED_RUNS = `
150 checkSuites(last: 50) { nodes { checkRuns(first: 20, filterBy: { checkType: LATEST, conclusions: [${FAILED}] }) {
151 nodes { databaseId title summary }
152 } } }`
153
154// One PR's checks with what handing a failure to Claude needs: a GitHub Actions run's job id
155// (its databaseId), the links and status descriptions (short: GitHub caps them at 140
156// characters), and the failed runs' summaries. One call, still one point. Asked only on that
157// press, so the poller's query stays as lean as it was; the same alias shape keeps one parser.
158export function checksQuery(ref: PrRef): string {
159 const [owner = '', name = ''] = ref.repo.split('/')
160
161 return `query {
162 p0: repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { pullRequest(number: ${ref.number}) {
163 ${checksOf('databaseId detailsUrl ', ' description targetUrl', FAILED_RUNS)}
164 } }
165}`
166}
167
168export function parseChecks(stdout: string, stderr: string, ref: PrRef): { checks: CheckItem[] } | { error: string } {
169 const reply = parseReply(stdout, stderr, [ref])
170 if (reply.error !== null) return { error: reply.error }
171 const pr = reply.found.get(ref.url)
172
173 return pr === undefined ? { error: "Not found, or this gh account can't see it" } : { checks: pr.statusCheckRollup ?? [] }
174}
175hooks/fix.ts 157 lines1// Handing a failed CI run to Claude: which checks failed, their logs trimmed to the part that
2// says why, and the message that asks for a fix. Pure, so the pane only fetches and delivers.
3import type { TrackedPr } from '../types'
4import { isStatusContext, latestRuns, verdictOf } from './pr'
5import type { CheckItem } from './pr'
6
7// The tail of each failed job's log that is kept, how many jobs' logs are fetched, and the
8// characters every log together may take: enough to find the error, not the runner's setup.
9export const LOG_LINES = 120
10export const LOG_JOBS = 3
11export const LOG_CHARS = 12_000
12const LINE_CHARS = 400
13
14export type Failure = {
15 name: string
16 workflow: string | null
17 // In words: "failure", "timed out", "error".
18 conclusion: string
19 url: string | null
20 // A check run's title and summary, or a status's description.
21 summary: string | null
22 // A GitHub Actions job, whose failed steps' log gh can fetch; null for any other CI.
23 jobId: number | null
24}
25
26const words = (value: string | null | undefined) => (value ?? 'failed').toLowerCase().replace(/_/g, ' ')
27
28const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
29
30// The checks failing on the latest run of each, counted the way the card counts them.
31export function failingChecks(items: readonly CheckItem[], repo: string): Failure[] {
32 return latestRuns(items)
33 .filter(item => verdictOf(item) === 'failed')
34 .map(item => {
35 const isContext = isStatusContext(item)
36 const id = typeof item.databaseId === 'number' ? item.databaseId : null
37 const about = isContext ? item.description : [item.title, item.summary].filter(Boolean).join(' · ')
38 const fallback = id === null ? null : `https://github.com/${repo}/runs/${id}`
39
40 return {
41 name: item.name ?? item.context ?? 'check',
42 workflow: item.workflow ?? null,
43 conclusion: words(isContext ? item.state : item.conclusion),
44 url: item.detailsUrl ?? item.targetUrl ?? fallback,
45 summary: about ? clip(about.replace(/\s+/g, ' ').trim(), 300) : null,
46 // Only a run in a workflow is an Actions job; another app's check run has no log here.
47 jobId: !isContext && item.workflow !== undefined ? id : null,
48 }
49 })
50}
51
52// The jobs whose logs are worth a fetch: the first few Actions failures.
53export const toFetch = (failures: readonly Failure[]) => failures.filter(f => f.jobId !== null).slice(0, LOG_JOBS)
54
55const ANSI = /\u001b\[[0-9;?]*[A-Za-z]|\u001b\][^\u0007]*\u0007/g
56const STAMP = /^?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z ?/
57
58// `gh run view --log-failed` prints "job<TAB>step<TAB>timestamp text" a line: kept are the
59// text, without colour codes or the runner's group markers, and the failed steps' names.
60export function cleanLog(raw: string): { steps: string[]; lines: string[] } {
61 const steps = new Set<string>()
62 const lines: string[] = []
63 for (const row of raw.replace(/\r/g, '').split('\n')) {
64 const parts = row.split('\t')
65 const step = parts.length >= 3 ? (parts[1] ?? '') : ''
66 if (step !== '' && step !== 'UNKNOWN STEP') steps.add(step)
67 const text = (parts.length >= 3 ? parts.slice(2).join('\t') : row).replace(STAMP, '').replace(ANSI, '')
68 if (/^##\[endgroup\]/.test(text)) continue
69 lines.push(clip(text.replace(/^##\[group\]/, ''), LINE_CHARS))
70 }
71 while (lines.length > 0 && lines[lines.length - 1]?.trim() === '') lines.pop()
72 while (lines.length > 0 && lines[0]?.trim() === '') lines.shift()
73
74 return { steps: [...steps], lines }
75}
76
77export type Trimmed = { text: string; steps: string[]; kept: number; total: number }
78
79// The end of the log, where the error is: its last `maxLines` lines, fewer if they pass `maxChars`.
80export function trimLog(raw: string, maxLines = LOG_LINES, maxChars = LOG_CHARS): Trimmed {
81 const { steps, lines } = cleanLog(raw)
82 const tail = lines.slice(-maxLines)
83 let size = tail.reduce((n, line) => n + line.length + 1, 0)
84 while (tail.length > 1 && size > maxChars) size -= (tail.shift()?.length ?? 0) + 1
85 while (tail.length > 0 && tail[0]?.trim() === '') tail.shift()
86
87 return { text: tail.join('\n'), steps, kept: tail.length, total: lines.length }
88}
89
90// What gh said when a log would not come, in plain words.
91export function logError(stderr: string): string {
92 const text = (stderr.trim().split('\n')[0] ?? '').replace(/^failed to get run log:\s*/i, '')
93 if (/HTTP 410/.test(text)) return 'GitHub no longer keeps this log'
94 if (/HTTP 404/.test(text)) return "GitHub has no log for it, or this account can't see it"
95 if (/in progress/i.test(text)) return 'its run is still going, and GitHub gives the log once it ends'
96
97 return text || 'gh run view failed'
98}
99
100// A failure and what came of fetching its log: the raw log, or why there is none.
101export type Handoff = { failure: Failure; log?: string; error?: string }
102
103// Logs that would not come, for the card: "No log for build: GitHub no longer keeps this log".
104export function logProblems(handoffs: readonly Handoff[]): string | null {
105 const problems = handoffs.filter(h => h.error !== undefined).map(h => `No log for ${h.failure.name}: ${h.error}`)
106
107 return problems.length > 0 ? problems.join('; ') : null
108}
109
110// A fence longer than any run of backticks in the log, so the log can't close it.
111const fence = (text: string) => '`'.repeat(Math.max(3, ...[...text.matchAll(/`+/g)].map(m => m[0].length + 1)))
112
113type PrFacts = Pick<TrackedPr, 'repo' | 'number' | 'title' | 'url' | 'branch' | 'base'>
114
115// The draft's first words, by which a press finds an earlier draft of its own in the prompt box.
116export const fixHeading = (pr: Pick<PrFacts, 'repo' | 'number'>) => `CI failed on ${pr.repo}#${pr.number}:`
117
118// A check as the PR page names it, its workflow first when that says more.
119const labelOf = (f: Failure) => (f.workflow === null || f.workflow === f.name ? f.name : `${f.workflow} / ${f.name}`)
120
121// The message for Claude: the PR, what failed, the logs' tails, and the ask. It never asks
122// for a push or a merge; that stays the person's call.
123export function fixPrompt(pr: PrFacts, handoffs: readonly Handoff[]): string {
124 const logs = handoffs.filter(h => h.log !== undefined)
125 const budget = Math.floor(LOG_CHARS / Math.max(1, logs.length))
126 const branch = pr.branch || 'its head branch'
127 const out = [
128 `${fixHeading(pr)} ${pr.title || 'untitled'}`,
129 `${pr.url} · ${branch} → ${pr.base || 'its base'} · checks: ${pr.url}/checks`,
130 '',
131 'Failing checks (the latest run of each):',
132 ...handoffs.map(({ failure: f }) => `- ${[labelOf(f), f.conclusion, f.summary, f.url].filter(Boolean).join(' · ')}`),
133 ]
134 for (const h of handoffs) {
135 const name = labelOf(h.failure)
136 if (h.error !== undefined) out.push('', `${name}: no log (${h.error}).`)
137 if (h.log === undefined) continue
138 const log = trimLog(h.log, LOG_LINES, budget)
139 if (log.kept === 0) {
140 out.push('', `${name}: gh found no failed step in its log.`)
141 continue
142 }
143 const step = log.steps.length > 0 ? `, failed step ${log.steps.map(s => `"${s}"`).join(', ')}` : ''
144 const span = log.kept < log.total ? `last ${log.kept} of ${log.total} lines, trimmed` : `all ${log.total} lines`
145 const bar = fence(log.text)
146 out.push('', `${name} log (${span}${step}):`, bar, log.text, bar)
147 }
148 const unfetched = handoffs.filter(h => h.failure.jobId !== null && h.log === undefined && h.error === undefined).length
149 if (unfetched > 0) out.push('', `Logs of the first ${LOG_JOBS} failing jobs only; ${unfetched} more not fetched.`)
150 out.push(
151 '',
152 `Find the cause and fix it on this branch (${branch}). If ${pr.repo} or ${branch} isn't checked out here, say so first.`,
153 )
154
155 return out.join('\n')
156}
157hooks/review.ts 160 lines1// A PR's review state as a card shows it: where each reviewer stands, the review threads still
2// open, and which reviews landed since the last look.
3import type { Reviewer, Reviews, Verdict } from '../types'
4
5type Actor = { __typename?: string; login?: string; slug?: string } | null
6
7export type GhReview = { author: Actor; state: string; submittedAt?: string | null }
8
9// The review connections the poller asks for (batch.ts), in GitHub's shape. `latestReviews` is
10// each reviewer's last review of any kind, but leaves out a reviewer with a request pending (a
11// re-request too); `latestOpinionatedReviews` is each one's last approval or change request,
12// which still counts after a later comment or a re-request.
13export type ReviewFacts = {
14 latestReviews?: { totalCount?: number; nodes: GhReview[] } | null
15 latestOpinionatedReviews?: { nodes: GhReview[] } | null
16 reviewRequests?: { totalCount?: number; nodes: { requestedReviewer: Actor }[] } | null
17 // The newest threads, enough to count; never their comments.
18 reviewThreads?: { totalCount?: number; nodes: { isResolved: boolean; isOutdated: boolean }[] } | null
19}
20
21type Reviewed = Exclude<Verdict, 'requested'>
22
23const VERDICT: Record<string, Reviewed> = { APPROVED: 'approved', CHANGES_REQUESTED: 'changes', COMMENTED: 'commented' }
24const RANK: readonly Verdict[] = ['changes', 'approved', 'commented', 'requested']
25
26// A review app's login cut to its name (copilot-pull-request-reviewer → copilot), so the line
27// stays short; a person's login stays whole.
28export function nameOf(actor: Actor): string {
29 const login = actor?.login ?? actor?.slug ?? ''
30
31 return actor?.__typename === 'Bot' && login.length > 12 ? login.split('-')[0] || login : login
32}
33
34const keyOf = (actor: Actor) => (actor?.__typename === 'Team' ? `team:${actor.slug ?? ''}` : (actor?.login ?? ''))
35
36// Each reviewer once. An approval or change request stands until its reviewer changes it, as
37// GitHub counts it for the decision; else a pending request; else their last comment. The PR's
38// author is no reviewer (their replies in a thread are reviews too). Null when the entry has no
39// review facts: an older version of this mod ran that round.
40export function reviewsOf(pr: ReviewFacts & { author?: { login: string } | null }): Reviews | null {
41 const { latestReviews: latest, reviewThreads: threads } = pr
42 if (latest == null || threads == null) return null
43 const requests = pr.reviewRequests ?? { nodes: [] }
44 const seen = new Map<string, Reviewer>()
45 const put = (actor: Actor, verdict: Verdict | undefined) => {
46 const key = keyOf(actor)
47 if (verdict === undefined || key === '' || key === pr.author?.login || seen.has(key)) return
48 seen.set(key, { login: nameOf(actor), verdict })
49 }
50 for (const review of pr.latestOpinionatedReviews?.nodes ?? []) {
51 if (review.state !== 'COMMENTED') put(review.author, VERDICT[review.state])
52 }
53 for (const request of requests.nodes) put(request.requestedReviewer, 'requested')
54 for (const review of latest.nodes) put(review.author, VERDICT[review.state])
55 const beyond = (total: number | undefined, shown: number) => Math.max(0, (total ?? shown) - shown)
56 const open = threads.nodes.filter(thread => !thread.isResolved)
57
58 return {
59 reviewers: [...seen.values()].sort((a, b) => RANK.indexOf(a.verdict) - RANK.indexOf(b.verdict)),
60 more: beyond(latest.totalCount, latest.nodes.length) + beyond(requests.totalCount, requests.nodes.length),
61 unresolved: open.length,
62 outdated: open.filter(thread => thread.isOutdated).length,
63 isCapped: beyond(threads.totalCount, threads.nodes.length) > 0,
64 }
65}
66
67// "3", or "8+" when there are more threads than were asked for: a floor, not a count.
68const count = (reviews: Reviews) => `${reviews.unresolved}${reviews.isCapped ? '+' : ''}`
69const plural = (reviews: Reviews, word: string) => `${count(reviews)} ${word}${reviews.unresolved === 1 && !reviews.isCapped ? '' : 's'}`
70
71// "3 unresolved threads", or nothing.
72export const unresolved = (reviews: Reviews) => (reviews.unresolved === 0 ? '' : plural(reviews, 'unresolved thread'))
73
74// "3 unresolved threads (1 outdated)": outdated ones are on code that has changed since.
75export function threadLine(reviews: Reviews): string {
76 const text = unresolved(reviews)
77
78 return text === '' || reviews.outdated === 0 ? text : `${text} (${reviews.outdated} outdated)`
79}
80
81// "3 comments to address", for the headline.
82export const toAddress = (reviews: Reviews) => `${plural(reviews, 'comment')} to address`
83
84// What a card says of the threads under its headline: the whole line, or, where the headline
85// counts them already ("3 comments to address", "Approved · 2 unresolved threads"), only how
86// many are outdated, beside it.
87export function threadsBeside(headline: string, reviews: Reviews): { line: string; note: string } {
88 const isCounted = reviews.unresolved > 0 && (headline === toAddress(reviews) || headline.includes(unresolved(reviews)))
89 if (!isCounted) return { line: threadLine(reviews), note: '' }
90
91 return { line: '', note: reviews.outdated > 0 ? `${reviews.outdated} outdated` : '' }
92}
93
94const ICON: Record<Verdict, string> = { approved: '✓', changes: '✗', commented: '💬', requested: '◷' }
95const SAYS: Record<Verdict, string> = { approved: 'approved', changes: 'requested changes', commented: 'commented', requested: 'requested' }
96
97// Terminal cells: an emoji outside the basic plane (💬) takes two.
98export function cells(text: string): number {
99 return [...text].reduce((n, ch) => n + ((ch.codePointAt(0) ?? 0) > 0xffff ? 2 : 1), 0)
100}
101
102export type Piece = { text: string; verdict: Verdict | null }
103
104const SEP = 3 // ' · '
105
106// The reviewers line, to fit `columns`: "✓ alice approved · ✗ bob requested changes" while it
107// fits, else just icons and names ("✓ alice · ✗ bob · 💬 nadav"), as many as fit beside a
108// "+2 more" (a null verdict). Always at least one reviewer: the card truncates the rest.
109export function verdictLine(reviews: Pick<Reviews, 'reviewers' | 'more'>, columns: number): Piece[] {
110 const width = (pieces: readonly Piece[]) => pieces.reduce((n, p) => n + cells(p.text), 0) + SEP * Math.max(0, pieces.length - 1)
111 const more = (n: number): Piece[] => (n > 0 ? [{ text: `+${n} more`, verdict: null }] : [])
112 const long = reviews.reviewers.map(r => ({ text: `${ICON[r.verdict]} ${r.login} ${SAYS[r.verdict]}`, verdict: r.verdict }))
113 if (width([...long, ...more(reviews.more)]) <= columns) return [...long, ...more(reviews.more)]
114 const short = reviews.reviewers.map(r => ({ text: `${ICON[r.verdict]} ${r.login}`, verdict: r.verdict }))
115 let shown = short.length
116 while (shown > 1 && width([...short.slice(0, shown), ...more(short.length - shown + reviews.more)]) > columns) shown -= 1
117
118 return [...short.slice(0, shown), ...more(short.length - shown + reviews.more)]
119}
120
121export type ReviewEvent = { who: string; verdict: Reviewed; at: number }
122
123// The reviews submitted since `seen` (the newest submission time already looked at), by anyone
124// but the person (any of their gh logins) or the PR's author (their thread replies are reviews
125// too, and reviewsOf leaves them out). On a PR's first look (`seen` null) none is new:
126// they were there before the card. Returns the new mark, which only moves forward, so a review
127// that drops out of the reply and comes back (a request made and withdrawn) isn't new again.
128export function newReviews(pr: ReviewFacts, seen: number | null, mine: readonly string[], author?: string): { fresh: ReviewEvent[]; seen: number | null } {
129 if (pr.latestReviews == null) return { fresh: [], seen }
130 const own = new Set([...mine, author ?? ''].filter(Boolean).map(login => login.toLowerCase()))
131 const all = new Map<string, ReviewEvent & { login: string }>()
132 for (const review of [...pr.latestReviews.nodes, ...(pr.latestOpinionatedReviews?.nodes ?? [])]) {
133 const at = Date.parse(review.submittedAt ?? '')
134 const verdict = VERDICT[review.state]
135 const login = review.author?.login ?? ''
136 if (Number.isFinite(at) && verdict !== undefined && login !== '') all.set(`${login}@${at}`, { who: nameOf(review.author), login, verdict, at })
137 }
138 const mark = Math.max(seen ?? 0, ...[...all.values()].map(review => review.at))
139 if (seen === null) return { fresh: [], seen: mark }
140 const fresh = [...all.values()]
141 .filter(review => review.at > seen && !own.has(review.login.toLowerCase()))
142 .sort((a, b) => a.at - b.at)
143 .map(({ who, verdict, at }) => ({ who, verdict, at }))
144
145 return { fresh, seen: mark }
146}
147
148const SAID: Record<Reviewed, (who: string, number: number) => string> = {
149 approved: (who, number) => `✓ ${who} approved #${number}`,
150 changes: (who, number) => `✗ ${who} requested changes on #${number}`,
151 commented: (who, number) => `💬 ${who} commented on #${number}`,
152}
153
154// One toast per new review; a burst of them (a round after a long sleep) is one toast.
155export function reviewToasts(fresh: readonly ReviewEvent[], number: number): string[] {
156 if (fresh.length > 2) return [`💬 ${fresh.length} new reviews on #${number}`]
157
158 return fresh.map(review => SAID[review.verdict](review.who, number))
159}
160types/index.d.ts 114 lines1export type Stage = 'draft' | 'checks' | 'review' | 'ready' | 'merged' | 'closed' | 'unknown'
2export type Tone = 'success' | 'error' | 'warning' | 'merged' | 'subtle' | 'suggestion'
3export type MergeMethod = 'squash' | 'rebase' | 'merge'
4
5export type Checks = {
6 passed: number
7 failed: number
8 pending: number
9 total: number
10 failing: string[]
11 // Pending checks split: started on a runner vs still waiting for one.
12 running: string[]
13 queued: number
14}
15
16// What the viewer may do about merging, as GitHub said at the last check.
17export type Mergeable = {
18 // The methods the repo allows, the viewer's default first.
19 methods: MergeMethod[]
20 // GitHub would merge it now, and the viewer may.
21 canMerge: boolean
22 // It waits on checks or review, and the viewer may turn auto-merge on.
23 canAuto: boolean
24 isAuto: boolean
25 // The method auto-merge will use, when GitHub said.
26 autoMethod: MergeMethod | null
27 canCancelAuto: boolean
28}
29
30// Where a reviewer stands: their approval or change request, a review asked of them, or a
31// comment-only review.
32export type Verdict = 'approved' | 'changes' | 'commented' | 'requested'
33
34export type Reviewer = { login: string; verdict: Verdict }
35
36// A PR's reviewers and open review threads, as GitHub said at the last check.
37export type Reviews = {
38 reviewers: Reviewer[]
39 // Reviewers and requests beyond the ones asked for.
40 more: number
41 unresolved: number
42 // Unresolved threads on code that has changed since.
43 outdated: number
44 // More threads than were asked for: `unresolved` counts the newest only.
45 isCapped: boolean
46}
47
48export type TrackedPr = {
49 url: string
50 repo: string
51 number: number
52 title: string
53 state: 'OPEN' | 'MERGED' | 'CLOSED' | 'UNKNOWN'
54 stage: Stage
55 headline: string
56 pill: string
57 tone: Tone
58 isMoving: boolean
59 checks: Checks
60 branch: string
61 base: string
62 additions: number
63 deletions: number
64 author: string
65 mergedAt: string | null
66 checkedAt: number | null
67 error: string | null
68 merge: Mergeable
69 // Null until a round with review facts lands.
70 reviews: Reviews | null
71 // The newest review submission looked at (ms), so only later ones toast; null before the
72 // first look.
73 reviewSeen: number | null
74}
75
76// A press's work under way, from when it started: a mark older than a run can take was left by
77// a reload mid-run, and counts for nothing.
78export type Busy = { at: number }
79
80// A card's merge row between presses: the method picked, the question asked, gh running (and
81// for which action), or what GitHub said no with.
82export type MergeAsk = {
83 method?: MergeMethod
84 asking?: 'merge' | 'auto'
85 busy?: Busy & { action: 'merge' | 'auto' | 'cancel-auto' }
86 error?: string
87}
88
89// A card's Fix with Claude row: logs being collected, the message held for a yes where no
90// prompt box can take it as a draft, what came of it, or what went wrong.
91export type FixAsk = {
92 busy?: Busy
93 asking?: string
94 done?: string
95 error?: string
96}
97
98declare module 'claude-code' {
99 interface PluginState {
100 'pr-watch': {
101 prs: TrackedPr[]
102 now: number
103 suggested: string[]
104 dropped: string[]
105 // By PR url.
106 merging: Record<string, MergeAsk>
107 // Methods GitHub refused this session, by repo.
108 refused: Record<string, MergeMethod[]>
109 // By PR url.
110 fixing: Record<string, FixAsk>
111 }
112 }
113}
114