Live pane of a repo's open pull requests (checks, review bots, enforced rules, review threads and the commits that address them, the agents at work) plus a…

A Claude Code mod: a live pane of the current repo's open pull requests, from opened to merged and branch-deleted.
For each PR it shows, refreshed on a timer and right after Claude runs git push or gh pr:
Co-Authored-By trailers and bot loginsIt also keeps a review-bot ledger in SQLite (~/.claude/pr-pulse/reviews.db by default): every bot finding with its severity, response time, whether the code moved after it, and whether another bot raised the same thing. /prs stats answers "is this bot earning its keep?" per bot: findings, used vs dismissed, median time from push to comment, unique vs duplicated findings, and the overlap between each pair of bots as judged by a cheap model call.
| Command | Does | |
|---|---|---|
/prs | open the pane (all open PRs) | |
/prs 262 | drill into PR #262 | |
/prs back | back to the list | |
/prs refresh | fetch now | |
/prs close | close the pane | |
/prs repo owner/name | watch another repository | |
| `/prs stats [days\ | all] [all-repos]` | per-bot usefulness and overlap report, also drawn in the pane |
/prs trend [weeks] [all-repos] | week-by-week findings, used, repeats and lines moved per bot (default 12 weeks); the stats view draws it as bars | |
/prs backfill [N] | sweep the last N merged or closed PRs (default 30) into the ledger, in the background | |
/prs judge [N] | settle up to N unjudged "same finding?" pairs with the model | |
/prs db | print the ledger's path |
In the pane: digits 1–9 open a PR, b goes back, r refreshes, s shows the stats view, j judges pending pairs.
The ledger is written whenever the pane loads a PR's detail, when the poller sees an open PR change, when a PR settles, and by /prs backfill. Query it with sqlite3 ~/.claude/pr-pulse/reviews.db (tables prs, commits, threads, thread_pairs).
/config lists the mod's fields: poll interval (seconds), open the pane automatically when the repo has open PRs, pin a status line under the prompt, toasts on changes (a failed check, new review threads, an approval, a merge).
gh on PATH and signed in (gh auth status)sqlite3 on PATH (ships with macOS) for the ledger; without it the pane still works and ledger writes are skippedAt the prompt of a Claude Code terminal session:
/plugin install pr-pulse --marketplace revsmoke/pr-pulse
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). From a shell: claude plugin install pr-pulse --marketplace revsmoke/pr-pulse. In the desktop app, click + next to the prompt box, then Plugins, Add marketplace with revsmoke/pr-pulse, then Add plugin.
To run it from a checkout instead: claude --plugin-dir /path/to/pr-pulse, or set CLAUDE_CODE_PLUGIN_DIRS to that path in the env block of ~/.claude/settings.json.
claude plugin validate .
claude plugin test .hooks/register.tsx 658 lines1// pr-pulse: a pane that follows a repo's open pull requests in near real time,
2// and a SQLite ledger of what the review bots found and what became of it.
3// Checks, review bots, enforced branch rules, review threads and the commits
4// that address them, the agents working on each PR. Data comes from the gh
5// CLI (GraphQL); live state lives in $.state so a hot reload keeps it; the
6// ledger is written through the sqlite3 CLI.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { BackfillProgress, CommitPatch, PrDetail, SettledPr, Snapshot, StatsReport } from '../types'
12import {
13 MIGRATIONS,
14 SCHEMA,
15 aggregateStats,
16 commitFilesSql,
17 formatStats,
18 formatTrend,
19 hasLineCounts,
20 judgePrompt,
21 pairUpdateSql,
22 parseJudgement,
23 patchFromRest,
24 recordSql,
25 statsSql,
26 unjudgedPairsSql,
27 type PairRow,
28 type SameRow,
29 type StatRow,
30 type UnjudgedRow,
31} from './db.ts'
32import { DETAIL_QUERY, LIST_QUERY, SETTLED_QUERY, TRIM_BODIES, diffSnapshots, parseDetail, parseList, parseSettledNumbers, statusLineOf } from './github.ts'
33import { renderDetail, renderList, renderStats } from './view.tsx'
34
35const PANE = 'pr-pulse'
36
37const repoAtom = atom({ plugin: 'pr-pulse', key: 'repo' } as const, null)
38const snapshotAtom = atom({ plugin: 'pr-pulse', key: 'snapshot' } as const, null)
39const detailAtom = atom({ plugin: 'pr-pulse', key: 'detail' } as const, null)
40const selectedAtom = atom({ plugin: 'pr-pulse', key: 'selected' } as const, null)
41const errorAtom = atom({ plugin: 'pr-pulse', key: 'error' } as const, null)
42const loadingAtom = atom({ plugin: 'pr-pulse', key: 'isLoading' } as const, false)
43const settledAtom = atom({ plugin: 'pr-pulse', key: 'settled' } as const, [])
44const modeAtom = atom({ plugin: 'pr-pulse', key: 'mode' } as const, 'prs')
45const statsAtom = atom({ plugin: 'pr-pulse', key: 'stats' } as const, null)
46const backfillAtom = atom({ plugin: 'pr-pulse', key: 'backfill' } as const, null)
47const dbPathAtom = atom({ plugin: 'pr-pulse', key: 'dbPath' } as const, null)
48
49type Settings = { pollMs: number; autoOpen: boolean; statusLine: boolean; toasts: boolean; dbPath: string }
50
51let settings: Settings = { pollMs: 45_000, autoOpen: true, statusLine: true, toasts: true, dbPath: '' }
52let sessionCwd: string | undefined
53let isFetching = false
54let hasOfferedPane = false
55let isPolling = false
56let isDbReady = false
57let isBackfilling = false
58// PR numbers already recorded in the ledger this session, by their updatedAt,
59// so the poller records an open PR again only when GitHub says it changed.
60const recordedAt = new Map<number, string>()
61const patchBySha = new Map<string, CommitPatch | null>()
62
63// ---------- gh ----------
64
65type GhResult = { ok: boolean; out: string; err: string }
66
67async function gh($: EngineInterface, args: readonly string[], timeoutMs = 60_000): Promise<GhResult> {
68 try {
69 const ran = await $.process.run(['gh', ...args], { cwd: sessionCwd, timeoutMs })
70 return { ok: ran.exitCode === 0, out: ran.stdout, err: ran.stderr.trim() }
71 } catch (error) {
72 return { ok: false, out: '', err: error instanceof Error ? error.message : String(error) }
73 }
74}
75
76function shortError(err: string): string {
77 const line = err.split('\n').find(l => l.trim().length > 0) ?? 'gh failed'
78 return line.length > 160 ? `${line.slice(0, 157)}…` : line
79}
80
81async function detectRepo($: EngineInterface): Promise<string | null> {
82 const ran = await gh($, ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], 20_000)
83 if (!ran.ok) return null
84 const name = ran.out.trim()
85 return /^[^/\s]+\/[^/\s]+$/.test(name) ? name : null
86}
87
88function splitRepo(repo: string): { owner: string; name: string } {
89 const [owner = '', name = ''] = repo.split('/')
90 return { owner, name }
91}
92
93// ---------- sqlite ----------
94
95async function dbPath($: EngineInterface): Promise<string> {
96 const known = await read($, dbPathAtom)
97 if (known) return known
98 let path = settings.dbPath.trim()
99 if (!path) {
100 const home = (await $.env.get('HOME')) ?? '.'
101 path = `${home}/.claude/pr-pulse/reviews.db`
102 } else if (path.startsWith('~/')) {
103 path = `${(await $.env.get('HOME')) ?? '.'}${path.slice(1)}`
104 }
105 await update($, dbPathAtom, () => path)
106 return path
107}
108
109async function sql($: EngineInterface, statements: string): Promise<{ ok: boolean; rows: unknown[]; err: string }> {
110 const path = await dbPath($)
111 if (!isDbReady) {
112 const dir = path.slice(0, path.lastIndexOf('/'))
113 try {
114 if (dir) await $.process.run(['mkdir', '-p', dir], { timeoutMs: 10_000 })
115 const made = await $.process.run(['sqlite3', '-bail', path], { stdin: SCHEMA, timeoutMs: 30_000 })
116 if (made.exitCode !== 0) return { ok: false, rows: [], err: `sqlite3: ${shortError(made.stderr)}` }
117 // Columns added since the first release; each ALTER fails harmlessly
118 // where the column exists, so this run is not bailed on errors.
119 await $.process.run(['sqlite3', path], { stdin: MIGRATIONS.join('\n'), timeoutMs: 30_000 })
120 isDbReady = true
121 } catch (error) {
122 return { ok: false, rows: [], err: `sqlite3: ${error instanceof Error ? error.message : String(error)}` }
123 }
124 }
125 try {
126 const ran = await $.process.run(['sqlite3', '-bail', '-json', path], { stdin: statements, timeoutMs: 60_000 })
127 if (ran.exitCode !== 0) return { ok: false, rows: [], err: `sqlite3: ${shortError(ran.stderr)}` }
128 const text = ran.stdout.trim()
129 return { ok: true, rows: text ? (JSON.parse(text) as unknown[]) : [], err: '' }
130 } catch (error) {
131 return { ok: false, rows: [], err: `sqlite3: ${error instanceof Error ? error.message : String(error)}` }
132 }
133}
134
135// What a commit changed, from the REST API: the files and each file's diff
136// hunks, so a thread can be checked at the line, not just the file. Cached per
137// sha for the session and in the ledger; asked only for commits credited with
138// addressing a thread.
139async function commitPatch($: EngineInterface, repo: string, sha: string): Promise<CommitPatch | null> {
140 const cached = patchBySha.get(sha)
141 if (cached !== undefined) return cached
142 const ran = await gh($, ['api', `repos/${repo}/commits/${sha}`, '--jq', '[.files[] | {filename, patch: ((.patch // "")[0:60000])}]'], 30_000)
143 const patch = ran.ok ? patchFromRest(ran.out) : null
144 patchBySha.set(sha, patch)
145 return patch
146}
147
148async function recordDetail($: EngineInterface, repo: string, detail: PrDetail): Promise<string | null> {
149 const known = await sql($, commitFilesSql(repo, detail.number))
150 if (!known.ok) return known.err
151 for (const row of known.rows as { sha: string; files: string; hunks: string | null }[]) {
152 // A row written before hunks (or their line counts) were kept is fetched
153 // again once; a complete one is reused.
154 if (!patchBySha.has(row.sha) && row.hunks) {
155 try {
156 const patch = { files: JSON.parse(row.files) as string[], hunks: JSON.parse(row.hunks) as CommitPatch['hunks'] }
157 if (hasLineCounts(patch)) patchBySha.set(row.sha, patch)
158 } catch {
159 // unreadable row: fetch again below
160 }
161 }
162 }
163 const wanted = new Set(detail.threads.map(t => t.addressedBy).filter((s): s is string => s !== null))
164 for (const sha of wanted) await commitPatch($, repo, sha)
165 const written = await sql($, recordSql(repo, detail, patchBySha, new Date(await $.clock.now()).toISOString()))
166 if (!written.ok) return written.err
167 recordedAt.set(detail.number, detail.updatedAt)
168 return null
169}
170
171// Settles unjudged candidate pairs with one cheap model call each: do two bots'
172// comments on the same file report the same issue?
173async function judgePairs($: EngineInterface, repo: string | null, limit: number): Promise<{ judged: number; same: number; error: string | null }> {
174 const found = await sql($, unjudgedPairsSql(repo, limit))
175 if (!found.ok) return { judged: 0, same: 0, error: found.err }
176 let judged = 0
177 let same = 0
178 for (const row of found.rows as UnjudgedRow[]) {
179 const reply = await $.model.complete({
180 model: 'haiku',
181 prompt: judgePrompt({ path: row.path_a, line: row.line_a, body: row.body_a }, { path: row.path_b, line: row.line_b, body: row.body_b }),
182 maxTokens: 80,
183 effort: 'low',
184 timeoutMs: 30_000,
185 })
186 if (!reply.isAnswered) continue
187 const verdict = parseJudgement(reply.text)
188 if (!verdict) continue
189 const saved = await sql($, pairUpdateSql(row.a, row.b, verdict.same, verdict.reason, new Date(await $.clock.now()).toISOString()))
190 if (!saved.ok) return { judged, same, error: saved.err }
191 judged += 1
192 if (verdict.same) same += 1
193 }
194 return { judged, same, error: null }
195}
196
197async function fetchDetail($: EngineInterface, repo: string, number: number): Promise<{ detail: PrDetail | null; error: string | null }> {
198 const { owner, name } = splitRepo(repo)
199 const ran = await gh($, [
200 'api', 'graphql',
201 '-f', `query=${DETAIL_QUERY}`,
202 '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `number=${number}`,
203 '--jq', TRIM_BODIES,
204 ])
205 if (!ran.ok) return { detail: null, error: `GitHub: ${shortError(ran.err || ran.out)}` }
206 const snapshot = await read($, snapshotAtom)
207 try {
208 return { detail: parseDetail(ran.out, await $.clock.now(), snapshot?.rules ?? null), error: null }
209 } catch (error) {
210 return { detail: null, error: `GitHub: ${error instanceof Error ? error.message : String(error)}` }
211 }
212}
213
214async function computeStats($: EngineInterface, repo: string | null, sinceDays: number | null, trendWeeks = 12): Promise<{ report: StatsReport | null; error: string | null }> {
215 const now = await $.clock.now()
216 const queries = statsSql(repo, sinceDays, new Date(now).toISOString())
217 const threads = await sql($, queries.threads)
218 if (!threads.ok) return { report: null, error: threads.err }
219 const pairs = await sql($, queries.pairs)
220 if (!pairs.ok) return { report: null, error: pairs.err }
221 const sames = await sql($, queries.sames)
222 if (!sames.ok) return { report: null, error: sames.err }
223 const report = aggregateStats(threads.rows as StatRow[], pairs.rows as PairRow[], sames.rows as SameRow[], repo, sinceDays, now, trendWeeks)
224 await update($, statsAtom, () => report)
225 return { report, error: null }
226}
227
228// Sweeps merged and closed PRs into the ledger so the stats have history from
229// day one; runs in the background and reports through the pane and status line.
230async function backfill($: EngineInterface, repo: string, count: number): Promise<void> {
231 if (isBackfilling) return
232 isBackfilling = true
233 const progress = async (p: Partial<BackfillProgress>) => update($, backfillAtom, prior => ({ ...(prior ?? { running: true, done: 0, total: 0, message: '' }), ...p }))
234 try {
235 await progress({ running: true, done: 0, total: 0, message: 'listing settled PRs…' })
236 const { owner, name } = splitRepo(repo)
237 const listed = await gh($, ['api', 'graphql', '-f', `query=${SETTLED_QUERY}`, '-F', `owner=${owner}`, '-F', `name=${name}`, '-F', `count=${Math.min(100, Math.max(1, count))}`])
238 if (!listed.ok) {
239 await progress({ running: false, message: `GitHub: ${shortError(listed.err || listed.out)}` })
240 return
241 }
242 const numbers = parseSettledNumbers(listed.out)
243 await progress({ total: numbers.length, message: `recording ${numbers.length} PRs…` })
244 let done = 0
245 let failed = 0
246 for (const number of numbers) {
247 const got = await fetchDetail($, repo, number)
248 if (got.detail) {
249 const err = await recordDetail($, repo, got.detail)
250 if (err) failed += 1
251 } else failed += 1
252 done += 1
253 await progress({ done, message: `recorded ${done}/${numbers.length}${failed ? `, ${failed} failed` : ''}` })
254 $.ui.status(`pr-pulse backfill ${done}/${numbers.length}`)
255 }
256 await progress({ message: `judging overlapping findings…` })
257 const judged = await judgePairs($, repo, 800)
258 const stats = await computeStats($, repo, null)
259 await progress({ running: false, message: `backfilled ${done} PRs${failed ? ` (${failed} failed)` : ''}, judged ${judged.judged} pairs${judged.error ? `; ${judged.error}` : ''}` })
260 if (settings.toasts) $.ui.toast(`pr-pulse: backfilled ${done} PRs, ${stats.report?.totalThreads ?? 0} bot findings on record`)
261 } catch (error) {
262 await progress({ running: false, message: error instanceof Error ? error.message : String(error) })
263 } finally {
264 isBackfilling = false
265 const snapshot = await read($, snapshotAtom)
266 $.ui.status(settings.statusLine && snapshot ? statusLineOf(snapshot) : undefined)
267 }
268}
269
270// ---------- fetching ----------
271
272async function refreshList($: EngineInterface): Promise<Snapshot | null> {
273 const repo = await read($, repoAtom)
274 if (!repo) return null
275 const { owner, name } = splitRepo(repo)
276 const ran = await gh($, ['api', 'graphql', '-f', `query=${LIST_QUERY}`, '-F', `owner=${owner}`, '-F', `name=${name}`])
277 if (!ran.ok) {
278 await update($, errorAtom, () => `GitHub: ${shortError(ran.err || ran.out)}`)
279 return null
280 }
281 let snapshot: Snapshot
282 try {
283 snapshot = parseList(ran.out, await $.clock.now())
284 } catch (error) {
285 await update($, errorAtom, () => `GitHub: ${error instanceof Error ? error.message : String(error)}`)
286 return null
287 }
288 const previous = await read($, snapshotAtom)
289 await update($, snapshotAtom, () => snapshot)
290 await update($, errorAtom, () => null)
291 if (!isBackfilling) $.ui.status(settings.statusLine ? statusLineOf(snapshot) : undefined)
292
293 if (settings.toasts) {
294 for (const change of diffSnapshots(previous, snapshot).slice(0, 3)) $.ui.toast(change.text)
295 }
296 if (previous && previous.repo === snapshot.repo) {
297 const gone = previous.prs.filter(p => !snapshot.prs.some(q => q.number === p.number))
298 for (const pr of gone) await noteSettled($, repo, pr.number)
299 }
300 await recheckBranches($, repo)
301 return snapshot
302}
303
304// Records every open PR whose activity changed since it was last written, a
305// few per poll, so the ledger follows PRs nobody drills into.
306async function recordChangedPrs($: EngineInterface, repo: string, snapshot: Snapshot, limit = 3): Promise<void> {
307 let written = 0
308 for (const pr of snapshot.prs) {
309 if (written >= limit) break
310 if (recordedAt.get(pr.number) === pr.updatedAt) continue
311 const got = await fetchDetail($, repo, pr.number)
312 if (!got.detail) continue
313 const err = await recordDetail($, repo, got.detail)
314 if (err) {
315 $.ui.log(`pr-pulse: ledger write failed: ${err}`, { to: 'debug' })
316 return
317 }
318 written += 1
319 const selected = await read($, selectedAtom)
320 if (selected === pr.number) await update($, detailAtom, () => got.detail)
321 }
322 if (written > 0) await judgePairs($, repo, 25)
323}
324
325async function refreshDetail($: EngineInterface, number: number): Promise<void> {
326 const repo = await read($, repoAtom)
327 if (!repo) return
328 const got = await fetchDetail($, repo, number)
329 if (!got.detail) {
330 await update($, errorAtom, () => got.error)
331 return
332 }
333 const stillSelected = (await read($, selectedAtom)) === number
334 if (stillSelected) await update($, detailAtom, () => got.detail)
335 await update($, errorAtom, () => null)
336 const err = await recordDetail($, repo, got.detail)
337 if (err) $.ui.log(`pr-pulse: ledger write failed: ${err}`, { to: 'debug' })
338}
339
340async function noteSettled($: EngineInterface, repo: string, number: number): Promise<void> {
341 const ran = await gh($, ['pr', 'view', String(number), '--repo', repo, '--json', 'number,title,url,state,mergedAt,closedAt,mergedBy,headRefName'], 20_000)
342 if (!ran.ok) return
343 let raw: { number: number; title: string; url: string; state: string; mergedAt: string | null; closedAt: string | null; mergedBy: { login?: string } | null; headRefName: string }
344 try {
345 raw = JSON.parse(ran.out)
346 } catch {
347 return
348 }
349 if (raw.state === 'OPEN') return
350 const entry: SettledPr = {
351 number: raw.number,
352 title: raw.title,
353 url: raw.url,
354 state: raw.state === 'MERGED' ? 'MERGED' : 'CLOSED',
355 at: raw.mergedAt ?? raw.closedAt,
356 by: raw.mergedBy?.login ?? null,
357 headRef: raw.headRefName,
358 branchDeleted: await branchIsGone($, repo, raw.headRefName),
359 seenAt: await $.clock.now(),
360 }
361 const list = await update($, settledAtom, prior => [entry, ...prior.filter(s => s.number !== entry.number)].slice(0, 10))
362 await $.store.set(`settled:${repo}`, list)
363 if (settings.toasts) {
364 const branch = entry.branchDeleted === true ? ', branch deleted' : entry.branchDeleted === false ? `, branch ${entry.headRef} still exists` : ''
365 $.ui.toast(`PR #${entry.number} ${entry.state.toLowerCase()}${entry.by ? ` by ${entry.by}` : ''}${branch}`)
366 }
367 // The final word on the PR's threads goes into the ledger.
368 const got = await fetchDetail($, repo, number)
369 if (got.detail) await recordDetail($, repo, got.detail)
370 const selected = await read($, selectedAtom)
371 if (selected === number) {
372 await update($, selectedAtom, () => null)
373 await update($, detailAtom, () => null)
374 }
375}
376
377async function branchIsGone($: EngineInterface, repo: string, branch: string): Promise<boolean | null> {
378 const ran = await gh($, ['api', `repos/${repo}/branches/${encodeURIComponent(branch)}`, '--silent'], 20_000)
379 if (ran.ok) return false
380 return /404|Not Found/i.test(ran.err) ? true : null
381}
382
383async function recheckBranches($: EngineInterface, repo: string): Promise<void> {
384 const settled = await read($, settledAtom)
385 const pending = settled.filter(s => s.branchDeleted !== true)
386 if (pending.length === 0) return
387 let changed = false
388 const next: SettledPr[] = []
389 for (const s of settled) {
390 if (s.branchDeleted === true) {
391 next.push(s)
392 continue
393 }
394 const gone = await branchIsGone($, repo, s.headRef)
395 if (gone !== s.branchDeleted) {
396 changed = true
397 if (gone === true && settings.toasts) $.ui.toast(`#${s.number}: branch ${s.headRef} deleted`)
398 }
399 next.push({ ...s, branchDeleted: gone })
400 }
401 if (changed) {
402 await update($, settledAtom, () => next)
403 await $.store.set(`settled:${repo}`, next)
404 }
405}
406
407async function refreshAll($: EngineInterface): Promise<void> {
408 if (isFetching) return
409 isFetching = true
410 await update($, loadingAtom, () => true)
411 try {
412 const snapshot = await refreshList($)
413 const selected = await read($, selectedAtom)
414 if (selected !== null) await refreshDetail($, selected)
415 const repo = await read($, repoAtom)
416 if (snapshot && repo) await recordChangedPrs($, repo, snapshot)
417 if (snapshot && settings.autoOpen && !hasOfferedPane && snapshot.prs.length > 0) {
418 hasOfferedPane = true
419 const isUp = (await $.ui.panes()).some(p => p.id === PANE)
420 if (!isUp) void $.ui.open({ id: PANE, title: 'PRs' })
421 }
422 } finally {
423 isFetching = false
424 await update($, loadingAtom, () => false)
425 }
426}
427
428async function selectPr($: EngineInterface, number: number | null): Promise<void> {
429 await update($, modeAtom, () => 'prs')
430 await update($, selectedAtom, () => number)
431 if (number === null) {
432 await update($, detailAtom, () => null)
433 return
434 }
435 const detail = await read($, detailAtom)
436 if (detail?.number !== number) await update($, detailAtom, () => null)
437 await update($, loadingAtom, () => true)
438 try {
439 await refreshDetail($, number)
440 } finally {
441 await update($, loadingAtom, () => false)
442 }
443}
444
445async function setRepo($: EngineInterface, repo: string | null): Promise<void> {
446 await update($, repoAtom, () => repo)
447 await update($, snapshotAtom, () => null)
448 await update($, detailAtom, () => null)
449 await update($, selectedAtom, () => null)
450 await update($, errorAtom, () => null)
451 await update($, statsAtom, () => null)
452 recordedAt.clear()
453 const stored = repo ? await $.store.get(`settled:${repo}`) : undefined
454 await update($, settledAtom, () => (Array.isArray(stored) ? (stored as SettledPr[]) : []))
455 $.ui.status(undefined)
456}
457
458function startPolling($: EngineInterface): void {
459 if (isPolling) return
460 isPolling = true
461 $.clock.every(settings.pollMs, () => refreshAll($))
462}
463
464// Finds the repo when none is known yet (a session that started outside a
465// checkout, or a test), and starts the poll once one is.
466async function ensureRepo($: EngineInterface): Promise<string | null> {
467 const known = await read($, repoAtom)
468 if (known) {
469 startPolling($)
470 return known
471 }
472 const found = await detectRepo($)
473 if (found) {
474 await setRepo($, found)
475 startPolling($)
476 }
477 return found
478}
479
480async function showStats($: EngineInterface, repo: string | null, sinceDays: number | null): Promise<string> {
481 const got = await computeStats($, repo, sinceDays)
482 await update($, modeAtom, () => 'stats')
483 await $.ui.open({ id: PANE, title: 'PRs', focus: true })
484 if (!got.report) return got.error ?? 'stats failed'
485 return formatStats(got.report)
486}
487
488function readSettings(options: Readonly<Record<string, unknown>>): Settings {
489 const seconds = Number(options['pollSeconds'] ?? 45)
490 return {
491 pollMs: Math.max(15, Number.isFinite(seconds) ? seconds : 45) * 1000,
492 autoOpen: options['autoOpen'] !== false,
493 statusLine: options['statusLine'] !== false,
494 toasts: options['toasts'] !== false,
495 dbPath: typeof options['dbPath'] === 'string' ? options['dbPath'] : '',
496 }
497}
498
499const USAGE = 'Usage: /prs · /prs N · /prs back · /prs refresh · /prs close · /prs repo owner/name · /prs stats [days|all] [all-repos] · /prs trend [weeks] · /prs backfill [N] · /prs judge [N] · /prs db'
500
501// ---------- hooks ----------
502
503export const register: Register = (on, options) => {
504 settings = readSettings(options)
505
506 on('session.start', async ($, e, next) => {
507 sessionCwd = e.cwd
508 await $.command.register({
509 name: 'prs',
510 description: 'Pull request pane and review-bot ledger: /prs, /prs N, /prs stats, /prs backfill, /prs judge, /prs repo owner/name',
511 argumentHint: '[N | back | refresh | close | stats [days] | trend [weeks] | backfill [N] | judge [N] | db | repo owner/name]',
512 })
513 hasOfferedPane = !e.isInteractive
514 const repo = await ensureRepo($)
515 if (repo) $.clock.after(200, () => refreshAll($))
516 return next(e)
517 })
518
519 on('command.run', { command: 'prs' }, async ($, e) => {
520 const args = e.args.trim()
521 const repoMatch = /^repo\s+([^/\s]+\/[^/\s]+)$/i.exec(args)
522 if (repoMatch && repoMatch[1]) {
523 await setRepo($, repoMatch[1])
524 startPolling($)
525 await $.ui.open({ id: PANE, title: 'PRs', focus: true })
526 void refreshAll($)
527 return { text: `Watching ${repoMatch[1]}.` }
528 }
529 const repo = await ensureRepo($)
530 if (/^close$/i.test(args)) {
531 await $.ui.close({ id: PANE })
532 return { text: 'PR pane closed.' }
533 }
534 if (/^(back|list|prs)$/i.test(args)) {
535 await selectPr($, null)
536 await $.ui.open({ id: PANE, title: 'PRs', focus: true })
537 return { text: 'Showing all open PRs.' }
538 }
539 if (/^refresh$/i.test(args)) {
540 await $.ui.open({ id: PANE, title: 'PRs' })
541 if (!repo) return { text: 'No GitHub repo detected here. Try /prs repo owner/name.' }
542 await refreshAll($)
543 const error = await read($, errorAtom)
544 return { text: error ?? 'PR status refreshed.' }
545 }
546 if (/^db$/i.test(args)) {
547 return { text: `Ledger: ${await dbPath($)} (open with: sqlite3 "${await dbPath($)}")` }
548 }
549 const stats = /^stats(?:\s+(\d+|all))?(?:\s+(all-repos))?$/i.exec(args)
550 if (stats) {
551 const days = stats[1] && stats[1] !== 'all' ? Number(stats[1]) : null
552 const scope = stats[2] ? null : repo
553 return { text: await showStats($, scope, days) }
554 }
555 const trend = /^trend(?:\s+(\d+))?(?:\s+(all-repos))?$/i.exec(args)
556 if (trend) {
557 const weeks = trend[1] ? Math.max(1, Math.min(52, Number(trend[1]))) : 12
558 const got = await computeStats($, trend[2] ? null : repo, null, weeks)
559 await update($, modeAtom, () => 'stats')
560 await $.ui.open({ id: PANE, title: 'PRs', focus: true })
561 return { text: got.report ? formatTrend(got.report) : got.error ?? 'trend failed' }
562 }
563 const back = /^backfill(?:\s+(\d+))?$/i.exec(args)
564 if (back) {
565 if (!repo) return { text: 'No GitHub repo detected here. Try /prs repo owner/name first.' }
566 if (isBackfilling) return { text: 'A backfill is already running; watch the pane for progress.' }
567 const count = back[1] ? Number(back[1]) : 30
568 await update($, modeAtom, () => 'stats')
569 await $.ui.open({ id: PANE, title: 'PRs' })
570 $.clock.after(10, () => backfill($, repo, count))
571 return { text: `Backfilling the last ${count} merged or closed PRs of ${repo} into the ledger in the background. /prs stats when it finishes.` }
572 }
573 const judge = /^judge(?:\s+(\d+))?$/i.exec(args)
574 if (judge) {
575 const result = await judgePairs($, repo, judge[1] ? Number(judge[1]) : 100)
576 if (result.error) return { text: result.error }
577 await computeStats($, repo, null)
578 return { text: `Judged ${result.judged} overlapping pair(s): ${result.same} report the same finding.` }
579 }
580 const number = /^#?(\d+)$/.exec(args)
581 if (number && number[1]) {
582 const n = Number(number[1])
583 await $.ui.open({ id: PANE, title: 'PRs', focus: true })
584 void selectPr($, n)
585 return { text: `Showing PR #${n}.` }
586 }
587 if (args.length > 0) return { text: USAGE }
588 await update($, modeAtom, () => 'prs')
589 const opened = await $.ui.open({ id: PANE, title: 'PRs', focus: true })
590 void refreshAll($)
591 if (!opened.isPlaced) return { text: `PR pane opened but not drawn yet: ${opened.reason}` }
592 return { text: 'PR pane opened.' }
593 })
594
595 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
596 const ran = await next(e)
597 if (/\b(git\s+push|gh\s+pr\b|gh\s+api\b|git\s+merge|gh\s+run\b)/.test(e.command)) {
598 $.clock.after(6_000, () => refreshAll($))
599 }
600 return ran
601 }).catch(($, e, next) => next(e))
602
603 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
604 const { Box, Text, Button, Link } = $.ui.resolve(e)
605 const table = { Box, Text, Button, Link }
606 const [repo, snapshot, detail, selected, error, isLoading, settled, mode, stats, progress] = await Promise.all([
607 read($, repoAtom),
608 read($, snapshotAtom),
609 read($, detailAtom),
610 read($, selectedAtom),
611 read($, errorAtom),
612 read($, loadingAtom),
613 read($, settledAtom),
614 read($, modeAtom),
615 read($, statsAtom),
616 read($, backfillAtom),
617 ])
618 const now = await $.clock.now()
619 const width = e.props.bodyColumns
620 const actions = {
621 select: (number: number) => {
622 void selectPr($, number)
623 },
624 back: () => {
625 void selectPr($, null)
626 },
627 refresh: () => {
628 void refreshAll($)
629 },
630 stats: () => {
631 void showStats($, repo, null)
632 },
633 judge: () => {
634 void judgePairs($, repo, 100).then(() => computeStats($, repo, null))
635 },
636 }
637
638 if (mode === 'stats') {
639 return renderStats(table, { repo, stats, progress, error, now, width }, actions)
640 }
641 if (selected !== null && detail && detail.number === selected) {
642 return renderDetail(table, { detail, rules: snapshot?.rules ?? null, error, isLoading, now, width }, actions)
643 }
644 if (selected !== null) {
645 return (
646 <Box flexDirection="column">
647 <Button key="back" hotkey="b" plain onPress={actions.back}>
648 ‹ back
649 </Button>
650 <Text dimColor>Loading PR #{selected}…</Text>
651 {error ? <Text color="error" wrap="truncate-end">{error}</Text> : null}
652 </Box>
653 )
654 }
655 return renderList(table, { repo, snapshot, settled, error, isLoading, now, width, selected, progress }, actions)
656 })
657}
658hooks/db.ts 545 lines1// Pure SQL builders and the analysis rules for the review-bot ledger kept in
2// SQLite (driven through the sqlite3 CLI from register.tsx). No `$` here.
3
4import type { BotStats, CommitPatch, Hunk, Outcome, PairStats, PrDetail, StatsReport, Thread, TrendBot, TrendWeek } from '../types'
5import { displayName } from './github.ts'
6
7export const SCHEMA = `
8CREATE TABLE IF NOT EXISTS prs (
9 repo TEXT NOT NULL, number INTEGER NOT NULL, title TEXT, author TEXT, state TEXT,
10 created_at TEXT, merged_at TEXT, closed_at TEXT, head_ref TEXT, base_ref TEXT,
11 additions INTEGER, deletions INTEGER, changed_files INTEGER, commit_count INTEGER,
12 agents TEXT, recorded_at TEXT, PRIMARY KEY (repo, number));
13CREATE TABLE IF NOT EXISTS commits (
14 repo TEXT NOT NULL, number INTEGER NOT NULL, sha TEXT NOT NULL, committed_at TEXT, author TEXT,
15 co_authors TEXT, headline TEXT, files TEXT, hunks TEXT, PRIMARY KEY (repo, number, sha));
16CREATE TABLE IF NOT EXISTS threads (
17 id TEXT PRIMARY KEY, repo TEXT NOT NULL, number INTEGER NOT NULL, bot TEXT, opened_by TEXT,
18 opened_by_is_bot INTEGER, opened_at TEXT, path TEXT, line INTEGER, severity TEXT, title TEXT, body TEXT,
19 trigger_sha TEXT, trigger_at TEXT, response_seconds INTEGER, is_resolved INTEGER, resolved_by TEXT,
20 is_outdated INTEGER, replies INTEGER, last_reply_by TEXT, last_reply_at TEXT, last_reply_body TEXT,
21 addressed_by TEXT, addressed_touched_file INTEGER, outcome TEXT, url TEXT, updated_at TEXT,
22 kind TEXT DEFAULT 'thread', has_findings INTEGER DEFAULT 1, original_line INTEGER, addressed_touched_line INTEGER, near_lines_changed INTEGER);
23CREATE INDEX IF NOT EXISTS threads_repo_bot ON threads (repo, bot, opened_at);
24CREATE TABLE IF NOT EXISTS thread_pairs (
25 a TEXT NOT NULL, b TEXT NOT NULL, repo TEXT NOT NULL, number INTEGER NOT NULL, bot_a TEXT, bot_b TEXT,
26 same INTEGER, reason TEXT, judged_at TEXT, basis TEXT DEFAULT 'colocated', PRIMARY KEY (a, b));
27`
28
29// Columns added after the first release; each ALTER fails harmlessly on a
30// ledger that already has it, so these run without -bail.
31export const MIGRATIONS = [
32 "ALTER TABLE threads ADD COLUMN kind TEXT DEFAULT 'thread';",
33 'ALTER TABLE threads ADD COLUMN has_findings INTEGER DEFAULT 1;',
34 'ALTER TABLE threads ADD COLUMN original_line INTEGER;',
35 'ALTER TABLE threads ADD COLUMN addressed_touched_line INTEGER;',
36 'ALTER TABLE commits ADD COLUMN hunks TEXT;',
37 "ALTER TABLE thread_pairs ADD COLUMN basis TEXT DEFAULT 'colocated';",
38 'ALTER TABLE threads ADD COLUMN near_lines_changed INTEGER;',
39]
40
41export function q(value: string | number | boolean | null | undefined): string {
42 if (value === null || value === undefined) return 'NULL'
43 if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL'
44 if (typeof value === 'boolean') return value ? '1' : '0'
45 return `'${value.replace(/\0/g, '').replace(/'/g, "''")}'`
46}
47
48const POSITIVE = /\b(fixed|done|addressed|applied|resolved|good catch|updated|implemented|added|removed|changed|thanks?,? (fixed|done))\b/i
49const NEGATIVE = /\b(won'?t fix|wontfix|not applicable|false positive|intentional|by design|as designed|not an issue|no change needed|out of scope|declin\w*|ignor\w*|skip\w*|n\/a|incorrect|wrong|disagree|not needed|already handled|working as intended)\b/i
50
51// ---------- patches ----------
52
53// Reads `@@ -a,b +c,d @@` headers out of a unified diff, and counts the
54// added and removed lines inside each hunk.
55export function parseHunks(patch: string | null | undefined): Hunk[] {
56 if (!patch) return []
57 const out: Hunk[] = []
58 const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
59 for (const line of patch.split('\n')) {
60 const m = header.exec(line)
61 if (m) {
62 out.push([Number(m[1]), m[2] === undefined ? 1 : Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4]), 0, 0])
63 continue
64 }
65 const current = out[out.length - 1]
66 if (!current) continue
67 if (line.startsWith('+') && !line.startsWith('+++')) current[4] = (current[4] ?? 0) + 1
68 else if (line.startsWith('-') && !line.startsWith('---')) current[5] = (current[5] ?? 0) + 1
69 }
70 return out
71}
72
73// Whether every hunk of a stored patch carries add/remove counts (rows written
74// before they were kept do not, and are fetched again once).
75export function hasLineCounts(patch: CommitPatch): boolean {
76 return Object.values(patch.hunks).every(hunks => hunks.every(h => h.length >= 6))
77}
78
79export function patchFromRest(json: string): CommitPatch | null {
80 let parsed: unknown
81 try {
82 parsed = JSON.parse(json)
83 } catch {
84 return null
85 }
86 if (!Array.isArray(parsed)) return null
87 const files: string[] = []
88 const hunks: Record<string, Hunk[]> = {}
89 for (const entry of parsed as { filename?: string; patch?: string | null }[]) {
90 if (!entry.filename) continue
91 files.push(entry.filename)
92 hunks[entry.filename] = parseHunks(entry.patch)
93 }
94 return { files, hunks }
95}
96
97// Whether a commit's change to the thread's file reaches the commented line:
98// the old side of a hunk covers the line as it stood when the comment was made
99// (`originalLine`), or the new side covers where GitHub now places it. A few
100// lines of slack, since fixes often land just above or below the flagged line.
101export function touchesLine(patch: CommitPatch | null | undefined, thread: Pick<Thread, 'path' | 'line' | 'originalLine'>, slack = 3): boolean | null {
102 if (!patch) return null
103 const hunks = patch.hunks[thread.path]
104 if (!hunks) return false
105 if (hunks.length === 0) return null // GitHub sends no patch for binary or very large files
106 const lines = [thread.originalLine, thread.line].filter((n): n is number => n !== null)
107 if (lines.length === 0) return null
108 return hunks.some(([oldStart, oldLines, newStart, newLines]) =>
109 lines.some(n => (n >= oldStart - slack && n <= oldStart + Math.max(oldLines, 1) + slack) || (n >= newStart - slack && n <= newStart + Math.max(newLines, 1) + slack)),
110 )
111}
112
113function hunkReaches(h: Hunk, lines: number[], slack: number): boolean {
114 const [oldStart, oldLines, newStart, newLines] = h
115 return lines.some(n => (n >= oldStart - slack && n <= oldStart + Math.max(oldLines, 1) + slack) || (n >= newStart - slack && n <= newStart + Math.max(newLines, 1) + slack))
116}
117
118// How many lines the addressing commit added plus removed within reach of the
119// finding: a measure of how much code moved because of it. Null when the
120// patch, the file's hunks or their counts are unknown; 0 when the file was
121// touched elsewhere only.
122export function nearLinesChanged(patch: CommitPatch | null | undefined, thread: Pick<Thread, 'path' | 'line' | 'originalLine'>, slack = 3): number | null {
123 if (!patch) return null
124 const hunks = patch.hunks[thread.path]
125 if (!hunks) return 0
126 if (hunks.length === 0) return null
127 const lines = [thread.originalLine, thread.line].filter((n): n is number => n !== null)
128 if (lines.length === 0) return null
129 let total = 0
130 for (const h of hunks) {
131 if (!hunkReaches(h, lines, slack)) continue
132 if (h[4] === undefined || h[5] === undefined) return null
133 total += h[4] + h[5]
134 }
135 return total
136}
137
138// ---------- outcomes ----------
139
140// How a thread ended: `used` when the code moved under it (GitHub marked the
141// diff outdated, the addressing commit's hunk reaches the line, or the reply
142// says so), `dismissed` when the reply says no and nothing moved, `resolved`
143// when it closed with no evidence either way, `open` while it stands. A
144// PR-level comment has no resolution: `comment`.
145export function outcomeOf(thread: Thread, touchedFile: boolean | null, touchedLine: boolean | null = null): Outcome {
146 if (thread.kind === 'comment') return 'comment'
147 if (!thread.isResolved) return 'open'
148 const reply = thread.lastReplyBody ?? ''
149 if (NEGATIVE.test(reply) && touchedLine !== true && !thread.isOutdated) return 'dismissed'
150 if (thread.isOutdated || touchedLine === true || POSITIVE.test(reply)) return 'used'
151 // File touched but not the line, or nothing known: no claim either way.
152 void touchedFile
153 return 'resolved'
154}
155
156export function botOf(thread: Thread): string | null {
157 return thread.openedByIsBot ? displayName(thread.openedBy) : null
158}
159
160export function recordSql(repo: string, pr: PrDetail, patches: ReadonlyMap<string, CommitPatch | null>, nowIso: string): string {
161 const lines: string[] = ['BEGIN;']
162 lines.push(
163 `INSERT INTO prs (repo, number, title, author, state, created_at, merged_at, closed_at, head_ref, base_ref, additions, deletions, changed_files, commit_count, agents, recorded_at) VALUES (` +
164 [repo, pr.number, pr.title, pr.author, pr.state, pr.createdAt, pr.mergedAt, pr.closedAt, pr.headRef, pr.baseRef, pr.additions, pr.deletions, pr.changedFiles, pr.commitCount, JSON.stringify(pr.agents), nowIso].map(q).join(', ') +
165 `) ON CONFLICT (repo, number) DO UPDATE SET title = excluded.title, state = excluded.state, merged_at = excluded.merged_at, closed_at = excluded.closed_at, additions = excluded.additions, deletions = excluded.deletions, changed_files = excluded.changed_files, commit_count = excluded.commit_count, agents = excluded.agents, recorded_at = excluded.recorded_at;`,
166 )
167 for (const c of pr.commits) {
168 const patch = patches.get(c.sha)
169 lines.push(
170 `INSERT INTO commits (repo, number, sha, committed_at, author, co_authors, headline, files, hunks) VALUES (` +
171 [repo, pr.number, c.sha, c.at, c.author, JSON.stringify(c.coAuthors), c.headline, patch ? JSON.stringify(patch.files) : null, patch ? JSON.stringify(patch.hunks) : null].map(q).join(', ') +
172 `) ON CONFLICT (repo, number, sha) DO UPDATE SET files = COALESCE(excluded.files, commits.files), hunks = COALESCE(excluded.hunks, commits.hunks);`,
173 )
174 }
175 for (const t of pr.threads) {
176 const patch = t.addressedBy ? patches.get(t.addressedBy) : undefined
177 const touchedFile = patch === undefined || patch === null ? null : patch.files.includes(t.path)
178 const touchedLine = touchesLine(patch, t)
179 const near = nearLinesChanged(patch, t)
180 const outcome = outcomeOf(t, touchedFile, touchedLine)
181 lines.push(
182 `INSERT INTO threads (id, repo, number, bot, opened_by, opened_by_is_bot, opened_at, path, line, severity, title, body, trigger_sha, trigger_at, response_seconds, is_resolved, resolved_by, is_outdated, replies, last_reply_by, last_reply_at, last_reply_body, addressed_by, addressed_touched_file, outcome, url, updated_at, kind, has_findings, original_line, addressed_touched_line, near_lines_changed) VALUES (` +
183 [t.id, repo, pr.number, botOf(t), t.openedBy, t.openedByIsBot, t.openedAt, t.path, t.line, t.severity, t.title, t.body, t.triggerSha, t.triggerAt, t.responseSeconds, t.isResolved, t.resolvedBy, t.isOutdated, t.replies, t.lastReplyBy, t.lastReplyAt, t.lastReplyBody, t.addressedBy, touchedFile, outcome, t.url, nowIso, t.kind, t.hasFindings, t.originalLine, touchedLine, near].map(q).join(', ') +
184 `) ON CONFLICT (id) DO UPDATE SET is_resolved = excluded.is_resolved, resolved_by = excluded.resolved_by, is_outdated = excluded.is_outdated, replies = excluded.replies, last_reply_by = excluded.last_reply_by, last_reply_at = excluded.last_reply_at, last_reply_body = excluded.last_reply_body, addressed_by = excluded.addressed_by, addressed_touched_file = COALESCE(excluded.addressed_touched_file, threads.addressed_touched_file), addressed_touched_line = COALESCE(excluded.addressed_touched_line, threads.addressed_touched_line), near_lines_changed = COALESCE(excluded.near_lines_changed, threads.near_lines_changed), outcome = excluded.outcome, updated_at = excluded.updated_at, kind = excluded.kind, has_findings = excluded.has_findings, original_line = excluded.original_line, title = excluded.title, severity = excluded.severity, body = excluded.body;`,
185 )
186 }
187 for (const { a, b, basis } of candidatePairs(pr.threads)) {
188 lines.push(
189 `INSERT OR IGNORE INTO thread_pairs (a, b, repo, number, bot_a, bot_b, same, reason, judged_at, basis) VALUES (` +
190 [a.id, b.id, repo, pr.number, botOf(a), botOf(b), null, null, null, basis].map(q).join(', ') +
191 `);`,
192 )
193 }
194 lines.push('COMMIT;')
195 return lines.join('\n')
196}
197
198// ---------- duplicate candidates ----------
199
200const STOP = new Set('the this that with from into when then than they them their there here have has had been being were was will would should could must also only just very more most some such each other same about after before while where which what into onto over under between within without because since during against through across until unless using used use make makes made like need needs needed'.split(' '))
201
202export function keywords(text: string): Set<string> {
203 const out = new Set<string>()
204 for (const raw of text.toLowerCase().replace(/<!--[\s\S]*?-->/g, ' ').replace(/https?:\/\/\S+/g, ' ').replace(/[^a-z0-9_./-]+/g, ' ').split(' ')) {
205 const w = raw.replace(/^[./-]+|[./-]+$/g, '')
206 if (w.length < 4 || STOP.has(w) || /^\d+$/.test(w)) continue
207 out.add(w)
208 }
209 return out
210}
211
212export function similarity(a: string, b: string): number {
213 const ka = keywords(a)
214 const kb = keywords(b)
215 if (ka.size === 0 || kb.size === 0) return 0
216 let shared = 0
217 for (const w of ka) if (kb.has(w)) shared += 1
218 return shared / (ka.size + kb.size - shared)
219}
220
221export type Candidate = { a: Thread; b: Thread; basis: 'colocated' | 'similar'; score: number }
222
223// Two bots' findings may be the same: on the same file within a few lines
224// (`colocated`), or anywhere in the PR with enough words in common
225// (`similar`, including PR-level comments). The model settles each later.
226// Ordered by id so each pair is stored once; capped per PR to bound the calls.
227export function candidatePairs(threads: Thread[], lineWindow = 8, minSimilarity = 0.22, cap = 60): Candidate[] {
228 const bots = threads.filter(t => t.openedByIsBot && t.hasFindings)
229 const out: Candidate[] = []
230 for (let i = 0; i < bots.length; i++) {
231 for (let j = i + 1; j < bots.length; j++) {
232 const x = bots[i]!
233 const y = bots[j]!
234 if (botOf(x) === botOf(y)) continue
235 const [a, b] = x.id < y.id ? [x, y] : [y, x]
236 const sameFile = a.kind === 'thread' && b.kind === 'thread' && a.path === b.path
237 const near = sameFile && (a.line === null || b.line === null || Math.abs(a.line - b.line) <= lineWindow)
238 if (near) {
239 out.push({ a, b, basis: 'colocated', score: 1 })
240 continue
241 }
242 const score = similarity(`${a.title} ${a.body}`, `${b.title} ${b.body}`)
243 if (score >= minSimilarity) out.push({ a, b, basis: 'similar', score })
244 }
245 }
246 out.sort((p, r) => r.score - p.score)
247 return out.slice(0, cap)
248}
249
250export function judgePrompt(a: { path: string; line: number | null; body: string }, b: { path: string; line: number | null; body: string }): string {
251 const where = (x: { path: string; line: number | null }) => (x.path ? `${x.path}${x.line ? `:${x.line}` : ''}` : 'a PR-level comment')
252 return [
253 'Two automated code-review comments were left on the same pull request.',
254 'Do they report the same underlying issue (same defect or the same requested change), even if worded differently or placed on different lines?',
255 'A broad summary that merely mentions the area is NOT the same issue. Answer on the first line with exactly YES or NO, then one short sentence of reason.',
256 '',
257 `Comment A (${where(a)}):`,
258 a.body,
259 '',
260 `Comment B (${where(b)}):`,
261 b.body,
262 ].join('\n')
263}
264
265export function parseJudgement(text: string): { same: boolean; reason: string } | null {
266 const m = /^\s*\**\s*(YES|NO)\b\**[.:,\s-]*([\s\S]*)$/i.exec(text.trim())
267 if (!m || !m[1]) return null
268 return { same: m[1].toUpperCase() === 'YES', reason: (m[2] ?? '').split('\n')[0]?.trim().slice(0, 240) ?? '' }
269}
270
271export function pairUpdateSql(a: string, b: string, same: boolean, reason: string, nowIso: string): string {
272 return `UPDATE thread_pairs SET same = ${same ? 1 : 0}, reason = ${q(reason)}, judged_at = ${q(nowIso)} WHERE a = ${q(a)} AND b = ${q(b)};`
273}
274
275export function unjudgedPairsSql(repo: string | null, limit: number): string {
276 const scope = repo ? `AND p.repo = ${q(repo)}` : ''
277 return `SELECT p.a, p.b, ta.path AS path_a, ta.line AS line_a, ta.body AS body_a, tb.path AS path_b, tb.line AS line_b, tb.body AS body_b
278FROM thread_pairs p JOIN threads ta ON ta.id = p.a JOIN threads tb ON tb.id = p.b
279WHERE p.same IS NULL ${scope} ORDER BY p.number DESC LIMIT ${Math.max(1, Math.floor(limit))};`
280}
281
282export type UnjudgedRow = { a: string; b: string; path_a: string; line_a: number | null; body_a: string; path_b: string; line_b: number | null; body_b: string }
283
284export function commitFilesSql(repo: string, number: number): string {
285 return `SELECT sha, files, hunks FROM commits WHERE repo = ${q(repo)} AND number = ${q(number)} AND files IS NOT NULL;`
286}
287
288// ---------- stats ----------
289
290export type StatRow = {
291 id: string
292 number: number
293 bot: string
294 kind: 'thread' | 'comment'
295 has_findings: number
296 severity: string | null
297 response_seconds: number | null
298 outcome: Outcome
299 is_outdated: number
300 addressed_touched_line: number | null
301 near_lines_changed: number | null
302 opened_at: string
303 dup: number
304}
305
306export type PairRow = { bot_a: string; bot_b: string; same: number | null; basis: string | null; n: number }
307
308// One row per confirmed duplicate: who said it first and who repeated it.
309export type SameRow = { bot_a: string; bot_b: string; at_a: string; at_b: string }
310
311export function statsSql(repo: string | null, sinceDays: number | null, nowIso: string): { threads: string; pairs: string; sames: string } {
312 const since = sinceDays === null ? null : new Date(Date.parse(nowIso) - sinceDays * 86_400_000).toISOString()
313 const where: string[] = ['opened_by_is_bot = 1', 'bot IS NOT NULL']
314 if (repo) where.push(`repo = ${q(repo)}`)
315 if (since) where.push(`opened_at >= ${q(since)}`)
316 const threads = `SELECT t.id, t.number, t.bot, COALESCE(t.kind, 'thread') AS kind, COALESCE(t.has_findings, 1) AS has_findings, t.severity, t.response_seconds, t.outcome, t.is_outdated, t.addressed_touched_line, t.near_lines_changed, t.opened_at,
317 (SELECT COUNT(*) FROM thread_pairs p WHERE p.same = 1 AND (p.a = t.id OR p.b = t.id)) AS dup
318FROM threads t WHERE ${where.join(' AND ')};`
319 const pwhere: string[] = []
320 if (repo) pwhere.push(`p.repo = ${q(repo)}`)
321 if (since) pwhere.push(`EXISTS (SELECT 1 FROM threads t WHERE t.id = p.a AND t.opened_at >= ${q(since)})`)
322 const pw = pwhere.length ? `WHERE ${pwhere.join(' AND ')}` : ''
323 const pairs = `SELECT p.bot_a, p.bot_b, p.same, COALESCE(p.basis, 'colocated') AS basis, COUNT(*) AS n FROM thread_pairs p ${pw} GROUP BY p.bot_a, p.bot_b, p.same, basis;`
324 const sames = `SELECT p.bot_a, p.bot_b, ta.opened_at AS at_a, tb.opened_at AS at_b FROM thread_pairs p JOIN threads ta ON ta.id = p.a JOIN threads tb ON tb.id = p.b ${pw ? `${pw} AND p.same = 1` : 'WHERE p.same = 1'};`
325 return { threads, pairs, sames }
326}
327
328function median(values: number[]): number | null {
329 if (values.length === 0) return null
330 const sorted = [...values].sort((a, b) => a - b)
331 const mid = Math.floor(sorted.length / 2)
332 return sorted.length % 2 ? sorted[mid]! : Math.round((sorted[mid - 1]! + sorted[mid]!) / 2)
333}
334
335// The Monday that starts the week an ISO timestamp falls in, as YYYY-MM-DD.
336export function weekStartOf(iso: string): string {
337 const d = new Date(iso)
338 if (Number.isNaN(d.getTime())) return '?'
339 const day = (d.getUTCDay() + 6) % 7
340 d.setUTCDate(d.getUTCDate() - day)
341 return d.toISOString().slice(0, 10)
342}
343
344export const SUBSTANTIVE_LINES = 5
345
346function medianNear(list: StatRow[]): number | null {
347 return median(list.filter(r => r.outcome === 'used' && r.near_lines_changed !== null).map(r => r.near_lines_changed as number))
348}
349
350export function aggregateStats(rows: StatRow[], pairRows: PairRow[], sameRows: SameRow[], repo: string | null, sinceDays: number | null, now: number, trendWeeks = 12): StatsReport {
351 const byBot = new Map<string, StatRow[]>()
352 for (const r of rows) {
353 const list = byBot.get(r.bot) ?? []
354 list.push(r)
355 byBot.set(r.bot, list)
356 }
357 const firsts = new Map<string, number>()
358 const repeats = new Map<string, number>()
359 for (const s of sameRows) {
360 const [first, repeat] = s.at_a <= s.at_b ? [s.bot_a, s.bot_b] : [s.bot_b, s.bot_a]
361 firsts.set(first, (firsts.get(first) ?? 0) + 1)
362 repeats.set(repeat, (repeats.get(repeat) ?? 0) + 1)
363 }
364 const bots: BotStats[] = [...byBot.entries()]
365 .map(([bot, list]) => {
366 const threads = list.filter(r => r.kind !== 'comment')
367 const comments = list.filter(r => r.kind === 'comment')
368 const count = (o: Outcome) => threads.filter(r => r.outcome === o).length
369 const used = count('used')
370 const dismissed = count('dismissed')
371 const resolved = count('resolved')
372 const settled = used + dismissed + resolved
373 const severities: Record<string, number> = {}
374 for (const r of threads) {
375 const key = r.severity ?? 'unrated'
376 severities[key] = (severities[key] ?? 0) + 1
377 }
378 const findings = threads.length + comments.filter(c => c.has_findings).length
379 const duplicated = list.filter(r => r.dup > 0 && (r.kind !== 'comment' || r.has_findings)).length
380 const nearKnownRows = threads.filter(r => r.outcome === 'used' && r.near_lines_changed !== null)
381 return {
382 medianNearLines: medianNear(threads),
383 nearKnown: nearKnownRows.length,
384 substantive: nearKnownRows.filter(r => (r.near_lines_changed ?? 0) >= SUBSTANTIVE_LINES).length,
385 bot,
386 findings,
387 threads: threads.length,
388 comments: comments.length,
389 commentsWithFindings: comments.filter(c => c.has_findings).length,
390 prs: new Set(list.map(r => r.number)).size,
391 used,
392 codeVerified: threads.filter(r => r.outcome === 'used' && (r.is_outdated === 1 || r.addressed_touched_line === 1)).length,
393 dismissed,
394 resolved,
395 open: count('open'),
396 usedRate: settled ? used / settled : null,
397 // Inline threads only, so a bot's quick status posts do not flatter it;
398 // a bot that only comments at PR level is measured on those.
399 medianResponseSeconds: median((threads.length ? threads : comments).map(r => r.response_seconds).filter((v): v is number => v !== null)),
400 duplicated,
401 unique: findings - duplicated,
402 firsts: firsts.get(bot) ?? 0,
403 repeats: repeats.get(bot) ?? 0,
404 severities,
405 }
406 })
407 .sort((a, b) => b.findings - a.findings)
408
409 const pairMap = new Map<string, PairStats>()
410 for (const p of pairRows) {
411 const [botA, botB] = [p.bot_a, p.bot_b].sort()
412 const key = `${botA}|${botB}`
413 const entry = pairMap.get(key) ?? { botA: botA!, botB: botB!, same: 0, judged: 0, unjudged: 0, colocated: 0, similar: 0 }
414 if (p.same === null) entry.unjudged += p.n
415 else {
416 entry.judged += p.n
417 if (p.same === 1) {
418 entry.same += p.n
419 if (p.basis === 'similar') entry.similar += p.n
420 else entry.colocated += p.n
421 }
422 }
423 pairMap.set(key, entry)
424 }
425 const pairs = [...pairMap.values()].sort((a, b) => b.same - a.same)
426
427 // Weekly trend over the counted findings: who found how much, how much of
428 // it was used, how often it repeated another bot, how much code it moved.
429 const repeatIds = new Set<string>()
430 void repeatIds
431 const byWeek = new Map<string, StatRow[]>()
432 for (const r of rows) {
433 if (r.kind === 'comment' && !r.has_findings) continue
434 const week = weekStartOf(r.opened_at)
435 const list = byWeek.get(week) ?? []
436 list.push(r)
437 byWeek.set(week, list)
438 }
439 const trend: TrendWeek[] = [...byWeek.entries()]
440 .sort((a, b) => (a[0] < b[0] ? -1 : 1))
441 .slice(-trendWeeks)
442 .map(([weekStart, list]) => {
443 const perBot: Record<string, TrendBot> = {}
444 for (const r of list) {
445 const entry = perBot[r.bot] ?? { findings: 0, used: 0, repeats: 0, medianNearLines: null }
446 entry.findings += 1
447 if (r.outcome === 'used') entry.used += 1
448 if (r.dup > 0) entry.repeats += 1
449 perBot[r.bot] = entry
450 }
451 for (const bot of Object.keys(perBot)) perBot[bot]!.medianNearLines = medianNear(list.filter(r => r.bot === bot))
452 return { weekStart, prs: new Set(list.map(r => r.number)).size, bots: perBot }
453 })
454
455 return {
456 trend,
457 repo,
458 sinceDays,
459 generatedAt: now,
460 totalThreads: rows.filter(r => r.kind !== 'comment' || r.has_findings).length,
461 totalPrs: new Set(rows.map(r => r.number)).size,
462 bots,
463 pairs,
464 unjudgedPairs: pairs.reduce((n, p) => n + p.unjudged, 0),
465 }
466}
467
468export function fmtSeconds(s: number | null): string {
469 if (s === null) return '–'
470 if (s < 90) return `${s}s`
471 if (s < 5400) return `${Math.round(s / 60)}m`
472 return `${(s / 3600).toFixed(1)}h`
473}
474
475export function pct(n: number | null): string {
476 return n === null ? '–' : `${Math.round(n * 100)}%`
477}
478
479export function formatStats(r: StatsReport): string {
480 const scope = `${r.repo ?? 'all repos'}${r.sinceDays ? `, last ${r.sinceDays} days` : ''}`
481 if (r.bots.length === 0) return `No bot review findings recorded yet for ${scope}. Open a PR in the pane or run /prs backfill 30.`
482 const lines = [`Review bots on ${scope}: ${r.totalThreads} findings across ${r.totalPrs} PRs`, '']
483 lines.push('bot findings threads PR-comments PRs used code-verified dismissed unknown open used% median response unique duplicated first repeat lines~ ≥5 lines')
484 for (const b of r.bots) {
485 lines.push(
486 [
487 b.bot.padEnd(18),
488 String(b.findings).padStart(8),
489 String(b.threads).padStart(8),
490 `${b.commentsWithFindings}/${b.comments}`.padStart(12),
491 String(b.prs).padStart(4),
492 String(b.used).padStart(5),
493 String(b.codeVerified).padStart(14),
494 String(b.dismissed).padStart(10),
495 String(b.resolved).padStart(8),
496 String(b.open).padStart(5),
497 pct(b.usedRate).padStart(6),
498 fmtSeconds(b.medianResponseSeconds).padStart(16),
499 String(b.unique).padStart(7),
500 String(b.duplicated).padStart(11),
501 String(b.firsts).padStart(6),
502 String(b.repeats).padStart(7),
503 (b.medianNearLines === null ? '–' : String(b.medianNearLines)).padStart(7),
504 (b.nearKnown ? `${b.substantive}/${b.nearKnown}` : '–').padStart(9),
505 ].join(' '),
506 )
507 }
508 lines.push('')
509 lines.push('findings = inline review threads + PR-level comments that report something (PR-comments shows reporting/total).')
510 lines.push('used = resolved and the code moved: GitHub marked the diff outdated, the addressing commit touched the flagged line, or the reply says fixed.')
511 lines.push('code-verified = used with code evidence (outdated diff or hunk on the line), not just a reply. dismissed = reply says no, nothing moved. unknown = resolved without evidence.')
512 lines.push('first/repeat = of findings the model judged the same as another bot\'s, how many this bot raised first vs repeated after another bot. median response = push to inline comment.')
513 lines.push(`lines~ = median lines added+removed by the addressing commit within 3 lines of a used finding; ≥5 lines = how many of those used findings moved at least ${SUBSTANTIVE_LINES} lines (a proxy for substance over nits).`)
514 if (r.pairs.length) {
515 lines.push('', 'overlap between bots (same finding, judged by the model; co-located = same file within 8 lines, similar = wording match anywhere in the PR):')
516 for (const p of r.pairs) {
517 lines.push(` ${p.botA} × ${p.botB}: ${p.same} same of ${p.judged} judged (${p.colocated} co-located, ${p.similar} similar)${p.unjudged ? `, ${p.unjudged} not judged yet` : ''}`)
518 }
519 if (r.unjudgedPairs) lines.push(` run /prs judge to settle the ${r.unjudgedPairs} unjudged pair(s)`)
520 }
521 return lines.join('\n')
522}
523
524export function formatTrend(r: StatsReport): string {
525 if (r.trend.length === 0) return 'No findings on record yet for a trend.'
526 const bots = [...new Set(r.trend.flatMap(w => Object.keys(w.bots)))].sort()
527 const scope = `${r.repo ?? 'all repos'}${r.sinceDays ? `, last ${r.sinceDays} days` : ''}`
528 const lines = [`Weekly trend on ${scope} (week starting Monday; findings, used, repeats of another bot, median lines moved):`, '']
529 lines.push(['week ', 'PRs'.padStart(4), ...bots.map(b => b.padStart(24))].join(' '))
530 for (const w of r.trend) {
531 lines.push(
532 [
533 w.weekStart,
534 String(w.prs).padStart(4),
535 ...bots.map(b => {
536 const t = w.bots[b]
537 if (!t) return '–'.padStart(24)
538 return `${t.findings} found ${t.used} used ${t.repeats} rep ${t.medianNearLines === null ? '–' : t.medianNearLines}L`.padStart(24)
539 }),
540 ].join(' '),
541 )
542 }
543 return lines.join('\n')
544}
545hooks/github.ts 810 lines1// Pure helpers: GraphQL queries, parsers from GitHub's shapes into the mod's
2// types, and the heuristics that name bots, agents and severities. No `$` here.
3
4import type {
5 Activity,
6 Check,
7 CheckState,
8 Commit,
9 PrDetail,
10 PrSummary,
11 Reviewer,
12 RuleSummary,
13 Snapshot,
14 Thread,
15} from '../types'
16
17// ---------- queries ----------
18
19const PR_SUMMARY_FIELDS = `
20 number title isDraft state mergedAt closedAt mergeable mergeStateStatus reviewDecision headRefName baseRefName
21 updatedAt createdAt url additions deletions changedFiles
22 author { login __typename }
23 autoMergeRequest { enabledAt }
24 baseRef { branchProtectionRule {
25 requiresApprovingReviews requiredApprovingReviewCount requiresStatusChecks
26 requiredStatusCheckContexts requiresConversationResolution requiresCodeOwnerReviews
27 } }
28 reviewThreads(first: 100) { totalCount nodes { isResolved isOutdated } }
29 reviews(last: 60) { nodes { author { login __typename } state submittedAt } }
30 reviewRequests(first: 10) { nodes { requestedReviewer {
31 __typename ... on User { login } ... on Team { name } ... on Bot { login } } } }
32 commits(last: 1) { totalCount nodes { commit {
33 abbreviatedOid committedDate message
34 author { name user { login } }
35 statusCheckRollup { state contexts(first: 80) { totalCount nodes {
36 __typename
37 ... on CheckRun { name status conclusion detailsUrl
38 checkSuite { app { name } workflowRun { workflow { name } } } }
39 ... on StatusContext { context state targetUrl creator { login } }
40 } } }
41 } } }
42`
43
44export const LIST_QUERY = `
45query($owner: String!, $name: String!) {
46 repository(owner: $owner, name: $name) {
47 nameWithOwner
48 rulesets(first: 20) { nodes { name enforcement target
49 conditions { refName { include exclude } }
50 rules(first: 30) { nodes { type parameters { __typename
51 ... on PullRequestParameters { requiredApprovingReviewCount requireCodeOwnerReview requiredReviewThreadResolution }
52 ... on RequiredStatusChecksParameters { requiredStatusChecks { context } }
53 } } }
54 } }
55 pullRequests(states: OPEN, first: 20, orderBy: { field: UPDATED_AT, direction: DESC }) {
56 totalCount
57 nodes { ${PR_SUMMARY_FIELDS} }
58 }
59 }
60}`
61
62export const SETTLED_QUERY = `
63query($owner: String!, $name: String!, $count: Int!) {
64 repository(owner: $owner, name: $name) {
65 pullRequests(states: [MERGED, CLOSED], first: $count, orderBy: { field: UPDATED_AT, direction: DESC }) {
66 nodes { number state updatedAt }
67 }
68 }
69}`
70
71export const DETAIL_QUERY = `
72query($owner: String!, $name: String!, $number: Int!) {
73 repository(owner: $owner, name: $name) {
74 pullRequest(number: $number) {
75 ${PR_SUMMARY_FIELDS}
76 allCommits: commits(last: 60) { nodes { commit {
77 abbreviatedOid committedDate messageHeadline message
78 author { name user { login } }
79 statusCheckRollup { state }
80 } } }
81 allThreads: reviewThreads(first: 100) { nodes {
82 id isResolved isOutdated path line originalLine resolvedBy { login }
83 comments(first: 12) { totalCount nodes {
84 author { login __typename } body createdAt url commit { abbreviatedOid } } }
85 } }
86 issueComments: comments(last: 60) { nodes { id author { login __typename } body createdAt url } }
87 }
88 }
89}`
90
91// gojq filter handed to `gh api graphql --jq`: trims every comment body so a
92// PR with a hundred long bot comments stays well under the 4 MiB stdout cap.
93export const TRIM_BODIES = 'walk(if type == "object" and has("body") and (.body | type) == "string" then .body |= .[0:600] else . end)'
94
95// ---------- naming bots and agents ----------
96
97const BOT_NAMES: Record<string, string> = {
98 coderabbitai: 'CodeRabbit',
99 cursor: 'Cursor Bugbot',
100 'chatgpt-codex-connector': 'Codex',
101 claude: 'Claude Code',
102 copilot: 'Copilot',
103 'copilot-pull-request-reviewer': 'Copilot',
104 'copilot-swe-agent': 'Copilot agent',
105 'github-actions': 'GitHub Actions',
106 dependabot: 'Dependabot',
107 renovate: 'Renovate',
108 'gemini-code-assist': 'Gemini',
109 'sourcery-ai': 'Sourcery',
110 'greptile-apps': 'Greptile',
111 'devin-ai-integration': 'Devin',
112 'graphite-app': 'Graphite',
113 'sentry-io': 'Sentry',
114 codecov: 'Codecov',
115 'ellipsis-dev': 'Ellipsis',
116 'qodo-merge-pro': 'Qodo',
117 'sweep-ai': 'Sweep',
118}
119
120export function stripBot(login: string): string {
121 return login.replace(/\[bot\]$/, '')
122}
123
124export function isBotLogin(login: string, typename?: string | null): boolean {
125 if (typename === 'Bot') return true
126 const bare = stripBot(login).toLowerCase()
127 return /\[bot\]$/.test(login) || bare in BOT_NAMES
128}
129
130export function displayName(login: string, typename?: string | null): string {
131 const bare = stripBot(login)
132 const known = BOT_NAMES[bare.toLowerCase()]
133 if (known) return known
134 return isBotLogin(login, typename) ? `${bare} (bot)` : bare
135}
136
137const AGENT_WORDS = /claude|codex|copilot|cursor|gemini|devin|gpt|openai|anthropic|aider|sweep|jules|amp\b/i
138
139export function coAuthorsOf(message: string): string[] {
140 const names: string[] = []
141 for (const line of message.split('\n')) {
142 const m = /^co-authored-by:\s*(.+?)\s*<[^>]*>\s*$/i.exec(line.trim())
143 if (m && m[1]) names.push(m[1].trim())
144 }
145 return names
146}
147
148export function sessionUrlOf(message: string): string | null {
149 const m = /^claude-session:\s*(\S+)/im.exec(message)
150 return m && m[1] ? m[1] : null
151}
152
153export function isAgentName(name: string): boolean {
154 return AGENT_WORDS.test(name)
155}
156
157// ---------- severity and titles from review comments ----------
158
159export function severityOf(body: string): string | null {
160 const m =
161 /\bP([0-3])\b/.exec(body) ??
162 /\b(Critical|Major|Minor|Nitpick|Nit|Trivial)\b/i.exec(body) ??
163 /\b(High|Medium|Low)\s+Severity\b/i.exec(body) ??
164 /\b(blocking|blocker|must[- ]fix)\b/i.exec(body)
165 if (!m) return null
166 const raw = m[0]
167 if (/^P[0-3]$/.test(raw)) return raw
168 if (/severity/i.test(raw)) return raw.replace(/\s+severity/i, '').replace(/^\w/, c => c.toUpperCase())
169 return raw.replace(/^\w/, c => c.toUpperCase())
170}
171
172export function titleOf(body: string): string {
173 const cleaned = body
174 .replace(/<!--[\s\S]*?-->/g, ' ')
175 .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
176 .replace(/<[^>]+>/g, ' ')
177 let inFence = false
178 for (const line of cleaned.split('\n')) {
179 // Code blocks are the suggested fix, not the finding's title.
180 if (/^\s*(```|~~~)/.test(line)) {
181 inFence = !inFence
182 continue
183 }
184 if (inFence) continue
185 // A badge row (`**🎯 Correctness** | **🟡 Minor** | **⚡ Quick win**`) is
186 // metadata, not the finding: skip any line of short `|`-separated cells.
187 if (line.includes('|') && line.split('|').every(cell => cell.replace(/\*\*/g, '').trim().length <= 32)) continue
188 const t = line.replace(/^#+\s*/, '').replace(/\*\*/g, '').replace(/\s+/g, ' ').trim()
189 if (t.length < 4) continue
190 if (/^(P[0-3]|Critical|Major|Minor|High|Medium|Low)\b[\s|]*$/i.test(t)) continue
191 // Tool badges CodeRabbit and others prepend ("🔎 Supported by static analysis",
192 // "🛠️ Refactor suggestion"): a short line naming a tool, or an emoji and a few words.
193 if (t.length < 48 && /supported by static analysis|refactor suggestion|verification agent|potential issue/i.test(t)) continue
194 if (t.length < 48 && /^[\p{Extended_Pictographic}\uFE0F]+\s*[^\s\p{Extended_Pictographic}]\S*( \S+){0,3}$/u.test(t)) continue
195 return t.length > 140 ? `${t.slice(0, 137)}…` : t
196 }
197 return '(comment)'
198}
199
200// ---------- raw shapes ----------
201
202type RawActor = { login?: string | null; __typename?: string | null } | null
203type RawCheckNode = {
204 __typename: 'CheckRun' | 'StatusContext'
205 name?: string
206 status?: string
207 conclusion?: string | null
208 detailsUrl?: string | null
209 checkSuite?: { app?: { name?: string } | null; workflowRun?: { workflow?: { name?: string } | null } | null } | null
210 context?: string
211 state?: string
212 targetUrl?: string | null
213 creator?: RawActor
214}
215type RawCommit = {
216 abbreviatedOid: string
217 committedDate: string
218 messageHeadline?: string
219 message?: string
220 author?: { name?: string | null; user?: { login?: string } | null } | null
221 statusCheckRollup?: { state?: string; contexts?: { totalCount: number; nodes: RawCheckNode[] } } | null
222}
223type RawProtection = {
224 requiresApprovingReviews?: boolean
225 requiredApprovingReviewCount?: number | null
226 requiresStatusChecks?: boolean
227 requiredStatusCheckContexts?: string[] | null
228 requiresConversationResolution?: boolean
229 requiresCodeOwnerReviews?: boolean
230} | null
231type RawPr = {
232 number: number
233 title: string
234 isDraft: boolean
235 state?: 'OPEN' | 'MERGED' | 'CLOSED'
236 mergedAt?: string | null
237 closedAt?: string | null
238 mergeable: string
239 mergeStateStatus: string
240 reviewDecision: string | null
241 headRefName: string
242 baseRefName: string
243 updatedAt: string
244 createdAt: string
245 url: string
246 additions: number
247 deletions: number
248 changedFiles: number
249 author: RawActor
250 autoMergeRequest: { enabledAt?: string } | null
251 baseRef: { branchProtectionRule: RawProtection } | null
252 reviewThreads: { totalCount: number; nodes: { isResolved: boolean; isOutdated: boolean }[] }
253 reviews: { nodes: { author: RawActor; state: string; submittedAt: string }[] }
254 reviewRequests: { nodes: { requestedReviewer: { __typename: string; login?: string; name?: string } | null }[] }
255 commits: { totalCount: number; nodes: { commit: RawCommit }[] }
256 allCommits?: { nodes: { commit: RawCommit }[] }
257 allThreads?: {
258 nodes: {
259 id: string
260 isResolved: boolean
261 isOutdated: boolean
262 path: string
263 line: number | null
264 originalLine: number | null
265 resolvedBy: RawActor
266 comments: {
267 totalCount: number
268 nodes: { author: RawActor; body: string; createdAt: string; url?: string | null; commit?: { abbreviatedOid: string } | null }[]
269 }
270 }[]
271 }
272 issueComments?: { nodes: { id?: string; author: RawActor; body: string; createdAt: string; url?: string | null }[] }
273}
274type RawRuleset = {
275 name: string
276 enforcement: string
277 target: string
278 conditions?: { refName?: { include?: string[]; exclude?: string[] } | null } | null
279 rules: { nodes: { type: string; parameters: Record<string, unknown> | null }[] }
280}
281type RawListData = {
282 repository: {
283 nameWithOwner: string
284 rulesets?: { nodes: RawRuleset[] } | null
285 pullRequests: { totalCount: number; nodes: RawPr[] }
286 } | null
287}
288type RawDetailData = { repository: { pullRequest: RawPr | null } | null }
289
290// ---------- parsing ----------
291
292export function checkStateOf(node: RawCheckNode): CheckState {
293 if (node.__typename === 'StatusContext') {
294 const s = (node.state ?? '').toUpperCase()
295 if (s === 'SUCCESS') return 'success'
296 if (s === 'FAILURE' || s === 'ERROR') return 'failure'
297 if (s === 'PENDING' || s === 'EXPECTED') return 'pending'
298 return 'unknown'
299 }
300 const status = (node.status ?? '').toUpperCase()
301 if (status !== 'COMPLETED') return status === '' ? 'unknown' : 'pending'
302 const c = (node.conclusion ?? '').toUpperCase()
303 if (c === 'SUCCESS') return 'success'
304 if (c === 'FAILURE' || c === 'TIMED_OUT' || c === 'CANCELLED' || c === 'ACTION_REQUIRED' || c === 'STARTUP_FAILURE') return 'failure'
305 if (c === 'SKIPPED') return 'skipped'
306 if (c === 'NEUTRAL' || c === 'STALE') return 'neutral'
307 return 'unknown'
308}
309
310function rollupState(state: string | undefined): CheckState {
311 const s = (state ?? '').toUpperCase()
312 if (s === 'SUCCESS') return 'success'
313 if (s === 'FAILURE' || s === 'ERROR') return 'failure'
314 if (s === 'PENDING' || s === 'EXPECTED') return 'pending'
315 return 'unknown'
316}
317
318function parseChecks(commit: RawCommit | undefined, required: Set<string>): Check[] {
319 const nodes = commit?.statusCheckRollup?.contexts?.nodes ?? []
320 // The same check name may run several times (one per workflow trigger);
321 // keep the latest non-skipped one per (name, app), as GitHub's own list does.
322 const byKey = new Map<string, Check>()
323 for (const node of nodes) {
324 const name = node.__typename === 'CheckRun' ? node.name ?? '?' : node.context ?? '?'
325 const app =
326 node.__typename === 'CheckRun'
327 ? node.checkSuite?.app?.name ?? null
328 : node.creator?.login
329 ? displayName(node.creator.login, node.creator.__typename)
330 : null
331 const workflow = node.__typename === 'CheckRun' ? node.checkSuite?.workflowRun?.workflow?.name ?? null : null
332 const check: Check = {
333 name,
334 app,
335 workflow,
336 state: checkStateOf(node),
337 url: (node.__typename === 'CheckRun' ? node.detailsUrl : node.targetUrl) ?? null,
338 isRequired: required.has(name),
339 }
340 const key = `${name}|${app ?? ''}|${workflow ?? ''}`
341 const prior = byKey.get(key)
342 if (!prior || prior.state === 'skipped' || (prior.state === 'unknown' && check.state !== 'unknown')) {
343 byKey.set(key, check)
344 } else if (check.state === 'failure' || (check.state === 'pending' && prior.state !== 'failure')) {
345 byKey.set(key, check)
346 }
347 }
348 return [...byKey.values()]
349}
350
351function parseReviewers(pr: RawPr): Reviewer[] {
352 const latest = new Map<string, Reviewer>()
353 for (const r of pr.reviews.nodes) {
354 const login = r.author?.login ?? 'unknown'
355 const prior = latest.get(login)
356 // An APPROVED or CHANGES_REQUESTED outranks a later COMMENTED from the
357 // same reviewer, as GitHub's own reviewer list keeps it.
358 const keepPrior = prior && prior.state !== 'COMMENTED' && r.state === 'COMMENTED'
359 if (keepPrior) continue
360 latest.set(login, {
361 login,
362 name: displayName(login, r.author?.__typename),
363 isBot: isBotLogin(login, r.author?.__typename),
364 state: r.state,
365 at: r.submittedAt,
366 })
367 }
368 return [...latest.values()].sort((a, b) => (a.at < b.at ? 1 : -1))
369}
370
371export function parseRules(rulesets: RawRuleset[] | undefined, protection: RawProtection, baseRef: string): RuleSummary | null {
372 const out: RuleSummary = {
373 names: [],
374 requiredApprovals: 0,
375 requiredChecks: [],
376 requiresThreadResolution: false,
377 requiresCodeOwners: false,
378 noForcePush: false,
379 source: 'none',
380 }
381 let fromRuleset = false
382 for (const rs of rulesets ?? []) {
383 if (rs.enforcement !== 'ACTIVE' || rs.target !== 'BRANCH') continue
384 const include = rs.conditions?.refName?.include ?? []
385 const applies =
386 include.length === 0 ||
387 include.some(ref => ref === `refs/heads/${baseRef}` || ref === '~DEFAULT_BRANCH' || ref === '~ALL' || ref.endsWith('/*'))
388 if (!applies) continue
389 fromRuleset = true
390 out.names.push(rs.name)
391 for (const rule of rs.rules.nodes) {
392 const p = rule.parameters ?? {}
393 if (rule.type === 'PULL_REQUEST') {
394 out.requiredApprovals = Math.max(out.requiredApprovals, Number(p['requiredApprovingReviewCount'] ?? 0))
395 out.requiresThreadResolution = out.requiresThreadResolution || p['requiredReviewThreadResolution'] === true
396 out.requiresCodeOwners = out.requiresCodeOwners || p['requireCodeOwnerReview'] === true
397 } else if (rule.type === 'REQUIRED_STATUS_CHECKS') {
398 const checks = p['requiredStatusChecks']
399 if (Array.isArray(checks)) {
400 for (const c of checks as { context?: string }[]) if (c.context) out.requiredChecks.push(c.context)
401 }
402 } else if (rule.type === 'NON_FAST_FORWARD') {
403 out.noForcePush = true
404 }
405 }
406 }
407 let fromProtection = false
408 if (protection) {
409 fromProtection = true
410 if (protection.requiresApprovingReviews) {
411 out.requiredApprovals = Math.max(out.requiredApprovals, protection.requiredApprovingReviewCount ?? 1)
412 }
413 for (const c of protection.requiredStatusCheckContexts ?? []) out.requiredChecks.push(c)
414 out.requiresThreadResolution = out.requiresThreadResolution || protection.requiresConversationResolution === true
415 out.requiresCodeOwners = out.requiresCodeOwners || protection.requiresCodeOwnerReviews === true
416 }
417 out.requiredChecks = [...new Set(out.requiredChecks)]
418 out.source = fromRuleset && fromProtection ? 'both' : fromRuleset ? 'ruleset' : fromProtection ? 'protection' : 'none'
419 return out.source === 'none' ? null : out
420}
421
422function agentsOf(pr: RawPr, commits: RawCommit[]): string[] {
423 const found = new Set<string>()
424 for (const c of commits) {
425 for (const name of coAuthorsOf(c.message ?? '')) if (isAgentName(name)) found.add(name)
426 const login = c.author?.user?.login
427 if (login && isBotLogin(login)) found.add(displayName(login))
428 }
429 if (pr.author?.login && isBotLogin(pr.author.login, pr.author.__typename)) found.add(displayName(pr.author.login, pr.author.__typename))
430 return [...found]
431}
432
433export function parseSummary(pr: RawPr, rulesets: RawRuleset[] | undefined): PrSummary {
434 const head = pr.commits.nodes[0]?.commit
435 const rules = parseRules(rulesets, pr.baseRef?.branchProtectionRule ?? null, pr.baseRefName)
436 const required = new Set(rules?.requiredChecks ?? [])
437 const threads = pr.reviewThreads.nodes
438 return {
439 number: pr.number,
440 title: pr.title,
441 url: pr.url,
442 author: pr.author?.login ?? 'unknown',
443 authorIsBot: isBotLogin(pr.author?.login ?? '', pr.author?.__typename),
444 isDraft: pr.isDraft,
445 headRef: pr.headRefName,
446 baseRef: pr.baseRefName,
447 createdAt: pr.createdAt,
448 updatedAt: pr.updatedAt,
449 mergeable: pr.mergeable,
450 mergeState: pr.mergeStateStatus,
451 reviewDecision: pr.reviewDecision,
452 autoMerge: pr.autoMergeRequest !== null,
453 additions: pr.additions,
454 deletions: pr.deletions,
455 changedFiles: pr.changedFiles,
456 commitCount: pr.commits.totalCount,
457 headSha: head?.abbreviatedOid ?? '',
458 headAt: head?.committedDate ?? pr.updatedAt,
459 threadsTotal: pr.reviewThreads.totalCount,
460 threadsResolved: threads.filter(t => t.isResolved).length,
461 threadsOutdated: threads.filter(t => t.isOutdated).length,
462 checks: parseChecks(head, required),
463 reviewers: parseReviewers(pr),
464 requested: pr.reviewRequests.nodes.map(n => n.requestedReviewer?.login ?? n.requestedReviewer?.name ?? '').filter(Boolean),
465 agents: agentsOf(pr, head ? [head] : []),
466 }
467}
468
469export function parseList(json: string, fetchedAt: number): Snapshot {
470 const parsed = JSON.parse(json) as { data?: RawListData; errors?: { message: string }[] }
471 if (parsed.errors?.length) throw new Error(parsed.errors.map(e => e.message).join('; '))
472 const repo = parsed.data?.repository
473 if (!repo) throw new Error('repository not found or not readable')
474 const rulesets = repo.rulesets?.nodes
475 const prs = repo.pullRequests.nodes.map(pr => parseSummary(pr, rulesets))
476 const first = repo.pullRequests.nodes[0]
477 const rules = first ? parseRules(rulesets, first.baseRef?.branchProtectionRule ?? null, first.baseRefName) : parseRules(rulesets, null, 'main')
478 return { repo: repo.nameWithOwner, fetchedAt, totalCount: repo.pullRequests.totalCount, rules, prs }
479}
480
481export function parseDetail(json: string, fetchedAt: number, rules: RuleSummary | null): PrDetail {
482 const parsed = JSON.parse(json) as { data?: RawDetailData; errors?: { message: string }[] }
483 if (parsed.errors?.length) throw new Error(parsed.errors.map(e => e.message).join('; '))
484 const pr = parsed.data?.repository?.pullRequest
485 if (!pr) throw new Error('pull request not found')
486 const summary = parseSummary(pr, undefined)
487 if (rules) {
488 const required = new Set(rules.requiredChecks)
489 summary.checks = summary.checks.map(c => ({ ...c, isRequired: required.has(c.name) }))
490 }
491
492 const rawCommits = (pr.allCommits?.nodes ?? []).map(n => n.commit).sort((a, b) => (a.committedDate < b.committedDate ? -1 : 1))
493 const commits: Commit[] = rawCommits.map(c => ({
494 sha: c.abbreviatedOid,
495 headline: c.messageHeadline ?? (c.message ?? '').split('\n')[0] ?? '',
496 at: c.committedDate,
497 author: c.author?.user?.login ?? c.author?.name ?? 'unknown',
498 coAuthors: coAuthorsOf(c.message ?? ''),
499 sessionUrl: sessionUrlOf(c.message ?? ''),
500 checks: rollupState(c.statusCheckRollup?.state),
501 threadsAddressed: 0,
502 }))
503
504 const threads: Thread[] = (pr.allThreads?.nodes ?? []).map(t => {
505 const comments = t.comments.nodes
506 const first = comments[0]
507 const openedBy = first?.author?.login ?? 'unknown'
508 const replies = comments.slice(1)
509 const last = replies[replies.length - 1]
510 const openedAt = first?.createdAt ?? pr.createdAt
511 const after = commits.filter(c => c.at > openedAt)
512 // The first commit pushed after the thread opened is the one most likely
513 // to have addressed it; a resolved thread is credited to that commit.
514 const addressedBy = t.isResolved || replies.length > 0 ? after[0]?.sha ?? null : null
515 // What the bot was reacting to: the commit its comment is anchored on, or
516 // the PR's opening when that commit predates the PR.
517 const triggerSha = first?.commit?.abbreviatedOid ?? null
518 const triggerCommitAt = commits.find(c => c.sha === triggerSha)?.at ?? null
519 const triggerAt = triggerCommitAt && triggerCommitAt > pr.createdAt ? triggerCommitAt : pr.createdAt
520 const responseMs = Date.parse(openedAt) - Date.parse(triggerAt)
521 return {
522 kind: 'thread',
523 hasFindings: true,
524 originalLine: t.originalLine ?? null,
525 body: squash(first?.body ?? ''),
526 lastReplyBody: last ? squash(last.body) : null,
527 triggerSha,
528 triggerAt,
529 responseSeconds: Number.isFinite(responseMs) && responseMs >= 0 ? Math.round(responseMs / 1000) : null,
530 id: t.id,
531 path: t.path,
532 line: t.line ?? t.originalLine ?? null,
533 isResolved: t.isResolved,
534 isOutdated: t.isOutdated,
535 resolvedBy: t.resolvedBy?.login ?? null,
536 severity: first ? severityOf(first.body) : null,
537 title: first ? titleOf(first.body) : '(thread)',
538 openedBy,
539 openedByIsBot: isBotLogin(openedBy, first?.author?.__typename),
540 openedAt,
541 replies: Math.max(replies.length, t.comments.totalCount - 1),
542 lastReplyBy: last?.author?.login ?? null,
543 lastReplyAt: last?.createdAt ?? null,
544 commitsSince: after.length,
545 addressedBy,
546 url: first?.url ?? null,
547 }
548 })
549 // A bot's PR-level comment (a review summary, a "no issues found") is a
550 // finding of another kind: no file, no resolution, but a response time and
551 // a text that may duplicate what another bot said in a thread.
552 for (const c of pr.issueComments?.nodes ?? []) {
553 const login = c.author?.login ?? ''
554 if (!login || !isBotLogin(login, c.author?.__typename) || !c.id) continue
555 const before = commits.filter(x => x.at <= c.createdAt)
556 const trigger = before[before.length - 1]
557 const triggerAt = trigger && trigger.at > pr.createdAt ? trigger.at : pr.createdAt
558 const responseMs = Date.parse(c.createdAt) - Date.parse(triggerAt)
559 threads.push({
560 id: c.id,
561 kind: 'comment',
562 hasFindings: commentHasFindings(c.body),
563 path: '',
564 line: null,
565 originalLine: null,
566 isResolved: false,
567 isOutdated: false,
568 resolvedBy: null,
569 severity: severityOf(c.body),
570 title: titleOf(c.body),
571 openedBy: login,
572 openedByIsBot: true,
573 openedAt: c.createdAt,
574 replies: 0,
575 lastReplyBy: null,
576 lastReplyAt: null,
577 commitsSince: commits.filter(x => x.at > c.createdAt).length,
578 addressedBy: null,
579 url: c.url ?? null,
580 body: squash(c.body),
581 lastReplyBody: null,
582 triggerSha: trigger?.sha ?? null,
583 triggerAt,
584 responseSeconds: Number.isFinite(responseMs) && responseMs >= 0 ? Math.round(responseMs / 1000) : null,
585 })
586 }
587 for (const t of threads) {
588 if (!t.isResolved || !t.addressedBy) continue
589 const c = commits.find(x => x.sha === t.addressedBy)
590 if (c) c.threadsAddressed += 1
591 }
592 threads.sort((a, b) => {
593 if (a.kind !== b.kind) return a.kind === 'thread' ? -1 : 1
594 if (a.isResolved !== b.isResolved) return a.isResolved ? 1 : -1
595 return a.openedAt < b.openedAt ? 1 : -1
596 })
597
598 const activity: Activity[] = []
599 for (const c of commits) {
600 const who = c.coAuthors.find(isAgentName) ?? c.author
601 activity.push({ at: c.at, who, isBot: isBotLogin(c.author) || c.coAuthors.some(isAgentName), what: `pushed ${c.sha} ${c.headline}` })
602 }
603 for (const r of pr.reviews.nodes) {
604 const login = r.author?.login ?? 'unknown'
605 activity.push({ at: r.submittedAt, who: displayName(login, r.author?.__typename), isBot: isBotLogin(login, r.author?.__typename), what: `review: ${r.state.toLowerCase().replace('_', ' ')}` })
606 }
607 for (const c of pr.issueComments?.nodes ?? []) {
608 const login = c.author?.login ?? 'unknown'
609 activity.push({ at: c.createdAt, who: displayName(login, c.author?.__typename), isBot: isBotLogin(login, c.author?.__typename), what: `commented: ${titleOf(c.body)}` })
610 }
611 activity.sort((a, b) => (a.at < b.at ? 1 : -1))
612 // A reviewer's batch of replies arrives as one review per reply; fold runs
613 // of the same person doing the same thing into one row with a count.
614 const folded: Activity[] = []
615 for (const ev of activity) {
616 const last = folded[folded.length - 1]
617 if (last && last.who === ev.who && last.what.replace(/ ×\d+$/, '') === ev.what) {
618 const n = Number(/ ×(\d+)$/.exec(last.what)?.[1] ?? 1) + 1
619 last.what = `${ev.what} ×${n}`
620 continue
621 }
622 folded.push({ ...ev })
623 }
624
625 const agents = new Set(summary.agents)
626 for (const c of commits) for (const name of c.coAuthors) if (isAgentName(name)) agents.add(name)
627 for (const t of threads) if (t.openedByIsBot) agents.add(displayName(t.openedBy))
628 for (const r of summary.reviewers) if (r.isBot) agents.add(r.name)
629 for (const c of pr.issueComments?.nodes ?? []) {
630 const login = c.author?.login
631 if (login && isBotLogin(login, c.author?.__typename)) agents.add(displayName(login, c.author?.__typename))
632 }
633
634 return {
635 ...summary,
636 agents: [...agents],
637 fetchedAt,
638 state: pr.state ?? 'OPEN',
639 mergedAt: pr.mergedAt ?? null,
640 closedAt: pr.closedAt ?? null,
641 threads,
642 commits: commits.slice().reverse(),
643 activity: folded.slice(0, 12),
644 }
645}
646
647// A PR-level bot comment that reports nothing (a status table, a badge, a
648// walkthrough, "no issues found", "review skipped") is recorded but not
649// counted as a finding. What remains must read like a finding.
650const COMMENT_NOISE = [
651 /no issues? found|didn'?t find any|found no (major |significant )?(issues|problems)|no (problems|concerns|findings)\b|lgtm|looks good to me|nothing to flag|approved with no/i,
652 /codex review summary|this comment shows the latest codex review activity/i,
653 /summary by coderabbit|review_stack|change-stack|action performed|review finished|review skipped|review paused|auto-generated comment|incremental review system|walkthrough/i,
654 /^\s*(## |### )?(pull request overview|review summary|coverage report|dependency review)/im,
655 /^\s*@\w+\s+(review|retry|help|resolve)\s*$/i,
656]
657const FINDING_WORDS = /\b(issues?|bugs?|fix(es|ed)?|should|must|missing|incorrect|wrong|vulnerab\w*|leaks?|race|errors?|fail\w*|unsafe|undefined|inject\w*|severity|P[0-3]|findings?|problems?|concerns?|suggest\w*|risks?|crash\w*|regression|breaks?|broken|security|unauthenticated|unauthori[sz]ed|unhandled|overflow|deprecated|nit|nitpick)\b/i
658
659export function commentHasFindings(body: string): boolean {
660 const text = body
661 .replace(/<!--[\s\S]*?-->/g, ' ')
662 .replace(/<[^>]+>/g, ' ')
663 .replace(/^\s*\|.*\|\s*$/gm, ' ')
664 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
665 .replace(/https?:\/\/\S+/g, ' ')
666 .replace(/\s+/g, ' ')
667 .trim()
668 if (text.length < 60) return false
669 if (COMMENT_NOISE.some(re => re.test(text))) return false
670 return FINDING_WORDS.test(text)
671}
672
673export function squash(text: string): string {
674 return text.replace(/<!--[\s\S]*?-->/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 600)
675}
676
677export function parseSettledNumbers(json: string): number[] {
678 const parsed = JSON.parse(json) as { data?: { repository?: { pullRequests?: { nodes: { number: number }[] } } | null }; errors?: { message: string }[] }
679 if (parsed.errors?.length) throw new Error(parsed.errors.map(e => e.message).join('; '))
680 return (parsed.data?.repository?.pullRequests?.nodes ?? []).map(n => n.number)
681}
682
683// ---------- presentation helpers (pure) ----------
684
685export function ago(iso: string | number, now: number): string {
686 const t = typeof iso === 'number' ? iso : Date.parse(iso)
687 if (Number.isNaN(t)) return '?'
688 const s = Math.max(0, Math.round((now - t) / 1000))
689 if (s < 60) return `${s}s`
690 const m = Math.round(s / 60)
691 if (m < 60) return `${m}m`
692 const h = Math.round(m / 60)
693 if (h < 48) return `${h}h`
694 return `${Math.round(h / 24)}d`
695}
696
697export function glyph(state: CheckState): string {
698 switch (state) {
699 case 'success':
700 return '✓'
701 case 'failure':
702 return '✗'
703 case 'pending':
704 return '⟳'
705 case 'neutral':
706 return '◦'
707 case 'skipped':
708 return '–'
709 default:
710 return '?'
711 }
712}
713
714export function checkTotals(checks: Check[]): Record<CheckState, number> {
715 const t: Record<CheckState, number> = { success: 0, failure: 0, pending: 0, neutral: 0, skipped: 0, unknown: 0 }
716 for (const c of checks) t[c.state] += 1
717 return t
718}
719
720export function mergeStateWords(pr: PrSummary): { text: string; tone: 'success' | 'error' | 'warning' | 'subtle' } {
721 if (pr.isDraft) return { text: 'draft', tone: 'subtle' }
722 switch (pr.mergeState) {
723 case 'CLEAN':
724 return { text: 'ready to merge', tone: 'success' }
725 case 'HAS_HOOKS':
726 return { text: 'ready (hooks pending)', tone: 'success' }
727 case 'UNSTABLE':
728 return { text: 'checks failing or pending', tone: 'warning' }
729 case 'BLOCKED':
730 return { text: 'blocked by rules', tone: 'error' }
731 case 'BEHIND':
732 return { text: 'behind base branch', tone: 'warning' }
733 case 'DIRTY':
734 return { text: 'merge conflicts', tone: 'error' }
735 case 'DRAFT':
736 return { text: 'draft', tone: 'subtle' }
737 default:
738 return { text: pr.mergeable === 'CONFLICTING' ? 'merge conflicts' : (pr.mergeState || 'unknown').toLowerCase(), tone: 'subtle' }
739 }
740}
741
742export function reviewWords(pr: PrSummary, rules: RuleSummary | null): string {
743 const approvals = pr.reviewers.filter(r => r.state === 'APPROVED' && !r.isBot).length
744 const changes = pr.reviewers.filter(r => r.state === 'CHANGES_REQUESTED').length
745 const need = rules?.requiredApprovals ?? 0
746 if (pr.reviewDecision === 'CHANGES_REQUESTED' || changes > 0) return `changes requested (${changes})`
747 if (pr.reviewDecision === 'APPROVED') return `approved${need ? ` ${approvals}/${need}` : ''}`
748 if (pr.reviewDecision === 'REVIEW_REQUIRED') return `review required ${approvals}/${Math.max(need, 1)}`
749 if (need > 0) return `approvals ${approvals}/${need}`
750 return approvals > 0 ? `approved by ${approvals}` : 'no approval required'
751}
752
753export function bar(done: number, total: number, width: number): string {
754 if (total <= 0 || width <= 0) return ''
755 const filled = Math.round((done / total) * width)
756 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
757}
758
759export function truncate(text: string, width: number): string {
760 if (width <= 1) return ''
761 return text.length <= width ? text : `${text.slice(0, width - 1)}…`
762}
763
764export function statusLineOf(snapshot: Snapshot): string | undefined {
765 if (snapshot.prs.length === 0) return undefined
766 const parts = snapshot.prs.slice(0, 3).map(pr => {
767 const t = checkTotals(pr.checks)
768 const checks = t.failure ? `✗${t.failure}` : t.pending ? `⟳${t.pending}` : `✓${t.success}`
769 const threads = pr.threadsTotal ? ` ${pr.threadsResolved}/${pr.threadsTotal}` : ''
770 return `#${pr.number} ${checks}${threads} ${mergeStateWords(pr).text}`
771 })
772 const more = snapshot.totalCount > 3 ? ` +${snapshot.totalCount - 3}` : ''
773 return `PRs ${snapshot.totalCount} open · ${parts.join(' · ')}${more}`
774}
775
776export type Change = { kind: 'merged' | 'closed' | 'check-failed' | 'threads' | 'review' | 'new-pr'; text: string }
777
778export function diffSnapshots(prev: Snapshot | null, next: Snapshot): Change[] {
779 if (!prev || prev.repo !== next.repo) return []
780 const out: Change[] = []
781 const before = new Map(prev.prs.map(p => [p.number, p]))
782 for (const pr of next.prs) {
783 const old = before.get(pr.number)
784 if (!old) {
785 out.push({ kind: 'new-pr', text: `PR #${pr.number} opened: ${truncate(pr.title, 60)}` })
786 continue
787 }
788 const oldFail = checkTotals(old.checks).failure
789 const newFail = checkTotals(pr.checks).failure
790 if (newFail > oldFail) {
791 const failed = pr.checks.filter(c => c.state === 'failure').map(c => c.name)
792 out.push({ kind: 'check-failed', text: `#${pr.number}: check failed: ${failed.slice(0, 3).join(', ')}` })
793 }
794 const oldOpen = old.threadsTotal - old.threadsResolved
795 const newOpen = pr.threadsTotal - pr.threadsResolved
796 if (pr.threadsTotal > old.threadsTotal) {
797 out.push({ kind: 'threads', text: `#${pr.number}: ${pr.threadsTotal - old.threadsTotal} new review thread(s), ${newOpen} open` })
798 } else if (newOpen < oldOpen && newOpen === 0 && pr.threadsTotal > 0) {
799 out.push({ kind: 'threads', text: `#${pr.number}: every review thread resolved` })
800 }
801 for (const r of pr.reviewers) {
802 const was = old.reviewers.find(x => x.login === r.login)
803 if ((!was || was.state !== r.state) && (r.state === 'APPROVED' || r.state === 'CHANGES_REQUESTED')) {
804 out.push({ kind: 'review', text: `#${pr.number}: ${r.name} ${r.state === 'APPROVED' ? 'approved' : 'requested changes'}` })
805 }
806 }
807 }
808 return out
809}
810hooks/view.tsx 560 lines1// Pure drawing: turns the mod's state into element trees. No `$` here; the
2// element table and the press handlers come in from register.tsx.
3
4import type { Elements } from 'claude-code'
5
6import type { BackfillProgress, Check, PrDetail, PrSummary, RuleSummary, SettledPr, Snapshot, StatsReport, Thread } from '../types'
7import { fmtSeconds, pct } from './db.ts'
8import { ago, bar, checkTotals, displayName, glyph, mergeStateWords, reviewWords, truncate } from './github.ts'
9
10export type Table = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Link'>
11
12export type Actions = {
13 select: (number: number) => void
14 back: () => void
15 refresh: () => void
16 stats: () => void
17 judge: () => void
18}
19
20export type ListData = {
21 repo: string | null
22 snapshot: Snapshot | null
23 settled: SettledPr[]
24 error: string | null
25 isLoading: boolean
26 now: number
27 width: number
28 selected: number | null
29 progress: BackfillProgress | null
30}
31
32export type StatsData = {
33 repo: string | null
34 stats: StatsReport | null
35 progress: BackfillProgress | null
36 error: string | null
37 now: number
38 width: number
39}
40
41export type DetailData = {
42 detail: PrDetail
43 rules: RuleSummary | null
44 error: string | null
45 isLoading: boolean
46 now: number
47 width: number
48}
49
50const TONE = { success: 'success', failure: 'error', pending: 'warning', neutral: 'subtle', skipped: 'subtle', unknown: 'subtle' } as const
51
52function checkGroups(checks: Check[]): { label: string; items: Check[] }[] {
53 const groups = new Map<string, Check[]>()
54 for (const c of checks) {
55 const label = c.app && c.app !== 'GitHub Actions' ? c.app : c.workflow ?? c.app ?? 'status'
56 const list = groups.get(label) ?? []
57 list.push(c)
58 groups.set(label, list)
59 }
60 return [...groups.entries()].map(([label, items]) => ({ label, items }))
61}
62
63export function renderList(t: Table, d: ListData, a: Actions) {
64 const { Box, Text, Button } = t
65 const W = Math.max(20, d.width)
66 const snap = d.snapshot
67
68 const header = (
69 <Box key="head" flexDirection="row" justifyContent="space-between">
70 <Text wrap="truncate-end">
71 <Text weight="bold">{d.repo ?? 'no GitHub repo detected'}</Text>
72 {snap ? <Text dimColor> · {snap.totalCount} open PR{snap.totalCount === 1 ? '' : 's'} · {ago(snap.fetchedAt, d.now)} ago</Text> : null}
73 {d.isLoading ? <Text color="warning"> ⟳</Text> : null}
74 </Text>
75 <Box flexDirection="row" gap={1}>
76 <Button key="stats" hotkey="s" plain onPress={a.stats}>
77 stats
78 </Button>
79 <Button key="refresh" hotkey="r" plain onPress={a.refresh}>
80 refresh
81 </Button>
82 </Box>
83 </Box>
84 )
85
86 if (!d.repo) {
87 return (
88 <Box flexDirection="column">
89 {header}
90 <Text dimColor wrap="wrap">
91 Run Claude Code inside a repo with a GitHub remote, or type /prs repo owner/name.
92 </Text>
93 {d.error ? <Text color="error" wrap="wrap">{d.error}</Text> : null}
94 </Box>
95 )
96 }
97
98 const rows = (snap?.prs ?? []).map((pr, i) => renderPrRow(t, pr, i, snap?.rules ?? null, d.now, W, a))
99
100 return (
101 <Box flexDirection="column">
102 {header}
103 {d.error ? <Text color="error" wrap="truncate-end">{d.error}</Text> : null}
104 {!snap && !d.error ? <Text dimColor>Asking GitHub…</Text> : null}
105 {snap && snap.prs.length === 0 ? <Text dimColor>No open pull requests.</Text> : null}
106 {rows}
107 {snap?.rules ? renderRules(t, snap.rules, W) : null}
108 {d.settled.length > 0 ? renderSettled(t, d.settled, d.now, W) : null}
109 {d.progress?.running ? <Text color="warning" wrap="truncate-end">backfill: {d.progress.message}</Text> : null}
110 <Text dimColor wrap="truncate-end">
111 digits drill in · s stats · /prs N · /prs refresh · /prs backfill 30 · /prs repo owner/name
112 </Text>
113 </Box>
114 )
115}
116
117function renderPrRow(t: Table, pr: PrSummary, i: number, rules: RuleSummary | null, now: number, W: number, a: Actions) {
118 const { Box, Text, Button } = t
119 const totals = checkTotals(pr.checks)
120 const merge = mergeStateWords(pr)
121 const open = pr.threadsTotal - pr.threadsResolved
122 const bots = pr.reviewers.filter(r => r.isBot)
123 const humans = pr.reviewers.filter(r => !r.isBot)
124 const hotkey = i < 9 ? String(i + 1) : undefined
125 const barWidth = Math.max(6, Math.min(16, Math.floor(W / 6)))
126
127 return (
128 <Box key={`pr-${pr.number}`} flexDirection="column" marginTop={1}>
129 <Button key={`open-${pr.number}`} hotkey={hotkey} plain onPress={() => a.select(pr.number)}>
130 #{pr.number} {truncate(pr.title, W - 14)}
131 </Button>
132 <Text dimColor wrap="truncate-end">
133 {' '}
134 {pr.headRef} → {pr.baseRef} · {pr.authorIsBot ? displayName(pr.author) : pr.author} · opened {ago(pr.createdAt, now)} ago · pushed {ago(pr.headAt, now)} ago · {pr.commitCount} commit{pr.commitCount === 1 ? '' : 's'}
135 </Text>
136 <Text wrap="truncate-end">
137 {' '}
138 <Text color={merge.tone}>● {merge.text}</Text>
139 <Text dimColor> · {reviewWords(pr, rules)}</Text>
140 {pr.autoMerge ? <Text dimColor> · auto-merge on</Text> : null}
141 </Text>
142 <Text wrap="truncate-end">
143 {' checks '}
144 <Text color="success">✓{totals.success}</Text>{' '}
145 <Text color={totals.failure ? 'error' : 'subtle'}>✗{totals.failure}</Text>{' '}
146 <Text color={totals.pending ? 'warning' : 'subtle'}>⟳{totals.pending}</Text>
147 {totals.skipped + totals.neutral ? <Text dimColor> –{totals.skipped + totals.neutral}</Text> : null}
148 {pr.threadsTotal > 0 ? (
149 <Text>
150 {' threads '}
151 <Text color={open === 0 ? 'success' : 'warning'}>{bar(pr.threadsResolved, pr.threadsTotal, barWidth)}</Text>
152 {' '}{pr.threadsResolved}/{pr.threadsTotal} resolved
153 {open > 0 ? <Text color="warning"> · {open} open</Text> : null}
154 </Text>
155 ) : (
156 <Text dimColor> no review threads</Text>
157 )}
158 </Text>
159 {bots.length + humans.length + pr.requested.length > 0 ? (
160 <Text wrap="truncate-end">
161 {' reviews '}
162 {[...humans, ...bots].map((r, k) => (
163 <Text key={`rv-${k}`}>
164 {k > 0 ? <Text dimColor> · </Text> : null}
165 <Text color={r.state === 'APPROVED' ? 'success' : r.state === 'CHANGES_REQUESTED' ? 'error' : undefined}>
166 {r.isBot ? '🤖 ' : ''}{r.name} {reviewGlyph(r.state)}
167 </Text>
168 </Text>
169 ))}
170 {pr.requested.length ? <Text dimColor> · awaiting {pr.requested.join(', ')}</Text> : null}
171 </Text>
172 ) : null}
173 {pr.agents.length > 0 ? (
174 <Text dimColor wrap="truncate-end">
175 {' agents '}{pr.agents.join(' · ')}
176 </Text>
177 ) : null}
178 </Box>
179 )
180}
181
182function reviewGlyph(state: string): string {
183 switch (state) {
184 case 'APPROVED':
185 return '✓'
186 case 'CHANGES_REQUESTED':
187 return '✗'
188 case 'COMMENTED':
189 return '💬'
190 case 'PENDING':
191 return '…'
192 case 'DISMISSED':
193 return '⊘'
194 default:
195 return ''
196 }
197}
198
199function renderRules(t: Table, rules: RuleSummary, W: number) {
200 const { Text } = t
201 const parts: string[] = []
202 if (rules.requiredChecks.length) parts.push(`required checks: ${rules.requiredChecks.join(', ')}`)
203 parts.push(rules.requiredApprovals > 0 ? `${rules.requiredApprovals} approval${rules.requiredApprovals === 1 ? '' : 's'} required` : 'no approvals required')
204 if (rules.requiresThreadResolution) parts.push('threads must be resolved')
205 if (rules.requiresCodeOwners) parts.push('code owner review')
206 if (rules.noForcePush) parts.push('no force-push')
207 return (
208 <Text dimColor wrap="truncate-end">
209 {'\n'}rules on base: {truncate(parts.join(' · '), W - 16)}
210 </Text>
211 )
212}
213
214function renderSettled(t: Table, settled: SettledPr[], now: number, W: number) {
215 const { Box, Text } = t
216 return (
217 <Box flexDirection="column" marginTop={1}>
218 <Text dimColor>recently settled</Text>
219 {settled.slice(0, 5).map(s => (
220 <Text key={`settled-${s.number}`} wrap="truncate-end">
221 {' '}
222 <Text color={s.state === 'MERGED' ? 'merged' : 'subtle'}>{s.state === 'MERGED' ? '⇄ merged' : '✕ closed'}</Text>
223 {' #'}{s.number} {truncate(s.title, Math.max(10, W - 50))}
224 <Text dimColor>
225 {s.at ? ` ${ago(s.at, now)} ago` : ''}{s.by ? ` by ${s.by}` : ''} ·{' '}
226 {s.branchDeleted === true ? 'branch deleted' : s.branchDeleted === false ? `branch ${s.headRef} still exists` : 'branch ?'}
227 </Text>
228 </Text>
229 ))}
230 </Box>
231 )
232}
233
234export function renderDetail(t: Table, d: DetailData, a: Actions) {
235 const { Box, Text, Button, Link } = t
236 const pr = d.detail
237 const W = Math.max(20, d.width)
238 const merge = mergeStateWords(pr)
239 const totals = checkTotals(pr.checks)
240 const inline = pr.threads.filter(x => x.kind === 'thread')
241 const comments = pr.threads.filter(x => x.kind === 'comment')
242 const open = inline.filter(x => !x.isResolved)
243 const resolved = inline.filter(x => x.isResolved)
244 const barWidth = Math.max(8, Math.min(24, Math.floor(W / 4)))
245
246 return (
247 <Box flexDirection="column">
248 <Box key="head" flexDirection="row" justifyContent="space-between">
249 <Box flexDirection="row">
250 <Button key="back" hotkey="b" plain onPress={a.back}>
251 ‹ back
252 </Button>
253 <Text wrap="truncate-end">
254 {' '}
255 <Text weight="bold">#{pr.number}</Text> {truncate(pr.title, W - 30)}
256 </Text>
257 </Box>
258 <Button key="refresh" hotkey="r" plain onPress={a.refresh}>
259 refresh
260 </Button>
261 </Box>
262 <Text dimColor wrap="truncate-end">
263 {pr.headRef} → {pr.baseRef} · {pr.author} · +{pr.additions} −{pr.deletions} · {pr.changedFiles} files · {pr.commitCount} commits · head {pr.headSha} {ago(pr.headAt, d.now)} ago
264 {d.isLoading ? <Text color="warning"> ⟳</Text> : <Text> · {ago(pr.fetchedAt, d.now)} ago</Text>}
265 </Text>
266 <Link href={pr.url} label={pr.url} />
267 {d.error ? <Text color="error" wrap="truncate-end">{d.error}</Text> : null}
268
269 <Text wrap="truncate-end">
270 {'\n'}
271 <Text weight="bold">merge </Text>
272 <Text color={merge.tone}>● {merge.text}</Text>
273 <Text dimColor> · {pr.mergeable.toLowerCase()} · {reviewWords(pr, d.rules)}{pr.autoMerge ? ' · auto-merge on' : ''}</Text>
274 </Text>
275 {d.rules ? renderRuleChecklist(t, pr, d.rules) : <Text dimColor> no branch rules on {pr.baseRef}</Text>}
276
277 <Text wrap="truncate-end">
278 {'\n'}
279 <Text weight="bold">checks </Text>
280 <Text color="success">✓{totals.success}</Text>{' '}
281 <Text color={totals.failure ? 'error' : 'subtle'}>✗{totals.failure}</Text>{' '}
282 <Text color={totals.pending ? 'warning' : 'subtle'}>⟳{totals.pending}</Text>{' '}
283 <Text dimColor>–{totals.skipped + totals.neutral}</Text>
284 </Text>
285 {checkGroups(pr.checks).map(g => (
286 <Text key={`cg-${g.label}`} wrap="truncate-end">
287 {' '}
288 <Text dimColor>{truncate(g.label, 22)}: </Text>
289 {g.items.map((c, k) => (
290 <Text key={`c-${k}`}>
291 {k > 0 ? <Text dimColor> · </Text> : null}
292 <Text color={TONE[c.state]}>{glyph(c.state)} {c.name}</Text>
293 {c.isRequired ? <Text dimColor> (required)</Text> : null}
294 </Text>
295 ))}
296 </Text>
297 ))}
298
299 <Text wrap="truncate-end">
300 {'\n'}
301 <Text weight="bold">reviewers </Text>
302 {pr.reviewers.length === 0 ? <Text dimColor>none yet</Text> : null}
303 {pr.reviewers.map((r, k) => (
304 <Text key={`r-${k}`}>
305 {k > 0 ? <Text dimColor> · </Text> : null}
306 <Text color={r.state === 'APPROVED' ? 'success' : r.state === 'CHANGES_REQUESTED' ? 'error' : undefined}>
307 {r.isBot ? '🤖 ' : ''}{r.name} {reviewGlyph(r.state)}
308 </Text>
309 <Text dimColor> {ago(r.at, d.now)}</Text>
310 </Text>
311 ))}
312 {pr.requested.length ? <Text dimColor> · awaiting {pr.requested.join(', ')}</Text> : null}
313 </Text>
314
315 <Text wrap="truncate-end">
316 {'\n'}
317 <Text weight="bold">threads </Text>
318 {pr.threadsTotal === 0 ? (
319 <Text dimColor>none</Text>
320 ) : (
321 <Text>
322 <Text color={open.length === 0 ? 'success' : 'warning'}>{bar(pr.threadsResolved, pr.threadsTotal, barWidth)}</Text>
323 {' '}{pr.threadsResolved}/{pr.threadsTotal} resolved
324 {open.length ? <Text color="warning"> · {open.length} open</Text> : <Text color="success"> · all addressed</Text>}
325 {pr.threadsOutdated ? <Text dimColor> · {pr.threadsOutdated} outdated by later commits</Text> : null}
326 </Text>
327 )}
328 </Text>
329 {open.slice(0, 12).map(th => renderThread(t, th, d.now, W))}
330 {open.length > 12 ? <Text dimColor> … {open.length - 12} more open</Text> : null}
331 {resolved.length > 0 ? (
332 <Text dimColor wrap="truncate-end">
333 {' ✓ '}{resolved.length} resolved
334 {resolvedBy(resolved)}
335 </Text>
336 ) : null}
337 {comments.length > 0 ? (
338 <Text dimColor wrap="truncate-end">
339 {' 💬 '}{comments.length} PR-level bot comment{comments.length === 1 ? '' : 's'}, {comments.filter(c => c.hasFindings).length} reporting something:{' '}
340 {comments.slice(0, 4).map(c => `${displayName(c.openedBy)} ${ago(c.openedAt, d.now)}${c.hasFindings ? '' : ' (nothing found)'}`).join(' · ')}
341 </Text>
342 ) : null}
343
344 <Text wrap="truncate-end">
345 {'\n'}
346 <Text weight="bold">commits </Text>
347 <Text dimColor>newest first · "addresses n" = resolved threads opened before it</Text>
348 </Text>
349 {pr.commits.slice(0, 8).map(c => (
350 <Text key={`cm-${c.sha}`} wrap="truncate-end">
351 {' '}
352 <Text color={TONE[c.checks]}>{glyph(c.checks)}</Text> {c.sha} <Text dimColor>{ago(c.at, d.now)}</Text> {truncate(c.headline, Math.max(10, W - 46))}
353 {c.coAuthors.length ? <Text color="claude"> · {c.coAuthors.join(', ')}</Text> : <Text dimColor> · {c.author}</Text>}
354 {c.threadsAddressed ? <Text color="success"> · addresses {c.threadsAddressed}</Text> : null}
355 </Text>
356 ))}
357 {pr.commits.length > 8 ? <Text dimColor> … {pr.commits.length - 8} earlier</Text> : null}
358
359 <Text wrap="truncate-end">
360 {'\n'}
361 <Text weight="bold">agents on this PR </Text>
362 {pr.agents.length ? <Text color="claude">{pr.agents.join(' · ')}</Text> : <Text dimColor>none detected</Text>}
363 </Text>
364
365 <Text weight="bold">{'\n'}activity</Text>
366 {pr.activity.slice(0, 6).map((ev, k) => (
367 <Text key={`ev-${k}`} dimColor wrap="truncate-end">
368 {' '}{ago(ev.at, d.now)} {ev.isBot ? '🤖 ' : ''}{ev.who}: {truncate(ev.what, Math.max(10, W - 24))}
369 </Text>
370 ))}
371 </Box>
372 )
373}
374
375function resolvedBy(resolved: Thread[]): string {
376 const counts = new Map<string, number>()
377 for (const t of resolved) {
378 const who = t.resolvedBy ?? 'unknown'
379 counts.set(who, (counts.get(who) ?? 0) + 1)
380 }
381 const parts = [...counts.entries()].map(([who, n]) => `${who} ${n}`)
382 return parts.length ? ` · by ${parts.join(', ')}` : ''
383}
384
385function renderRuleChecklist(t: Table, pr: PrDetail, rules: RuleSummary) {
386 const { Text } = t
387 const items: { ok: boolean | null; text: string }[] = []
388 for (const name of rules.requiredChecks) {
389 const check = pr.checks.find(c => c.name === name)
390 items.push({ ok: check ? check.state === 'success' : null, text: `check ${name} ${check ? check.state : 'not reported yet'}` })
391 }
392 if (rules.requiredApprovals > 0) {
393 const approvals = pr.reviewers.filter(r => r.state === 'APPROVED' && !r.isBot).length
394 items.push({ ok: approvals >= rules.requiredApprovals, text: `${approvals}/${rules.requiredApprovals} approvals` })
395 }
396 if (rules.requiresThreadResolution) {
397 const open = pr.threadsTotal - pr.threadsResolved
398 items.push({ ok: open === 0, text: open === 0 ? 'all threads resolved' : `${open} threads still open` })
399 }
400 if (rules.requiresCodeOwners) items.push({ ok: pr.reviewDecision === 'APPROVED', text: 'code owner review' })
401 if (items.length === 0) return <Text dimColor> rules: {rules.names.join(', ') || 'branch protection'} (nothing gating merge)</Text>
402 return (
403 <Text wrap="truncate-end">
404 {' rules: '}
405 {items.map((it, k) => (
406 <Text key={`rule-${k}`}>
407 {k > 0 ? <Text dimColor> · </Text> : null}
408 <Text color={it.ok === true ? 'success' : it.ok === false ? 'error' : 'warning'}>{it.ok === true ? '✓' : it.ok === false ? '✗' : '○'} {it.text}</Text>
409 </Text>
410 ))}
411 </Text>
412 )
413}
414
415function renderThread(t: Table, th: Thread, now: number, W: number) {
416 const { Text } = t
417 const who = th.openedByIsBot ? displayName(th.openedBy) : th.openedBy
418 const where = `${th.path}${th.line ? `:${th.line}` : ''}`
419 let status: { text: string; tone: 'success' | 'warning' | 'error' | 'subtle' }
420 if (th.replies > 0 && th.commitsSince > 0) status = { text: `replied by ${th.lastReplyBy ?? '?'} · ${th.commitsSince} commit${th.commitsSince === 1 ? '' : 's'} since · awaiting resolve`, tone: 'success' }
421 else if (th.replies > 0) status = { text: `replied by ${th.lastReplyBy ?? '?'} · no commit yet`, tone: 'warning' }
422 else if (th.commitsSince > 0) status = { text: `${th.commitsSince} commit${th.commitsSince === 1 ? '' : 's'} since · no reply`, tone: 'warning' }
423 else status = { text: 'waiting', tone: 'error' }
424 if (th.isOutdated) status = { ...status, text: `${status.text} · code changed` }
425 return (
426 <Text key={`th-${th.id}`} wrap="truncate-end">
427 {' ○ '}
428 {th.severity ? <Text color={/P0|P1|Critical|Major|High|block/i.test(th.severity) ? 'error' : 'warning'}>{th.severity} </Text> : null}
429 <Text dimColor>{truncate(where, Math.max(12, Math.floor(W / 3)))} </Text>
430 {th.openedByIsBot ? '🤖 ' : ''}{who} <Text dimColor>{ago(th.openedAt, now)}</Text>
431 {' '}{truncate(th.title, Math.max(10, W - 40))}
432 <Text color={status.tone}> · {status.text}</Text>
433 </Text>
434 )
435}
436
437export function renderStats(t: Table, d: StatsData, a: Actions) {
438 const { Box, Text, Button } = t
439 const W = Math.max(20, d.width)
440 const r = d.stats
441 const narrow = W < 100
442 return (
443 <Box flexDirection="column">
444 <Box key="head" flexDirection="row" justifyContent="space-between">
445 <Box flexDirection="row">
446 <Button key="back" hotkey="b" plain onPress={a.back}>
447 ‹ PRs
448 </Button>
449 <Text wrap="truncate-end">
450 {' '}
451 <Text weight="bold">review bots</Text>
452 <Text dimColor> · {d.repo ?? 'all repos'}{r?.sinceDays ? ` · last ${r.sinceDays} days` : ''}</Text>
453 </Text>
454 </Box>
455 <Box flexDirection="row" gap={1}>
456 {r && r.unjudgedPairs > 0 ? (
457 <Button key="judge" hotkey="j" plain onPress={a.judge}>
458 judge {r.unjudgedPairs}
459 </Button>
460 ) : null}
461 <Button key="refresh" hotkey="r" plain onPress={a.stats}>
462 refresh
463 </Button>
464 </Box>
465 </Box>
466 {d.progress ? (
467 <Text color={d.progress.running ? 'warning' : 'subtle'} wrap="truncate-end">
468 backfill {d.progress.running ? '⟳ ' : ''}{d.progress.total ? `${d.progress.done}/${d.progress.total} · ` : ''}{d.progress.message}
469 </Text>
470 ) : null}
471 {d.error ? <Text color="error" wrap="truncate-end">{d.error}</Text> : null}
472 {!r ? <Text dimColor>No stats computed yet. /prs stats, or /prs backfill 30 to sweep merged PRs into the ledger.</Text> : null}
473 {r && r.bots.length === 0 ? <Text dimColor>No bot review threads on record. Drill into a PR, or /prs backfill 30.</Text> : null}
474 {r && r.bots.length > 0 ? (
475 <Text dimColor wrap="truncate-end">
476 {r.totalThreads} findings across {r.totalPrs} PRs · computed {ago(r.generatedAt, d.now)} ago
477 </Text>
478 ) : null}
479 {r && r.bots.length > 0 ? (
480 <Text dimColor wrap="truncate-end">
481 {'\n'}{'bot'.padEnd(17)} {'found'.padStart(5)} {'PRs'.padStart(4)} {'used'.padStart(5)} {'code✓'.padStart(5)} {'dismis'.padStart(6)} {'unk'.padStart(4)} {'open'.padStart(4)} {'used%'.padStart(5)} {'resp'.padStart(6)} {'uniq'.padStart(5)} {'dup'.padStart(4)} {'1st'.padStart(4)} {'rep'.padStart(4)} {'lines~'.padStart(6)} {'≥5'.padStart(6)}
482 </Text>
483 ) : null}
484 {(r?.bots ?? []).map(b => (
485 <Text key={`bot-${b.bot}`} wrap="truncate-end">
486 <Text weight="bold">{truncate(b.bot, 17).padEnd(17)}</Text> {String(b.findings).padStart(5)} {String(b.prs).padStart(4)}{' '}
487 <Text color="success">{String(b.used).padStart(5)}</Text> <Text color="success">{String(b.codeVerified).padStart(5)}</Text> <Text color="error">{String(b.dismissed).padStart(6)}</Text>{' '}
488 <Text dimColor>{String(b.resolved).padStart(4)}</Text> <Text color="warning">{String(b.open).padStart(4)}</Text>{' '}
489 <Text color={b.usedRate !== null && b.usedRate >= 0.6 ? 'success' : b.usedRate !== null && b.usedRate < 0.3 ? 'error' : undefined}>{pct(b.usedRate).padStart(5)}</Text>{' '}
490 {fmtSeconds(b.medianResponseSeconds).padStart(6)} {String(b.unique).padStart(5)} <Text color={b.duplicated > b.unique ? 'warning' : undefined}>{String(b.duplicated).padStart(4)}</Text>{' '}
491 {String(b.firsts).padStart(4)} <Text color={b.repeats > b.firsts ? 'warning' : undefined}>{String(b.repeats).padStart(4)}</Text>{' '}
492 {(b.medianNearLines === null ? '–' : String(b.medianNearLines)).padStart(6)} {(b.nearKnown ? `${b.substantive}/${b.nearKnown}` : '–').padStart(6)}
493 </Text>
494 ))}
495 {(r?.bots ?? []).filter(b => b.comments > 0).map(b => (
496 <Text key={`cm-${b.bot}`} dimColor wrap="truncate-end">
497 {' '}{truncate(b.bot, 17)}: {b.threads} inline threads · {b.comments} PR-level comments, {b.commentsWithFindings} reporting something
498 </Text>
499 ))}
500 {(r?.bots ?? []).map(b => (
501 <Text key={`sev-${b.bot}`} dimColor wrap="truncate-end">
502 {' '}{truncate(b.bot, 17)}: severities {Object.entries(b.severities).sort((x, y) => y[1] - x[1]).map(([k, n]) => `${k} ${n}`).join(' · ')}
503 </Text>
504 ))}
505 {r && r.pairs.length > 0 ? (
506 <Text weight="bold">{'\n'}overlap between bots <Text dimColor weight="regular">same finding on the same file, judged by the model</Text></Text>
507 ) : null}
508 {(r?.pairs ?? []).map(p => (
509 <Text key={`pair-${p.botA}-${p.botB}`} wrap="truncate-end">
510 {' '}{p.botA} × {p.botB}: <Text color={p.judged && p.same / p.judged > 0.4 ? 'warning' : undefined}>{p.same} same</Text> of {p.judged} judged
511 <Text dimColor> ({p.colocated} co-located, {p.similar} similar)</Text>
512 {p.unjudged ? <Text dimColor> · {p.unjudged} unjudged</Text> : null}
513 </Text>
514 ))}
515 {r && r.trend.length > 0 ? renderTrend(t, r, W) : null}
516 <Text dimColor wrap={narrow ? 'wrap' : 'truncate-end'}>
517 {'\n'}used = code moved after it (diff outdated, commit hunk on the line, or reply says fixed) · code✓ = used with code evidence · dismis = reply says no, no change · unk = resolved, no evidence · resp = median push→comment · 1st/rep = duplicates raised first / repeated after another bot · lines~ = median lines moved near a used finding · ≥5 = used findings that moved 5+ lines
518 </Text>
519 <Text dimColor wrap="truncate-end">b back · j judge pending pairs · r recompute · /prs stats 30 · /prs trend 12 · /prs stats all all-repos · /prs db</Text>
520 </Box>
521 )
522}
523
524// One row per week, a bar per bot scaled to the busiest week, so the eye sees
525// who found how much and whether it is rising or falling.
526function renderTrend(t: Table, r: StatsReport, W: number) {
527 const { Box, Text } = t
528 const bots = [...new Set(r.trend.flatMap(w => Object.keys(w.bots)))].sort()
529 const peak = Math.max(1, ...r.trend.flatMap(w => Object.values(w.bots).map(b => b.findings)))
530 const barWidth = Math.max(4, Math.min(14, Math.floor((W - 16) / Math.max(1, bots.length) / 2)))
531 const colors = ['claude', 'success', 'warning', 'merged', 'permission', 'ide'] as const
532 return (
533 <Box flexDirection="column">
534 <Text weight="bold">
535 {'\n'}trend <Text dimColor weight="regular">by week · findings per bot · {r.trend.length} weeks</Text>
536 </Text>
537 <Text dimColor wrap="truncate-end">
538 {' '}{bots.map((b, i) => <Text key={`lg-${b}`} color={colors[i % colors.length]}>{i ? ' · ' : ''}█ {b}</Text>)}
539 </Text>
540 {r.trend.map(w => (
541 <Text key={`wk-${w.weekStart}`} wrap="truncate-end">
542 {' '}<Text dimColor>{w.weekStart.slice(5)}</Text>{' '}
543 {bots.map((b, i) => {
544 const tb = w.bots[b]
545 const n = tb?.findings ?? 0
546 const filled = Math.round((n / peak) * barWidth)
547 return (
548 <Text key={`wk-${w.weekStart}-${b}`}>
549 <Text color={colors[i % colors.length]}>{'█'.repeat(filled)}</Text><Text dimColor>{'░'.repeat(barWidth - filled)}</Text>
550 <Text dimColor>{String(n).padStart(3)}{tb && tb.repeats ? `(${tb.repeats}r)` : ' '} </Text>
551 </Text>
552 )
553 })}
554 <Text dimColor>{w.prs} PR{w.prs === 1 ? '' : 's'}</Text>
555 </Text>
556 ))}
557 </Box>
558 )
559}
560types/index.d.ts 218 lines1export type CheckState = 'success' | 'failure' | 'pending' | 'neutral' | 'skipped' | 'unknown'
2
3export type Check = {
4 name: string
5 app: string | null
6 workflow: string | null
7 state: CheckState
8 url: string | null
9 isRequired: boolean
10}
11
12export type Reviewer = {
13 login: string
14 name: string
15 isBot: boolean
16 state: string
17 at: string
18}
19
20export type RuleSummary = {
21 names: string[]
22 requiredApprovals: number
23 requiredChecks: string[]
24 requiresThreadResolution: boolean
25 requiresCodeOwners: boolean
26 noForcePush: boolean
27 source: 'ruleset' | 'protection' | 'both' | 'none'
28}
29
30export type PrSummary = {
31 number: number
32 title: string
33 url: string
34 author: string
35 authorIsBot: boolean
36 isDraft: boolean
37 headRef: string
38 baseRef: string
39 createdAt: string
40 updatedAt: string
41 mergeable: string
42 mergeState: string
43 reviewDecision: string | null
44 autoMerge: boolean
45 additions: number
46 deletions: number
47 changedFiles: number
48 commitCount: number
49 headSha: string
50 headAt: string
51 threadsTotal: number
52 threadsResolved: number
53 threadsOutdated: number
54 checks: Check[]
55 reviewers: Reviewer[]
56 requested: string[]
57 agents: string[]
58}
59
60export type Snapshot = {
61 repo: string
62 fetchedAt: number
63 totalCount: number
64 rules: RuleSummary | null
65 prs: PrSummary[]
66}
67
68export type ThreadComment = {
69 author: string
70 isBot: boolean
71 at: string
72 body: string
73 commit: string | null
74}
75
76export type Thread = {
77 id: string
78 kind: 'thread' | 'comment'
79 hasFindings: boolean
80 path: string
81 line: number | null
82 isResolved: boolean
83 isOutdated: boolean
84 resolvedBy: string | null
85 severity: string | null
86 title: string
87 openedBy: string
88 openedByIsBot: boolean
89 openedAt: string
90 replies: number
91 lastReplyBy: string | null
92 lastReplyAt: string | null
93 commitsSince: number
94 addressedBy: string | null
95 url: string | null
96 body: string
97 lastReplyBody: string | null
98 triggerSha: string | null
99 triggerAt: string
100 responseSeconds: number | null
101 originalLine: number | null
102}
103
104// A unified-diff hunk of one file in one commit: old-side start and length,
105// new-side start and length, and (when the patch text was read) the lines
106// added and removed inside it. Rows written before counts were kept have four.
107export type Hunk = [oldStart: number, oldLines: number, newStart: number, newLines: number, adds?: number, dels?: number]
108
109export type CommitPatch = { files: string[]; hunks: Record<string, Hunk[]> }
110
111export type Commit = {
112 sha: string
113 headline: string
114 at: string
115 author: string
116 coAuthors: string[]
117 sessionUrl: string | null
118 checks: CheckState
119 threadsAddressed: number
120}
121
122export type Activity = {
123 at: string
124 who: string
125 isBot: boolean
126 what: string
127}
128
129export type PrDetail = PrSummary & {
130 fetchedAt: number
131 state: 'OPEN' | 'MERGED' | 'CLOSED'
132 mergedAt: string | null
133 closedAt: string | null
134 threads: Thread[]
135 commits: Commit[]
136 activity: Activity[]
137}
138
139export type Outcome = 'used' | 'dismissed' | 'resolved' | 'open' | 'comment'
140
141export type BotStats = {
142 bot: string
143 findings: number
144 threads: number
145 comments: number
146 commentsWithFindings: number
147 prs: number
148 used: number
149 codeVerified: number
150 dismissed: number
151 resolved: number
152 open: number
153 usedRate: number | null
154 medianResponseSeconds: number | null
155 duplicated: number
156 unique: number
157 firsts: number
158 repeats: number
159 severities: Record<string, number>
160 // Lines added plus removed by the addressing commit within a few lines of
161 // the finding: how much code moved because of it. Over used findings whose
162 // commit patch is known.
163 medianNearLines: number | null
164 nearKnown: number
165 substantive: number
166}
167
168export type TrendBot = { findings: number; used: number; repeats: number; medianNearLines: number | null }
169
170export type TrendWeek = { weekStart: string; prs: number; bots: Record<string, TrendBot> }
171
172export type PairStats = { botA: string; botB: string; same: number; judged: number; unjudged: number; colocated: number; similar: number }
173
174export type StatsReport = {
175 repo: string | null
176 sinceDays: number | null
177 generatedAt: number
178 totalThreads: number
179 totalPrs: number
180 bots: BotStats[]
181 pairs: PairStats[]
182 unjudgedPairs: number
183 trend: TrendWeek[]
184}
185
186export type BackfillProgress = { running: boolean; done: number; total: number; message: string }
187
188
189export type SettledPr = {
190 number: number
191 title: string
192 url: string
193 state: 'MERGED' | 'CLOSED'
194 at: string | null
195 by: string | null
196 headRef: string
197 branchDeleted: boolean | null
198 seenAt: number
199}
200
201declare module 'claude-code' {
202 interface PluginState {
203 'pr-pulse': {
204 repo: string | null
205 snapshot: Snapshot | null
206 detail: PrDetail | null
207 selected: number | null
208 error: string | null
209 isLoading: boolean
210 settled: SettledPr[]
211 mode: 'prs' | 'stats'
212 stats: StatsReport | null
213 backfill: BackfillProgress | null
214 dbPath: string | null
215 }
216 }
217}
218