SLOPSHOPPER

pr-watch

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

newpaneguardcommandtoaststatus
★ 1v0.6.1MITupdated 2026-10-09ElirazKed/claude-code-pr-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-watch
│ ┃ Pull requests ✕ › fix the failing auth test and add an audit log call │ ┃ ⎇ Pull requests │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭─────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ ⎇ ◌ │ ⏺ Bash(bun test) │ ┃ ╰─────╯ ⎿ 3 pass, 1 fail │ ┃ Nothing to watch yet │ ┃ PRs appear when Claude runs gh pr … ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ or with /pr-watch <url> │ ┃ ✻ Worked for 42s · done 4:20 PM │ │ › /pr-watch │ ⎿ pr-watch: PR panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pull requests
⎇ Pull requests ──────────────────────────────────────────────────────── ╭─────╮ │ ⎇ ◌ │ ╰─────╯ Nothing to watch yet PRs appear when Claude runs gh pr … or with /pr-watch <url>
README

pr-watch

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 pr-watch pane docked beside a Claude Code session, showing this repo's PR #1 just merged

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                 ✕  │
╰──────────────────────────────────────────────╯

Install

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

What it watches

  • Automatically: PRs Claude acts on: opens (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.
  • When you ask: /pr-watch <url>, or paste a PR link in a prompt. These are watched in any state.
  • When offered: PRs Claude only reads (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.

Reviews

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                 ✕  │
╰──────────────────────────────────────────────╯
  • Who reviewed: each reviewer once, by what still counts. An approval (✓) or a change request (✗) stands until its reviewer changes it, as GitHub counts it; otherwise a review asked of them (◷ carol requested); otherwise their latest comment-only review (💬 nadav commented). Review apps count too, by a short name (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.
  • Threads: how many review threads are unresolved, and how many of those are outdated (the code changed since). The poll asks about the newest 50 threads only, so past that the count is a floor (50+).
  • Headline: with threads open and no approval yet, the card says 3 comments to address (💬 COMMENTS) instead of Waiting for review. With comment-only reviews and every thread resolved it says Reviewed · waiting for approval. Approved with threads open, it says Approved · 2 unresolved threads, or Blocked · 2 unresolved threads when GitHub holds the merge back, as it does in a repo that requires conversations resolved. Conflicts and CI still come first.
  • Toasts: each new review toasts once: 💬 nadav commented on #532, ✓ alice approved #12, ✗ bob requested changes on #12. Reviews already there when the card first loads don't toast, and neither do your own (from any of your gh accounts). The card keeps the newest review it has seen, so a /clear or a reload doesn't toast them again.

Merging

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.

  • Confirmation: the first press asks, e.g. Squash-merge #12 into main?, and only Confirm (y) runs anything; Cancel (n) goes back.
  • Auto-merge: while the PR waits on checks or reviews in a repo with auto-merge allowed, the button is Auto-merge · squash instead. It asks the same way, then GitHub merges the PR once everything passes (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.
  • Which account: gh pr merge runs as the gh account that can see the repo, the one the poller reads it with.
  • While it runs: the card says so (Merging…), and a second press does nothing. If a reload cuts the run off, the buttons come back after 2 minutes.
  • When GitHub says no: its message shows on the card. If it refused the method itself (say, a ruleset that allows only rebase), that method isn't offered for the repo again this session.

Fix with Claude

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.

  • What's in it: the PR (repo, number, title, link, head branch → base), each failing check with its conclusion and link, the failed steps' logs, and the ask: find the cause and fix it on this branch, or say first if the repo or branch isn't checked out here. It doesn't ask Claude to push or merge.
  • Logs: for GitHub Actions jobs, 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.
  • Other CI: a check from another CI app, or a commit status, has no log to fetch here, so its name, conclusion, summary or description, and link go in instead.
  • Which account: the checks and logs are fetched as the gh account that can see the repo, the one the poller reads it with.
  • When something won't come: the card says why (say, No log for build: GitHub no longer keeps this log), and the draft still has the names and links.
  • While it collects: the card says Collecting logs…, and a second press does nothing. If a reload cuts it off, the button comes back after 2 minutes.
  • No prompt box: where the box won't take a draft, the card asks instead, Send CI failure of #12 to Claude?, and only Confirm (y) sends it; Cancel (n) sends nothing.

How it polls

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.

  • Each session lists its PRs in ~/.cache/pr-watch/sessions/. The file's modified time is its heartbeat.
  • When the shared results are due, one session takes a short lease and fetches every live session's PRs in batched GraphQL queries, 20 PRs each, one after another. It writes them to ~/.cache/pr-watch/results.json, and the other sessions read that.
  • By default it polls every 20s while CI is running somewhere and every 60s otherwise. It backs off when fewer than 300 calls of your rate limit remain.
  • Each query costs 1 point of GitHub's GraphQL rate limit (5,000 an hour), so 40 watched PRs cost 2 points a round. Smaller queries also keep a round of busy PRs inside GitHub's own timeout. It asks only for what the card shows: of reviews, the reviewers and their states; of threads, whether each is resolved or outdated, never what anyone wrote.

Poll intervals

Both intervals are settings in /config:

SettingDefaultRange
poll_active_seconds: while a watched PR's CI runs or it's about to merge2010–600
poll_idle_seconds: while nothing is moving6010–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.

  • With several 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.

Develop

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.

License

MIT

Source 7 files
hooks/register.tsx 1138 lines
1import { 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 &lt;url&gt;</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}
1138
hooks/look.ts 125 lines
1// 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}
125
hooks/pr.ts 510 lines
1import 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}
510
hooks/batch.ts 175 lines
1// 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}
175
hooks/fix.ts 157 lines
1// 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}
157
hooks/review.ts 160 lines
1// 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}
160
types/index.d.ts 114 lines
1export 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