SLOPSHOPPER

pr-pulse

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…

paneguardcommandtoaststatusmodel
v0.4.0no licenseupdated 2026-10-18revsmoke/pr-pulse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-pulse
│ ┃ PRs ✕ › fix the failing auth test and add an audit log call │ ┃ no GitHub repo detected ⟳s: stats r: refresh │ ┃ Run Claude Code inside a repo with a GitHub ⏺ Read(src/auth.ts) │ ┃ remote, or type /prs repo owner/name. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /prs │ ⎿ pr-pulse: PR pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PRs
no GitHub repo detected ⟳ s: stats r: refresh Run Claude Code inside a repo with a GitHub remote, or type /prs repo owner/name.
README

pr-pulse

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:

  • merge readiness (mergeable, blocked, behind, conflicts) and the review decision
  • the branch rules that gate the merge (required checks, approvals, thread resolution, no force-push) and whether each one is satisfied now
  • every check grouped by the app that runs it, with required ones marked
  • reviewers, human and bot (CodeRabbit, Cursor Bugbot, Codex, Copilot, Claude Code, Gemini, ...), with their latest verdict
  • review threads: resolved/open bar, each open thread's severity, file, who opened it, whether it got a reply, and how many commits landed since
  • commits newest first, each with the agent that co-authored it and how many review threads it addressed
  • the agents and models working on the PR, read from Co-Authored-By trailers and bot logins
  • recently merged or closed PRs and whether their branch was deleted

It 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.

Commands

CommandDoes
/prsopen the pane (all open PRs)
/prs 262drill into PR #262
/prs backback to the list
/prs refreshfetch now
/prs closeclose the pane
/prs repo owner/namewatch 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 dbprint 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.

How the ledger scores a finding

  • findings: inline review threads plus PR-level bot comments that report something (a "no issues found" or a walkthrough is recorded but not counted). That is how the Claude Code review action and summary-style bots are seen.
  • used: the thread was resolved and the code moved under it: GitHub marked the diff outdated, the first commit after the comment has a hunk reaching the flagged line (with three lines of slack), or the reply says fixed/done/applied.
  • code-verified: used with code evidence (outdated diff or hunk on the line), as opposed to a reply alone.
  • dismissed: resolved with a reply like won't fix, false positive, by design, and no change on the line.
  • unknown: resolved with no evidence either way (including a commit that touched the file but not the line).
  • open: still unresolved.
  • response: seconds from the commit the comment is anchored on (or the PR opening) to the comment.
  • duplicated: another bot's finding on the same PR that the model judged to report the same issue. Candidates are co-located (same file within 8 lines) or similar (enough words in common anywhere in the PR, PR-level comments included); each is settled by one low-effort Haiku call.
  • first / repeat: among duplicated findings, how many this bot raised before the other bot, and how many it repeated after another bot had already said it. A bot that is mostly "repeat" adds little on top of the others.
  • lines~ / ≥5 lines: for used findings whose addressing commit's patch is known, the median lines added plus removed within three lines of the finding, and how many moved at least five lines. A proxy for substance: a bot whose findings mostly move one or two lines is mostly flagging nits.
  • trend: the same counts bucketed by week (Monday start), so you can see whether a bot's volume, usefulness or repetition is changing over time.

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).

Settings

/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).

Requirements

  • 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 skipped
  • Claude Code 2.1.302 or later (tested with 2.1.302)

Install

At 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.

Develop

claude plugin validate .
claude plugin test .
Source 5 files
hooks/register.tsx 658 lines
1// 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}
658
hooks/db.ts 545 lines
1// 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}
545
hooks/github.ts 810 lines
1// 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}
810
hooks/view.tsx 560 lines
1// 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}
560
types/index.d.ts 218 lines
1export 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