SLOPSHOPPER

pr-pulse

PR Pulse: live status, merge readiness, checks, comments, review queue and change alerts for your GitHub PRs, with a collapsible band

newpanebandcommandtoaststatus
v0.1.0MITupdated 2026-10-05gerricchaplin/pr-pulse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-pulse
│ ┃ PR Pulse ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ PR PULSE …[ ↻ Refresh ] [ ▁ Minimise ] │ ┃ ● pr-pulse: refresh failed: JSON Parse error: Unexpected EOF │ ┃ OPEN PRS · 0 ⏺ Read(src/auth.ts) │ ┃ None open. ⎿ Read 6 lines │ ┃ ⚠ JSON Parse error: Unexpected EOF ⏺ 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 │ │ › /pulse │ ⎿ pr-pulse: Watching your latest PR. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · PR Pulse
◆ PR PULSE … [ ↻ Refresh ] [ ▁ Minimise ] [ ✕ Close ] OPEN PRS · 0 None open. ⚠ JSON Parse error: Unexpected EOF
Pane · PR history
◆ PR HISTORY … [ ▁ Minimise ] [ ✕ Close ] YOUR PRS MERGED · LAST 7 DAYS · 0 None merged. ALL PRS MERGED · LAST 7 DAYS · 0 None merged.
Pane · PR reviews
◆ PR REVIEWS … [ ▁ Minimise ] [ ✕ Close ] WAITING ON YOU · 0 Nothing requested from you directly. WAITING ON YOUR TEAM · 0 Nothing requested from your teams.
README

PR Pulse

CI CodeQL Security OpenSSF Scorecard License: MIT

Live GitHub pull-request status inside Claude Code: checks, merge readiness, review comments, your review queue and change alerts, without leaving the terminal.

PR Pulse pane watching a pull request: merge-readiness verdict, check progress, recent activity and workflows

Install

/plugin marketplace add gerricchaplin/pr-pulse
/plugin install pr-pulse@pr-pulse

Requires the GitHub CLI (gh), signed in (gh auth status). PR Pulse makes every GitHub call through gh, as you.

Use

Run /pulse in a GitHub repo. Optional argument: owner/repo, owner/repo#123 or a PR URL.

PR Pulse opens in the same window as your Claude conversation, as three tabbed panes (PR Pulse, PR history and PR reviews), so you can keep working with Claude while it watches.

  • PR Pulse pane: your open PRs, a merge-readiness verdict (✓ READY TO MERGE, ✗ BLOCKED · n, ◐ WAITING ON CHECKS) with the checklist behind it, a check bar, failed checks first, and drill-down from workflow to job to steps with a failure-log excerpt.
  • Comments: unresolved review threads, the conversation and resolved threads.
  • PR history: your PRs and everyone's merged in the last 7 days.
  • PR reviews: PRs waiting on you, and on your teams.
  • Alerts: toasts and a recent-activity list when a check fails or all pass, a review lands, someone comments, the PR becomes ready to merge, or a review is requested from you.
  • Fix with Claude / Address with Claude: drafts a prompt from a failed check's log or a review thread into your prompt box. Nothing is sent until you press Enter.
  • Minimise (m): the panes collapse to a one-line band above the prompt; polling and alerts carry on. /pulse or e expands.

Keys while a pane is focused: r refresh, m minimise, q close, o open the PR in the browser.

Security

PR Pulse has no dependencies and a deliberately small reach: it runs only gh, git, uname and open/xdg-open (for https:// links), never writes files, never calls a model and never sends a prompt for you. CI fails if that reach grows without review. Details, the checks this repository runs, and how to report a vulnerability: SECURITY.md.

GitHub API use

PR Pulse polls (a local plugin cannot receive webhooks): every 15 s for your PRs and checks, every 60 s for history, the review queue and branch status. All calls are serial and go through your own gh token, sharing its hourly limits with everything else you run.

Try it on this repo

This repository doubles as a demo target. scripts/demo.sh <scenario> opens a throwaway draft PR in a known state:

ScenarioWhat PR Pulse shows
greenall checks pass, the "✓ All checks passed" alert
faila failing build with its error log, and Fix with Claude
slowa two-minute check: the pane goes from running to green
flakyfails, then passes after gh run rerun --failed without a false alert
skipa skipped optional check
titlethe required pr-title check failing

scripts/demo.sh clean closes them all.

Develop

npm ci                                   # pinned Claude Code
npx claude plugin validate .
npx claude plugin test .
python3 scripts/check-capabilities.py    # fails if the plugin's reach changed

Guidance for contributors and coding agents: AGENTS.md.

License

MIT

Source 2 files
hooks/register.tsx 1086 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Alert, Check, Comment, Pr, PrRow, ReviewRow, Step, Thread, View } from '../types'
5
6const PANE = 'pr-pulse'
7const HISTORY = 'pr-pulse-history'
8const REVIEWS = 'pr-pulse-reviews'
9const POLL_MS = 15_000
10const HISTORY_DAYS = 7
11const SLOW_POLL_MS = 60_000 // history and review queue change slowly
12
13const prAtom = atom({ plugin: 'pr-pulse', key: 'pr' } as const, null as Pr | null)
14const errorAtom = atom({ plugin: 'pr-pulse', key: 'error' } as const, '')
15const loadedAtom = atom({ plugin: 'pr-pulse', key: 'loadedAt' } as const, 0)
16const viewAtom = atom({ plugin: 'pr-pulse', key: 'view' } as const, {} as View)
17const stepsAtom = atom({ plugin: 'pr-pulse', key: 'steps' } as const, [] as Step[])
18const logAtom = atom({ plugin: 'pr-pulse', key: 'failedLog' } as const, [] as string[])
19const repoAtom = atom({ plugin: 'pr-pulse', key: 'repo' } as const, '')
20const openAtom = atom({ plugin: 'pr-pulse', key: 'openPrs' } as const, [] as PrRow[])
21const historyAtom = atom({ plugin: 'pr-pulse', key: 'history' } as const, [] as PrRow[])
22const teamAtom = atom({ plugin: 'pr-pulse', key: 'teamMerged' } as const, [] as PrRow[])
23const reviewsAtom = atom({ plugin: 'pr-pulse', key: 'reviews' } as const, [] as ReviewRow[])
24const threadsAtom = atom({ plugin: 'pr-pulse', key: 'threads' } as const, [] as Thread[])
25const conversationAtom = atom({ plugin: 'pr-pulse', key: 'conversation' } as const, [] as Comment[])
26const commentsLoadedAtom = atom({ plugin: 'pr-pulse', key: 'commentsLoaded' } as const, false)
27const watchingAtom = atom({ plugin: 'pr-pulse', key: 'watching' } as const, false)
28const targetAtom = atom({ plugin: 'pr-pulse', key: 'target' } as const, '')
29const repoCacheAtom = atom({ plugin: 'pr-pulse', key: 'repoCache' } as const, '')
30const collapsedAtom = atom({ plugin: 'pr-pulse', key: 'collapsed' } as const, false)
31const alertsAtom = atom({ plugin: 'pr-pulse', key: 'alerts' } as const, [] as Alert[])
32const selectedAtom = atom({ plugin: 'pr-pulse', key: 'selected' } as const, 0)
33
34let target = '' // "owner/repo#N" pinned by /pr-pulse <arg>; empty = latest PR of mine
35let repoCache = '' // repo of the session's cwd, else of my latest PR
36let me = '' // github login of the gh user
37let slowAt = 0 // when history and reviews were last fetched
38let slowRepo = ''
39let extrasAt = 0 // when required checks and behind-count were last fetched
40let extrasFor = 0
41let required: string[] = []
42let behind = 0
43let queueSeen: Set<number> | undefined // review-queue PR numbers already announced
44let watchedRoot = '' // git toplevel the pane currently follows
45let timer: { cancel: () => void } | undefined
46let running = false // panes opened and polling for this session
47
48type Theme = {
49  brand: string
50  brandBg: string
51  onBrand: string
52  accent: string
53  muted: string
54  rule: string
55  ok: string
56  fail: string
57  run: string
58  skip: string
59}
60// purple theme: #6000F0 primary with lavender tints and slate neutrals; soft status tones for long viewing
61const DARK: Theme = {
62  brand: '#A37BFF', brandBg: '#6000F0', onBrand: '#F4F0FF', accent: '#C9B8FB', muted: '#A7B1C2', rule: '#48566A',
63  ok: '#86D9A8', fail: '#F2A0A0', run: '#E9CB7C', skip: '#A7B1C2',
64}
65const LIGHT: Theme = {
66  brand: '#6000F0', brandBg: '#6000F0', onBrand: '#FFFFFF', accent: '#6000F0', muted: '#48566A', rule: '#C6D2E1',
67  ok: '#15803D', fail: '#B91C1C', run: '#B45309', skip: '#64748B',
68}
69let theme = DARK
70
71async function loadTheme($: any) {
72  try {
73    const row = (await $.config.list()).find((r: { key: string }) => r.key === 'theme')
74    theme = /light/i.test(String(row?.value ?? '')) ? LIGHT : DARK
75  } catch {
76    theme = DARK
77  }
78}
79
80const icon = (status: string, conclusion: string): { glyph: string; tone: Outcome } => {
81  if (status !== 'COMPLETED' && status !== 'completed') {
82    return { glyph: status === 'QUEUED' || status === 'queued' ? '○' : '◐', tone: 'running' }
83  }
84  const c = conclusion.toUpperCase()
85  if (c === 'SUCCESS') return { glyph: '✓', tone: 'passed' }
86  if (c === 'SKIPPED' || c === 'NEUTRAL') return { glyph: '–', tone: 'skipped' }
87  if (c === 'CANCELLED') return { glyph: '⊘', tone: 'failed' }
88  return { glyph: '✗', tone: 'failed' }
89}
90const isPending = (c: { status: string }) => c.status.toUpperCase() !== 'COMPLETED'
91// github treats a cancelled required check as failing, so a cancelled run must never read as green
92const isFailed = (c: Check) => !isPending(c) && !['SUCCESS', 'SKIPPED', 'NEUTRAL'].includes(c.conclusion.toUpperCase())
93const OUTCOMES = ['failed', 'running', 'passed', 'skipped'] as const
94type Outcome = (typeof OUTCOMES)[number]
95const outcome = (c: Check): Outcome =>
96  isPending(c) ? 'running' : isFailed(c) ? 'failed' : c.conclusion.toUpperCase() === 'SUCCESS' ? 'passed' : 'skipped'
97const tone = (o: Outcome) => ({ failed: theme.fail, running: theme.run, passed: theme.ok, skipped: theme.skip })[o]
98const GLYPH: Record<Outcome, string> = { failed: '✗', running: '◐', passed: '✓', skipped: '–' }
99const BAR_WIDTH = 30
100const LOG_LINES = 20
101const REVIEW_LABEL: Record<string, string> = {
102  APPROVED: 'approved',
103  CHANGES_REQUESTED: 'changes requested',
104  REVIEW_REQUIRED: 'review required',
105  NONE: 'no review needed',
106}
107const ALERTS_KEPT = 8
108const BAND_ALERT_MS = 60_000 // how long the newest alert stays on the collapsed band
109const BODY_CHARS = 700
110const BODY_LINES = 10
111
112let opener = '' // 'open' on macOS, 'xdg-open' elsewhere
113
114// the mod api has no open-url call; the person's own opener does it
115async function openUrl($: any, url: string) {
116  if (!/^https:\/\//.test(url)) return
117  if (!opener) opener = (await $.process.run(['uname'])).stdout.trim() === 'Darwin' ? 'open' : 'xdg-open'
118  const r = await $.process.run([opener, url])
119  if (r.exitCode !== 0) $.ui.toast(`Couldn't open ${url}`)
120}
121
122async function gh($: any, args: string[]) {
123  const r = await $.process.run(['gh', ...args], { timeoutMs: 30_000 })
124  if (r.exitCode !== 0) throw new Error((r.stderr || r.stdout || 'gh failed').trim().split('\n')[0])
125  return JSON.parse(r.stdout)
126}
127
128async function refresh($: any, force = false) {
129  try {
130    let repo: string
131    if (target) {
132      repo = /^(.+)#\d+$/.exec(target)![1]!
133    } else {
134      if (!repoCache) {
135        const here = await $.process.run(['gh', 'repo', 'view', '--json', 'nameWithOwner'])
136        if (here.exitCode === 0) {
137          repoCache = JSON.parse(here.stdout).nameWithOwner
138        } else {
139          const found = await gh($, [
140            'search', 'prs', '--author', '@me', '--sort', 'created', '--order', 'desc',
141            '--limit', '1', '--json', 'repository',
142          ])
143          if (!found.length) throw new Error('Not in a GitHub repo and no PRs found for your account')
144          repoCache = found[0].repository.nameWithOwner
145        }
146      }
147      repo = repoCache
148    }
149    const rows = (list: any[]): PrRow[] =>
150      list.map(p => ({
151        number: p.number, title: p.title, url: p.url, state: p.state, isDraft: !!p.isDraft,
152        author: p.author?.login ?? '',
153        when: String(p.mergedAt || p.closedAt || p.updatedAt || '').slice(0, 16).replace('T', ' '),
154      }))
155    const fields = 'number,title,url,state,isDraft,author,mergedAt,closedAt,updatedAt'
156    if (!me) me = (await gh($, ['api', 'user'])).login
157    const since = new Date(Date.now() - HISTORY_DAYS * 86_400_000).toISOString().slice(0, 10)
158    const openRaw = await gh($, [
159      'pr', 'list', '-R', repo, '--author', '@me', '--state', 'open', '--limit', '20', '--json', `${fields},reviewDecision,statusCheckRollup`,
160    ])
161    const open: PrRow[] = rows(openRaw).map((r, i) => {
162      const cs = latestRuns(openRaw[i].statusCheckRollup ?? [])
163      const failing = cs.filter(isFailed).length
164      const checks = !cs.length ? 'none' : failing ? 'failed' : cs.some(isPending) ? 'running' : 'passed'
165      return { ...r, checks, failing, review: openRaw[i].reviewDecision || 'NONE' }
166    })
167    await update($, repoAtom, () => repo)
168    await update($, openAtom, () => open)
169    let history: PrRow[] = await read($, historyAtom)
170    if (force || repo !== slowRepo || Date.now() - slowAt >= SLOW_POLL_MS) {
171      const teamMerged = rows(await gh($, [
172        'pr', 'list', '-R', repo, '--state', 'merged', '--search', `merged:>=${since}`, '--limit', '200', '--json', fields,
173      ])).sort((a, b) => b.when.localeCompare(a.when))
174      history = teamMerged.filter(p => p.author === me)
175      const requested = await gh($, [
176        'pr', 'list', '-R', repo, '--state', 'open', '--search', 'review-requested:@me', '--limit', '100',
177        '--json', `${fields},reviewRequests`,
178      ])
179      const reviews: ReviewRow[] = rows(requested).map((r, i) => {
180        const asked = (requested[i].reviewRequests ?? []) as { login?: string; slug?: string; name?: string }[]
181        return {
182          ...r,
183          direct: asked.some(q => q.login === me),
184          teams: asked.filter(q => !q.login).map(q => (q.slug ?? q.name ?? '').replace(/^[^/]+\//, '')),
185        }
186      })
187      await update($, historyAtom, () => history)
188      await update($, teamAtom, () => teamMerged)
189      await update($, reviewsAtom, () => reviews)
190      if (queueSeen && repo === slowRepo) {
191        for (const r of reviews.filter(r => !queueSeen!.has(r.number))) {
192          await alert($, `👀 Review requested${r.direct ? ' from you' : ''}: #${r.number} ${r.title} (${r.author})`, 'info', r.url)
193        }
194      }
195      queueSeen = new Set(reviews.map(r => r.number))
196      slowAt = Date.now()
197      slowRepo = repo
198      $.ui.log(`${repo} open=${open.length} mine-merged=${history.length} all-merged=${teamMerged.length} reviews=${reviews.length}`, { to: 'debug' })
199    }
200
201    const chosen: number = await read($, selectedAtom)
202    const pinned = target ? Number(/#(\d+)$/.exec(target)![1]) : 0
203    const number = [pinned, chosen].find(n => n && [...open, ...history].some(p => p.number === n)) || pinned || open[0]?.number || history[0]?.number
204    if (!number) {
205      await update($, prAtom, () => null)
206      throw new Error(`No PRs of yours in ${repo}`)
207    }
208    await update($, selectedAtom, () => number)
209    const v = await gh($, [
210      'pr', 'view', String(number), '-R', repo, '--json',
211      'title,url,state,isDraft,reviewDecision,mergeable,mergeStateStatus,baseRefName,headRefName,latestReviews,statusCheckRollup',
212    ])
213    const checks = latestRuns(v.statusCheckRollup ?? [])
214    let unresolved = 0
215    let threads = 0
216    let commentCount = 0
217    let lastCommenter = ''
218    try {
219      const [owner, name] = repo.split('/')
220      const g = await gh($, ['api', 'graphql', '-f', `query=${COUNTS_QUERY}`, '-f', `o=${owner}`, '-f', `n=${name}`, '-F', `num=${number}`])
221      const p = g.data.repository.pullRequest
222      const nodes = p.reviewThreads.nodes as { isResolved: boolean; comments: { totalCount: number; nodes: Latest[] } }[]
223      threads = nodes.length
224      unresolved = nodes.filter(n => !n.isResolved).length
225      commentCount = p.comments.totalCount + nodes.reduce((a, n) => a + n.comments.totalCount, 0)
226      const latest = [...p.comments.nodes, ...nodes.flatMap(n => n.comments.nodes)] as Latest[]
227      latest.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
228      lastCommenter = latest[0]?.author?.login ?? ''
229    } catch {
230      unresolved = -1
231    }
232    if (force || number !== extrasFor || Date.now() - extrasAt >= SLOW_POLL_MS) {
233      // `gh pr checks` exits non-zero while checks fail or run, the json is still on stdout
234      const req = await $.process.run(['gh', 'pr', 'checks', String(number), '-R', repo, '--required', '--json', 'name'], { timeoutMs: 30_000 })
235      try {
236        required = (JSON.parse(req.stdout || '[]') as { name: string }[]).map(c => c.name)
237      } catch {
238        required = []
239      }
240      try {
241        behind = (await gh($, ['api', `repos/${repo}/compare/${v.baseRefName}...${encodeURIComponent(v.headRefName)}`])).behind_by ?? 0
242      } catch {
243        behind = 0
244      }
245      extrasAt = Date.now()
246      extrasFor = number
247    }
248    const pr: Pr = {
249      repo, number, title: v.title, url: v.url, state: v.state, isDraft: v.isDraft,
250      review: v.reviewDecision || 'NONE', mergeable: v.mergeable, checks, unresolved, threads,
251      mergeState: v.mergeStateStatus ?? 'UNKNOWN', base: v.baseRefName ?? 'main', behind, required,
252      latestReviews: (v.latestReviews ?? []).map((r: any) => ({ author: r.author?.login ?? '', state: r.state ?? '' })),
253      commentCount, lastCommenter,
254    }
255    const prev: Pr | null = await read($, prAtom)
256    await update($, prAtom, () => pr)
257    await announcePrChanges($, prev, pr)
258    await update($, errorAtom, () => '')
259    await update($, loadedAtom, () => Date.now())
260    const running = checks.filter(isPending).length
261    const failed = checks.filter(isFailed).length
262    $.ui.status(
263      `PR #${number} ${pr.state.toLowerCase()}${unresolved > 0 ? ` · ${unresolved} unresolved` : ''} · ${failed ? `${failed} failed` : running ? `${running} running` : 'checks ok'}`,
264    )
265    const view: View = await read($, viewAtom)
266    if (view.job) await loadSteps($, pr, view.job)
267    if (view.comments) await loadComments($, pr)
268  } catch (err) {
269    $.ui.log(`refresh failed: ${String((err as Error).message ?? err)}`, { to: 'debug' })
270    await update($, errorAtom, () => String((err as Error).message ?? err))
271  }
272}
273
274// the rollup keeps superseded runs (a re-run, or a title edit re-triggering a check); keep each check's newest
275function latestRuns(rollup: any[]): Check[] {
276  // a queued re-run has no real start time yet (github reports 0001-01-01); it still supersedes finished runs
277  const rank = (c: any) => {
278    const live = String(c.status ?? '').toUpperCase() !== 'COMPLETED' && c.state !== 'SUCCESS' && c.state !== 'FAILURE'
279    const at = String(c.startedAt ?? c.createdAt ?? '')
280    return live && (!at || at.startsWith('0001')) ? '9999' : at
281  }
282  const newest = new Map<string, any>()
283  for (const c of rollup) {
284    const key = `${c.workflowName ?? ''}|${c.name ?? c.context ?? '?'}`
285    const seen = newest.get(key)
286    if (!seen || rank(c) >= rank(seen)) newest.set(key, c)
287  }
288  return [...newest.values()].map(c => ({
289    name: c.name ?? c.context ?? '?',
290    workflow: c.workflowName || 'Other checks',
291    status: c.status ?? (c.state === 'PENDING' ? 'IN_PROGRESS' : 'COMPLETED'),
292    conclusion: c.conclusion ?? c.state ?? '',
293    url: c.detailsUrl ?? c.targetUrl ?? '',
294  }))
295}
296
297const COUNTS_QUERY = `query($o:String!,$n:String!,$num:Int!){repository(owner:$o,name:$n){pullRequest(number:$num){
298reviewThreads(first:100){nodes{isResolved comments(last:1){totalCount nodes{author{login} createdAt}}}}
299comments(last:1){totalCount nodes{author{login} createdAt}}}}}`
300type Latest = { author?: { login: string }; createdAt?: string }
301
302async function alert($: any, text: string, tone: Alert['tone'], url = '') {
303  $.ui.toast(text, { timeoutMs: 8000 })
304  await update($, alertsAtom, (list: Alert[]) => [{ at: Date.now(), text, tone, url }, ...list].slice(0, ALERTS_KEPT))
305}
306
307// compare against the previous poll of the same PR; the first poll only records
308async function announcePrChanges($: any, prev: Pr | null, pr: Pr) {
309  if (!prev || prev.number !== pr.number || prev.repo !== pr.repo || !prev.mergeState) return
310  const tag = `#${pr.number}`
311  const failedBefore = new Set(prev.checks.filter(isFailed).map(c => c.name))
312  for (const c of pr.checks.filter(c => isFailed(c) && !failedBefore.has(c.name))) {
313    const cancelled = c.conclusion.toUpperCase() === 'CANCELLED'
314    await alert($, `${cancelled ? '⊘' : '✗'} ${c.name} ${cancelled ? 'cancelled' : 'failed'} on ${tag}`, 'fail', c.url)
315  }
316  const settled = (p: Pr) => p.checks.length > 0 && !p.checks.some(isPending)
317  if (!settled(prev) && settled(pr) && !pr.checks.some(isFailed)) await alert($, `✓ All checks passed on ${tag}`, 'ok', pr.url)
318  const before = new Map(prev.latestReviews.map(r => [r.author, r.state]))
319  for (const r of pr.latestReviews) {
320    if (r.author === me || before.get(r.author) === r.state) continue
321    if (r.state === 'APPROVED') await alert($, `✅ ${tag} approved by ${r.author}`, 'ok', pr.url)
322    else if (r.state === 'CHANGES_REQUESTED') await alert($, `✋ ${r.author} requested changes on ${tag}`, 'fail', pr.url)
323    else if (r.state === 'COMMENTED') await alert($, `💬 ${r.author} reviewed ${tag}`, 'info', pr.url)
324  }
325  if (pr.commentCount > prev.commentCount && pr.lastCommenter && pr.lastCommenter !== me) {
326    const n = pr.commentCount - prev.commentCount
327    await alert($, `💬 ${n} new comment${n === 1 ? '' : 's'} on ${tag} from ${pr.lastCommenter}`, 'info', pr.url)
328  }
329  if (prev.mergeState !== 'CLEAN' && pr.mergeState === 'CLEAN') await alert($, `● ${tag} is ready to merge`, 'ok', pr.url)
330  if (prev.state !== pr.state && pr.state === 'MERGED') await alert($, `⑂ ${tag} merged`, 'ok', pr.url)
331  if (prev.state !== pr.state && pr.state === 'CLOSED') await alert($, `⊘ ${tag} closed`, 'fail', pr.url)
332}
333
334// keep the lines leading up to the last ##[error] (else the tail), minus runner bookkeeping
335function failureExcerpt(raw: string) {
336  const lines = raw
337    .split('\n')
338    .map(l => (l.split('\t').length >= 3 ? l.split('\t').slice(2).join('\t') : l).replace(/^\S+Z /, '').replace(/\x1b\[[0-9;]*m/g, ''))
339    .filter(l => l.trim() && !/^##\[(end)?group\]/.test(l) && !/^Cleaning up orphan processes/.test(l))
340  const lastError = lines.map(l => l.startsWith('##[error]')).lastIndexOf(true)
341  const end = lastError >= 0 ? lastError + 1 : lines.length
342  return lines.slice(Math.max(0, end - LOG_LINES), end).map(l => l.replace(/^##\[error\]/, '✗ '))
343}
344
345const COMMENTS_QUERY = `query($o:String!,$n:String!,$num:Int!){repository(owner:$o,name:$n){pullRequest(number:$num){
346reviewThreads(first:100){nodes{isResolved isOutdated path line comments(first:30){nodes{author{login} body createdAt url}}}}
347comments(last:30){nodes{author{login} body createdAt url}}
348reviews(last:30){nodes{author{login} state body submittedAt url}}}}}`
349
350const clip = (body: string) => {
351  const lines = body.replace(/\r/g, '').trim().split('\n')
352  let text = lines.slice(0, BODY_LINES).join('\n')
353  if (text.length > BODY_CHARS) text = text.slice(0, BODY_CHARS)
354  return text.length < body.trim().length ? `${text.trimEnd()} …` : text
355}
356const stamp = (iso: string) => String(iso ?? '').slice(5, 16).replace('T', ' ')
357
358async function loadComments($: any, pr: Pr) {
359  try {
360    const [owner, name] = pr.repo.split('/')
361    const g = await gh($, ['api', 'graphql', '-f', `query=${COMMENTS_QUERY}`, '-f', `o=${owner}`, '-f', `n=${name}`, '-F', `num=${pr.number}`])
362    const p = g.data.repository.pullRequest
363    const comment = (c: any): Comment => ({
364      author: c.author?.login ?? 'ghost', body: clip(c.body ?? ''), when: stamp(c.createdAt ?? c.submittedAt), url: c.url ?? '',
365      state: c.state && c.state !== 'COMMENTED' ? c.state : '',
366    })
367    const threads: Thread[] = p.reviewThreads.nodes.map((t: any) => ({
368      path: t.path ?? '', line: t.line ?? 0, isResolved: !!t.isResolved, isOutdated: !!t.isOutdated,
369      url: t.comments.nodes[0]?.url ?? pr.url, comments: t.comments.nodes.map(comment),
370    }))
371    const conversation: Comment[] = [
372      ...p.comments.nodes.map(comment),
373      ...p.reviews.nodes.filter((r: any) => (r.body ?? '').trim() || (r.state && r.state !== 'COMMENTED')).map(comment),
374    ].sort((a, b) => a.when.localeCompare(b.when))
375    await update($, threadsAtom, () => threads)
376    await update($, conversationAtom, () => conversation)
377    await update($, commentsLoadedAtom, () => true)
378  } catch (err) {
379    await update($, errorAtom, () => String((err as Error).message ?? err))
380  }
381}
382
383async function fetchFailureLog($: any, pr: Pr, job: Check): Promise<string[]> {
384  const m = /runs\/(\d+)\/job\/(\d+)/.exec(job.url)
385  if (!m) return []
386  // --log-failed prints "<job>\t<step>\t<timestamp> <message>" per line
387  const log = await $.process.run(['gh', 'run', 'view', m[1]!, '-R', pr.repo, '--job', m[2]!, '--log-failed'], { timeoutMs: 60_000 })
388  return failureExcerpt(log.exitCode === 0 ? log.stdout : log.stderr)
389}
390
391// a draft in the person's prompt box, never sent on their behalf
392async function draftPrompt($: any, text: string) {
393  try {
394    const box = await $.prompt.read()
395    const r = await $.prompt.fill({ text: box.text.trim() ? `\n\n${text}` : text, mode: 'append' })
396    $.ui.toast(r.isFilled ? 'Draft added to your prompt: review it, then press Enter' : "Couldn't fill the prompt (is a dialog open?)", { timeoutMs: 6000 })
397  } catch (err) {
398    $.ui.toast(`Couldn't fill the prompt: ${String((err as Error).message ?? err)}`, { timeoutMs: 6000 })
399  }
400}
401
402async function fixCheck($: any, pr: Pr, job: Check) {
403  const log = await fetchFailureLog($, pr, job)
404  await draftPrompt($, [
405    `The "${job.name}" check (${job.workflow}) failed on PR #${pr.number} in ${pr.repo}: ${pr.url}`,
406    `Job: ${job.url}`,
407    log.length ? `Failure log excerpt:\n\`\`\`\n${log.join('\n')}\n\`\`\`` : 'Fetch the failing log with `gh run view --log-failed`.',
408    'Find the root cause and fix it on this branch. Tell me what you changed.',
409  ].join('\n'))
410}
411
412async function addressThread($: any, pr: Pr, th: Thread) {
413  const quoted = th.comments.map(c => `> **${c.author}**: ${c.body.replace(/\n/g, '\n> ')}`).join('\n>\n')
414  await draftPrompt($, [
415    `Address this review thread on PR #${pr.number} (${th.url}) at \`${th.path}${th.line ? `:${th.line}` : ''}\`:`,
416    quoted,
417    'Make the change if it is right, or tell me why not. Summarise what you did so I can reply on the thread.',
418  ].join('\n'))
419}
420
421async function loadSteps($: any, pr: Pr, job: Check) {
422  const m = /runs\/(\d+)\/job\/(\d+)/.exec(job.url)
423  if (!m) return update($, stepsAtom, () => [])
424  try {
425    const run = await gh($, ['run', 'view', m[1]!, '-R', pr.repo, '--json', 'jobs'])
426    const j = run.jobs.find((x: any) => String(x.databaseId) === m[2])
427    await update($, stepsAtom, () =>
428      (j?.steps ?? []).map((s: any) => ({ name: s.name, status: s.status, conclusion: s.conclusion ?? '' })),
429    )
430    if (!isFailed(job)) return update($, logAtom, () => [])
431    const lines = await fetchFailureLog($, pr, job)
432    await update($, logAtom, () => lines)
433  } catch (err) {
434    await update($, errorAtom, () => String((err as Error).message ?? err))
435  }
436}
437
438// Follow the agent's `cd` into a different top-level git repo. A pinned
439// target (/pr-pulse owner/repo#N) or a non-repo directory leaves the view alone.
440async function followCwd($: any, cwd: string) {
441  if (target) return
442  const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
443  if (top.exitCode !== 0) return
444  const root = top.stdout.trim()
445  if (!root || root === watchedRoot) return
446  const here = await $.process.run(['gh', 'repo', 'view', '--json', 'nameWithOwner'], { cwd: root })
447  if (here.exitCode !== 0) return
448  watchedRoot = root
449  repoCache = JSON.parse(here.stdout).nameWithOwner
450  await update($, viewAtom, () => ({}))
451  await update($, selectedAtom, () => 0)
452  await refresh($)
453}
454
455async function openPanes($: any) {
456  await $.ui.open({ id: PANE, title: 'PR Pulse' })
457  await $.ui.open({ id: HISTORY, title: 'PR history' })
458  await $.ui.open({ id: REVIEWS, title: 'PR reviews' })
459}
460
461async function closePanes($: any) {
462  await $.ui.close({ id: REVIEWS })
463  await $.ui.close({ id: HISTORY })
464  await $.ui.close({ id: PANE })
465}
466
467// panes away, polling and alerts carry on, the AbovePrompt band summarises
468async function minimise($: any) {
469  await update($, collapsedAtom, () => true)
470  await closePanes($)
471}
472
473async function expand($: any, number = 0) {
474  if (number) {
475    await update($, selectedAtom, () => number)
476    await update($, viewAtom, () => ({}))
477  }
478  await update($, collapsedAtom, () => false)
479  await openPanes($)
480  await refresh($)
481}
482
483async function start($: any) {
484  running = true
485  await update($, watchingAtom, () => true)
486  await update($, collapsedAtom, () => false)
487  await update($, targetAtom, () => target)
488  await update($, repoCacheAtom, () => repoCache)
489  await loadTheme($)
490  await openPanes($)
491  await refresh($)
492  if (!timer) timer = $.clock.every(POLL_MS, () => void tick($))
493}
494
495// a reload starts the module over; pick the watch back up from session state
496async function resume($: any) {
497  if (running || !(await read($, watchingAtom))) return
498  running = true
499  target = await read($, targetAtom)
500  repoCache = await read($, repoCacheAtom)
501  await loadTheme($)
502  void refresh($, true)
503  if (!timer) timer = $.clock.every(POLL_MS, () => void tick($))
504}
505
506async function stop($: any) {
507  running = false
508  await update($, watchingAtom, () => false)
509  timer?.cancel()
510  timer = undefined
511  $.ui.status(undefined)
512  await update($, collapsedAtom, () => false)
513  await closePanes($)
514}
515
516async function tick($: any) {
517  await refresh($)
518}
519
520
521function paneBar(ui: any, $: any, title: string, repo: string) {
522  const { Box, Text, Button } = ui
523  return (
524    <Box flexDirection="row" justifyContent="space-between">
525      <Text>
526        <Text backgroundColor={theme.brandBg} color={theme.onBrand}> ◆ {title} </Text>
527        <Text color={theme.muted}>  {repo || '…'}</Text>
528      </Text>
529      <Box flexDirection="row" gap={1}>
530        <Button key="minimise" label="▁ Minimise" hotkey="m" onPress={() => void minimise($)} />
531        <Button key="close" label="✕ Close" hotkey="q" onPress={() => void stop($)} />
532      </Box>
533    </Box>
534  )
535}
536
537// the one thing standing between a PR and merging, as a few words for the band
538function blocker(p: PrRow) {
539  if (p.failing) return `${p.failing} failing`
540  if (p.checks === 'running') return 'checks running'
541  if (p.isDraft) return 'draft'
542  if (p.review === 'CHANGES_REQUESTED') return 'changes requested'
543  if (p.review === 'REVIEW_REQUIRED') return 'needs review'
544  return 'ready'
545}
546
547function band(ui: any, $: any, open: PrRow[], selected: number, queue: number, latest: Alert | undefined) {
548  const { Box, Text, Button } = ui
549  const t = theme
550  const glyph = (p: PrRow) =>
551    p.checks === 'failed' ? { g: '✗', c: t.fail } : p.checks === 'running' ? { g: '◐', c: t.run } : p.checks === 'passed' ? { g: '✓', c: t.ok } : { g: '○', c: t.muted }
552  return (
553    <Box flexDirection="column">
554      <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
555        <Text backgroundColor={t.brandBg} color={t.onBrand}> ◆ PR </Text>
556        {open.length === 0 && <Text color={t.muted}>no open PRs</Text>}
557        {open.map(p => {
558          const gl = glyph(p)
559          const b = blocker(p)
560          return (
561            <Box key={`chip-${p.number}`} flexDirection="row">
562              <Text color={gl.c}>{gl.g} </Text>
563              <Button plain key={`chip-open-${p.number}`} dimColor={p.number !== selected} label={`#${p.number}`} onPress={() => void expand($, p.number)} />
564              <Text color={b === 'ready' ? t.ok : t.muted}> {b}</Text>
565            </Box>
566          )
567        })}
568        {queue > 0 && <Text color={t.accent}>👀 {queue} to review</Text>}
569        <Button key="expand" label="⤢ Expand" hotkey="e" onPress={() => void expand($)} />
570      </Box>
571      {latest && (
572        <Text color={latest.tone === 'fail' ? t.fail : latest.tone === 'ok' ? t.ok : t.muted}>
573          {'      '}{latest.text} · {new Date(latest.at).toLocaleTimeString().slice(0, 5)}
574        </Text>
575      )}
576    </Box>
577  )
578}
579
580function heading(ui: any, label: string, count: number, color = theme.accent) {
581  const { Box, Text } = ui
582  return (
583    <Box marginTop={1}>
584      <Text color={color}>{`${label.toUpperCase()} · ${count}`}</Text>
585    </Box>
586  )
587}
588
589export const register: Register = on => {
590  on('session.start', async ($, e, next) => {
591    await $.command.register({
592      name: 'pulse',
593      description: 'PR Pulse: PR status, checks, comments and review queue. Optional arg: owner/repo#123',
594    })
595    await resume($)
596    return next(e)
597  })
598
599  on('classic.CwdChanged', async ($, e: any, next) => {
600    $.ui.log(`cwd changed to ${e.new_cwd}`, { to: 'debug' })
601    await followCwd($, String(e.new_cwd ?? ''))
602    return next(e)
603  })
604
605  on('command.run', { command: 'pulse' }, async ($, e: any) => {
606    const arg = String(e.args ?? '').trim()
607    if (running && !arg) {
608      if (await read($, collapsedAtom)) {
609        await expand($)
610        return { text: 'Expanded PR Pulse.' }
611      }
612      await refresh($, true)
613      return { text: `Already watching ${target || repoCache || 'your latest PR'}; refreshed.` }
614    }
615    const url = /github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/.exec(arg)
616    repoCache = /^[\w.-]+\/[\w.-]+$/.test(arg) ? arg : ''
617    target = url ? `${url[1]}#${url[2]}` : /^.+#\d+$/.test(arg) ? arg : ''
618    await update($, viewAtom, () => ({}))
619    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
620    watchedRoot = top.exitCode === 0 ? top.stdout.trim() : ''
621    const text = target ? `Watching ${target}` : repoCache ? `Watching ${repoCache}` : 'Watching your latest PR.'
622    await start($)
623    return { text }
624  })
625
626  on('ui.close', async ($, e, next) => {
627    if (e.id === PANE && e.origin.kind === 'person' && running && !(await read($, collapsedAtom))) void stop($)
628    return next(e)
629  })
630
631  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
632    if (e.props.hasSurvey || !(await read($, watchingAtom)) || !(await read($, collapsedAtom))) return next(e)
633    const open: PrRow[] = await read($, openAtom)
634    const selected: number = await read($, selectedAtom)
635    const queue: ReviewRow[] = await read($, reviewsAtom)
636    const alerts: Alert[] = await read($, alertsAtom)
637    const latest = alerts[0] && Date.now() - alerts[0].at < BAND_ALERT_MS ? alerts[0] : undefined
638    return band($.ui.resolve(e), $, open, selected, queue.length, latest)
639  })
640
641  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
642    const { Box, Text, Button } = $.ui.resolve(e)
643    const t = theme
644    const pr = await read($, prAtom)
645    const repo: string = await read($, repoAtom)
646    const openPrs: PrRow[] = await read($, openAtom)
647    const selected: number = await read($, selectedAtom)
648    const error = await read($, errorAtom)
649    const loaded: number = await read($, loadedAtom)
650    const view: View = await read($, viewAtom)
651    const steps: Step[] = await read($, stepsAtom)
652    const failedLog: string[] = await read($, logAtom)
653    const alerts: Alert[] = await read($, alertsAtom)
654
655    const pick = (n: number) => async () => {
656      await update($, selectedAtom, () => n)
657      await update($, viewAtom, () => ({}))
658      await update($, stepsAtom, () => [])
659      void refresh($)
660    }
661    const go = (v: View) => async () => {
662      await update($, viewAtom, () => v)
663      await update($, stepsAtom, () => [])
664      await update($, logAtom, () => [])
665      if (v.comments) await update($, commentsLoadedAtom, () => false)
666      if (v.job && pr) await loadSteps($, pr, v.job)
667      if (v.comments && pr) await loadComments($, pr)
668    }
669
670    const topBar = (
671      <Box flexDirection="row" justifyContent="space-between">
672        <Text>
673          <Text backgroundColor={t.brandBg} color={t.onBrand}> ◆ PR PULSE </Text>
674          <Text color={t.muted}>  {repo || '…'}</Text>
675        </Text>
676        <Box flexDirection="row" gap={1}>
677          <Button key="refresh" label="↻ Refresh" hotkey="r" onPress={() => void refresh($, true)} />
678          <Button key="minimise" label="▁ Minimise" hotkey="m" onPress={() => void minimise($)} />
679          <Button key="close" label="✕ Close" hotkey="q" onPress={() => void stop($)} />
680        </Box>
681      </Box>
682    )
683    const section = (label: string, count?: number, color = t.accent) => (
684      <Box marginTop={1}>
685        <Text color={color}>{count === undefined ? label.toUpperCase() : `${label.toUpperCase()} · ${count}`}</Text>
686      </Box>
687    )
688    const errorLine = error ? <Text color={t.fail}>⚠ {error}</Text> : null
689
690    const openSection = (
691      <Box flexDirection="column">
692        {section('Open PRs', openPrs.length)}
693        {openPrs.length === 0 && <Text color={t.muted}>None open.</Text>}
694        {openPrs.map(p => {
695          const isSel = p.number === selected
696          return (
697            <Box key={`pr-row-${p.number}`} flexDirection="row">
698              <Text color={t.brand}>{isSel ? '▸ ' : '  '}</Text>
699              <Button plain key={`pr-${p.number}`} dimColor={!isSel} label={`#${p.number} ${p.title}`} onPress={pick(p.number)} />
700              {p.isDraft && <Text color={t.muted}> · draft</Text>}
701            </Box>
702          )
703        })}
704      </Box>
705    )
706
707    if (!pr) {
708      return (
709        <Box flexDirection="column">
710          {topBar}
711          {openSection}
712          {errorLine ?? <Text color={t.muted}>Loading PR…</Text>}
713        </Box>
714      )
715    }
716
717    const groups = new Map<string, Check[]>()
718    for (const c of pr.checks) groups.set(c.workflow, [...(groups.get(c.workflow) ?? []), c])
719    const total = pr.checks.length
720    const done = pr.checks.filter(c => !isPending(c)).length
721    const count = (cs: Check[]) => {
722      const n: Record<Outcome, number> = { failed: 0, running: 0, passed: 0, skipped: 0 }
723      for (const c of cs) n[outcome(c)]++
724      return n
725    }
726    const counts = count(pr.checks)
727
728    const bar = (n: Record<Outcome, number>, of: number) => {
729      const cells = (k: number) => (of ? Math.round((k / of) * BAR_WIDTH) : 0)
730      const order: Outcome[] = ['failed', 'passed', 'skipped', 'running']
731      const widths = order.map(o => cells(n[o]))
732      const rest = Math.max(0, BAR_WIDTH - widths.reduce((a, b) => a + b, 0))
733      return (
734        <Text>
735          {order.map((o, i) => <Text color={tone(o)}>{'█'.repeat(widths[i]!)}</Text>)}
736          <Text color={t.rule}>{'░'.repeat(rest)}</Text>
737        </Text>
738      )
739    }
740    const tally = (n: Record<Outcome, number>) => (
741      <Text>
742        {OUTCOMES.filter(o => n[o]).map(o => (
743          <Text color={tone(o)}>{GLYPH[o]} {n[o]} {o}   </Text>
744        ))}
745      </Text>
746    )
747    const checkRow = (c: Check, key: string, label: string) => {
748      const i = icon(c.status, c.conclusion)
749      return (
750        <Box key={key} flexDirection="row">
751          <Text color={tone(i.tone)}>{i.glyph} </Text>
752          <Button plain label={label} onPress={go({ workflow: c.workflow, job: c })} />
753        </Box>
754      )
755    }
756
757    const review = pr.review.toUpperCase()
758    const reviewColor = review === 'APPROVED' ? t.ok : review === 'CHANGES_REQUESTED' ? t.fail : t.run
759    const reqChecks = pr.required.length ? pr.checks.filter(c => pr.required.includes(c.name)) : pr.checks
760    const reqFailed = reqChecks.filter(isFailed).length
761    const reqPending = reqChecks.filter(isPending).length
762    const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
763    const checkWord = pr.required.length ? 'required check' : 'check'
764    // ok: true passes, false blocks, null is waiting or advisory
765    const readiness: { ok: boolean | null; label: string }[] = [
766      { ok: !pr.isDraft, label: pr.isDraft ? 'draft' : 'ready for review' },
767      {
768        ok: reqFailed ? false : reqPending ? null : true,
769        label: reqFailed ? `${plural(reqFailed, checkWord)} failing` : reqPending ? `${plural(reqPending, checkWord)} running` : `${checkWord}s passed`,
770      },
771      {
772        ok: review === 'APPROVED' || review === 'NONE',
773        label: review === 'APPROVED' ? 'approved' : review === 'CHANGES_REQUESTED' ? 'changes requested' : review === 'NONE' ? 'no review required' : 'needs approval',
774      },
775      { ok: pr.unresolved <= 0, label: pr.unresolved > 0 ? plural(pr.unresolved, 'unresolved thread') : 'threads resolved' },
776      { ok: pr.mergeable !== 'CONFLICTING', label: pr.mergeable === 'CONFLICTING' ? 'merge conflicts' : 'no conflicts' },
777      // behind is only a blocker when a branch rule demands up-to-date branches, which github reports as BEHIND
778      {
779        ok: !pr.behind ? true : pr.mergeState === 'BEHIND' ? false : null,
780        label: pr.behind ? `${plural(pr.behind, 'commit')} behind ${pr.base}` : `up to date with ${pr.base}`,
781      },
782    ]
783    const waiting = readiness.some(r => r.ok === null && r.label.includes('running'))
784    // github can block on rules the checklist cannot see (signed commits, codeowners, deployments)
785    if (pr.mergeState === 'BLOCKED' && !waiting && readiness.every(r => r.ok !== false)) {
786      readiness.push({ ok: false, label: 'blocked by a branch rule' })
787    }
788    const blockers = readiness.filter(r => r.ok === false).length
789    const isReady = pr.state === 'OPEN' && !blockers && !waiting
790    const verdict =
791      pr.state === 'MERGED' ? { text: ' ⑂ MERGED ', bg: t.brandBg }
792      : pr.state === 'CLOSED' ? { text: ' ⊘ CLOSED ', bg: t.rule }
793      : isReady ? { text: ' ✓ READY TO MERGE ', bg: t.ok }
794      : blockers ? { text: ` ✗ BLOCKED · ${blockers} `, bg: t.fail }
795      : { text: ' ◐ WAITING ON CHECKS ', bg: t.run }
796    const readinessLine = (
797      <Box flexDirection="column" marginTop={1}>
798        <Text>
799          <Text backgroundColor={verdict.bg} color="#0F1729">{verdict.text}</Text>
800        </Text>
801        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
802          {readiness.map(r => (
803            <Text color={r.ok === true ? t.ok : r.ok === false ? t.fail : t.run}>
804              {r.ok === true ? '✓' : r.ok === false ? '✗' : '◐'} {r.label}
805            </Text>
806          ))}
807        </Box>
808      </Box>
809    )
810
811    const card = (
812      <Box flexDirection="column" borderStyle="round" borderColor={t.brand} paddingX={1} marginTop={1}>
813        <Box flexDirection="row" gap={1}>
814          <Button key="open-pr" variant="primary" label="↗ Open PR" hotkey="o" onPress={() => void openUrl($, pr.url)} />
815          <Text>{pr.title}</Text>
816        </Box>
817        <Text>
818          <Text color={t.accent}>#{pr.number}</Text>
819          <Text color={t.muted}>  ·  </Text>
820          <Text color={pr.isDraft ? t.muted : t.ok}>{pr.isDraft ? '○ draft' : `● ${pr.state.toLowerCase()}`}</Text>
821          <Text color={t.muted}>  ·  </Text>
822          <Text color={reviewColor}>● {REVIEW_LABEL[review] ?? review.toLowerCase().replace(/_/g, ' ')}</Text>
823          <Text color={t.muted}>  ·  </Text>
824          <Text color={pr.mergeable === 'CONFLICTING' ? t.fail : t.muted}>{pr.mergeable.toLowerCase()}</Text>
825        </Text>
826        <Box flexDirection="row">
827          <Text color={pr.unresolved > 0 ? t.run : t.ok}>{pr.unresolved > 0 ? '💬 ' : '✓ '}</Text>
828          <Button
829            plain
830            key="comments"
831            label={`${pr.unresolved > 0 ? `${pr.unresolved} unresolved` : 'no unresolved comments'} · ${pr.threads} thread${pr.threads === 1 ? '' : 's'} · conversation ›`}
832            onPress={go({ comments: true })}
833          />
834        </Box>
835        {readinessLine}
836        <Text color={t.muted}>updated {new Date(loaded).toLocaleTimeString()} · auto-refresh {POLL_MS / 1000}s</Text>
837        {errorLine}
838      </Box>
839    )
840    const back = (label: string, v: View) => (
841      <Box marginTop={1}>
842        <Button plain key={`back-${label}`} label={`← ${label}`} onPress={go(v)} />
843      </Box>
844    )
845
846    if (view.comments) {
847      const { Markdown } = $.ui.resolve(e)
848      const threads: Thread[] = await read($, threadsAtom)
849      const conversation: Comment[] = await read($, conversationAtom)
850      const loadedComments: boolean = await read($, commentsLoadedAtom)
851      const open = threads.filter(th => !th.isResolved)
852      const resolved = threads.filter(th => th.isResolved)
853      const byline = (c: Comment) => (
854        <Text>
855          <Text color={c.author === me ? t.accent : undefined}>{c.author === me ? 'you' : c.author}</Text>
856          {c.state && <Text color={c.state === 'APPROVED' ? t.ok : c.state === 'CHANGES_REQUESTED' ? t.fail : t.muted}> · {c.state.toLowerCase().replace(/_/g, ' ')}</Text>}
857          <Text color={t.muted}> · {c.when}</Text>
858        </Text>
859      )
860      const where = (th: Thread) => `${th.path}${th.line ? `:${th.line}` : ''}${th.isOutdated ? ' · outdated' : ''}`
861      return (
862        <Box flexDirection="column">
863          {topBar}
864          {card}
865          {back('checks', {})}
866          {!loadedComments && <Text color={t.muted}>Loading comments…</Text>}
867          {loadedComments && (
868            <Box flexDirection="column">
869              {section('Unresolved threads', open.length, open.length ? t.run : t.accent)}
870              {open.length === 0 && <Text color={t.ok}>✓ Nothing waiting on you.</Text>}
871              {open.map((th, i) => (
872                <Box key={`thread-${i}`} flexDirection="column" borderStyle="round" borderColor={t.run} paddingX={1}>
873                  <Box flexDirection="row" justifyContent="space-between">
874                    <Text color={t.accent}>{where(th)}</Text>
875                    <Box flexDirection="row" columnGap={1}>
876                      <Button key={`thread-fix-${i}`} label="✦ Address with Claude" onPress={() => void addressThread($, pr, th)} />
877                      <Button key={`thread-open-${i}`} label="↗ Open" onPress={() => void openUrl($, th.url)} />
878                    </Box>
879                  </Box>
880                  {th.comments.map((c, k) => (
881                    <Box key={`thread-${i}-c-${k}`} flexDirection="column" marginTop={k ? 1 : 0}>
882                      {byline(c)}
883                      <Markdown text={c.body || '_(no text)_'} />
884                    </Box>
885                  ))}
886                </Box>
887              ))}
888              {section('Conversation', conversation.length)}
889              {conversation.length === 0 && <Text color={t.muted}>No general comments.</Text>}
890              {conversation.map((c, k) => (
891                <Box key={`conv-${k}`} flexDirection="column" borderStyle="round" borderColor={t.rule} paddingX={1}>
892                  <Box flexDirection="row" justifyContent="space-between">
893                    {byline(c)}
894                    {c.url && <Button key={`conv-open-${k}`} plain label="↗" onPress={() => void openUrl($, c.url)} />}
895                  </Box>
896                  {c.body && <Markdown text={c.body} />}
897                </Box>
898              ))}
899              {section('Resolved threads', resolved.length, t.muted)}
900              {resolved.map((th, i) => (
901                <Box key={`resolved-${i}`} flexDirection="row">
902                  <Text color={t.ok}>✓ </Text>
903                  <Button
904                    plain
905                    dimColor
906                    key={`resolved-open-${i}`}
907                    label={`${where(th)} · ${th.comments[0]?.author ?? ''}: ${(th.comments[0]?.body ?? '').split('\n')[0]!.slice(0, 80)}`}
908                    onPress={() => void openUrl($, th.url)}
909                  />
910                </Box>
911              ))}
912            </Box>
913          )}
914        </Box>
915      )
916    }
917
918    if (view.job) {
919      const j = view.job
920      const ji = icon(j.status, j.conclusion)
921      return (
922        <Box flexDirection="column">
923          {topBar}
924          {card}
925          {back(view.workflow ?? 'workflows', { workflow: view.workflow })}
926          <Box flexDirection="row" gap={1} marginTop={1}>
927            <Text color={tone(ji.tone)}>{ji.glyph} {j.name} · {outcome(j)}</Text>
928            {j.url && <Button key="open-job" variant="primary" label="↗ Open job" onPress={() => void openUrl($, j.url)} />}
929            {isFailed(j) && <Button key="fix-job" variant="primary" label="✦ Fix with Claude" onPress={() => void fixCheck($, pr, j)} />}
930          </Box>
931          {section('Steps', steps.length || undefined)}
932          {steps.length === 0 && <Text color={t.muted}>Loading steps…</Text>}
933          {steps.map(st => {
934            const i = icon(st.status, st.conclusion)
935            return <Text color={i.tone === 'passed' ? undefined : tone(i.tone)}><Text color={tone(i.tone)}>{i.glyph}</Text> {st.name}</Text>
936          })}
937          {isFailed(j) && (
938            <Box flexDirection="column" borderStyle="round" borderColor={t.fail} paddingX={1} marginTop={1}>
939              <Text color={t.fail}>Failure log · last {LOG_LINES} lines</Text>
940              {failedLog.length === 0 && <Text color={t.muted}>Loading log…</Text>}
941              {failedLog.map(l => <Text color={l.startsWith('✗') ? t.fail : t.muted}>{l}</Text>)}
942            </Box>
943          )}
944        </Box>
945      )
946    }
947
948    if (view.workflow) {
949      const jobs = groups.get(view.workflow) ?? []
950      return (
951        <Box flexDirection="column">
952          {topBar}
953          {card}
954          {back('all workflows', {})}
955          <Box marginTop={1}>
956            <Text color={t.accent}>{view.workflow}  </Text>
957            {bar(count(jobs), jobs.length)}
958          </Box>
959          {tally(count(jobs))}
960          {OUTCOMES.map(o => {
961            const these = jobs.filter(c => outcome(c) === o)
962            if (!these.length) return null
963            return (
964              <Box flexDirection="column">
965                {section(o, these.length, tone(o))}
966                {these.map(c => checkRow(c, `job-${o}-${c.name}`, `${c.name} ›`))}
967              </Box>
968            )
969          })}
970        </Box>
971      )
972    }
973
974    const failedChecks = pr.checks.filter(isFailed)
975    return (
976      <Box flexDirection="column">
977        {topBar}
978        {openSection}
979        {card}
980        <Box marginTop={1} flexDirection="row" gap={1}>
981          <Text color={t.accent}>CHECKS</Text>
982          {bar(counts, total)}
983          <Text color={t.muted}>{done}/{total}</Text>
984        </Box>
985        {tally(counts)}
986        {alerts.length > 0 && (
987          <Box flexDirection="column">
988            {section('Recent activity', alerts.length)}
989            {alerts.slice(0, 5).map((a, i) => (
990              <Box key={`alert-${i}`} flexDirection="row">
991                <Text color={t.muted}>{new Date(a.at).toLocaleTimeString().slice(0, 5)}  </Text>
992                {a.url
993                  ? <Button plain key={`alert-open-${i}`} label={a.text} onPress={() => void openUrl($, a.url)} />
994                  : <Text color={tone(a.tone === 'info' ? 'skipped' : a.tone === 'ok' ? 'passed' : a.tone === 'fail' ? 'failed' : 'running')}>{a.text}</Text>}
995              </Box>
996            ))}
997          </Box>
998        )}
999        {failedChecks.length > 0 && (
1000          <Box flexDirection="column">
1001            {section('Failed', failedChecks.length, t.fail)}
1002            {failedChecks.map(c => (
1003              <Box key={`fail-row-${c.workflow}-${c.name}`} flexDirection="row" columnGap={1}>
1004                {checkRow(c, `fail-${c.workflow}-${c.name}`, `${c.name} · ${c.workflow} ›`)}
1005                <Button key={`fix-${c.workflow}-${c.name}`} label="✦ Fix with Claude" onPress={() => void fixCheck($, pr, c)} />
1006              </Box>
1007            ))}
1008          </Box>
1009        )}
1010        {section('Workflows', groups.size)}
1011        {[...groups.entries()].map(([name, cs]) => {
1012          const n = count(cs)
1013          const worst: Outcome = n.failed ? 'failed' : n.running ? 'running' : n.passed ? 'passed' : 'skipped'
1014          return (
1015            <Box key={`wf-${name}`} flexDirection="row">
1016              <Text color={tone(worst)}>{GLYPH[worst]} </Text>
1017              <Button
1018                plain
1019                label={`${name} — ${n.passed} passed${n.failed ? `, ${n.failed} failed` : ''}${n.running ? `, ${n.running} running` : ''} ›`}
1020                onPress={go({ workflow: name })}
1021              />
1022            </Box>
1023          )
1024        })}
1025        {total === 0 && <Text color={t.muted}>No checks reported yet.</Text>}
1026      </Box>
1027    )
1028  })
1029
1030  on('ui.render', { component: 'Pane', requestId: HISTORY }, async ($, e) => {
1031    const ui = $.ui.resolve(e)
1032    const { Box, Text, Button } = ui
1033    const t = theme
1034    const repo: string = await read($, repoAtom)
1035    const mine: PrRow[] = await read($, historyAtom)
1036    const all: PrRow[] = await read($, teamAtom)
1037    const line = (p: PrRow, showAuthor: boolean) => (
1038      <Box key={`${showAuthor ? 'all' : 'mine'}-${p.number}`} flexDirection="row">
1039        <Text color={t.brand}>⑂ </Text>
1040        <Text color={t.muted}>{p.when.slice(5)}  </Text>
1041        <Button plain key={`${showAuthor ? 'all' : 'mine'}-open-${p.number}`} label={`#${p.number} ${p.title}`} onPress={() => void openUrl($, p.url)} />
1042        {showAuthor && <Text color={p.author === me ? t.accent : t.muted}> · {p.author === me ? 'you' : p.author}</Text>}
1043      </Box>
1044    )
1045    return (
1046      <Box flexDirection="column">
1047        {paneBar(ui, $, 'PR HISTORY', repo)}
1048        {heading(ui, `Your PRs merged · last ${HISTORY_DAYS} days`, mine.length)}
1049        {mine.length === 0 && <Text color={t.muted}>None merged.</Text>}
1050        {mine.map(p => line(p, false))}
1051        {heading(ui, `All PRs merged · last ${HISTORY_DAYS} days`, all.length)}
1052        {all.length === 0 && <Text color={t.muted}>None merged.</Text>}
1053        {all.map(p => line(p, true))}
1054      </Box>
1055    )
1056  })
1057
1058  on('ui.render', { component: 'Pane', requestId: REVIEWS }, async ($, e) => {
1059    const ui = $.ui.resolve(e)
1060    const { Box, Text, Button } = ui
1061    const t = theme
1062    const repo: string = await read($, repoAtom)
1063    const reviews: ReviewRow[] = await read($, reviewsAtom)
1064    const direct = reviews.filter(r => r.direct)
1065    const team = reviews.filter(r => !r.direct)
1066    const line = (r: ReviewRow) => (
1067      <Box key={`review-${r.number}`} flexDirection="row">
1068        <Text color={r.direct ? t.run : t.brand}>● </Text>
1069        <Button plain key={`review-open-${r.number}`} label={`#${r.number} ${r.title}`} onPress={() => void openUrl($, r.url)} />
1070        <Text color={t.muted}> · {r.author}{r.isDraft ? ' · draft' : ''}{!r.direct && r.teams.length ? ` · ${r.teams.join(', ')}` : ''} · {r.when.slice(5)}</Text>
1071      </Box>
1072    )
1073    return (
1074      <Box flexDirection="column">
1075        {paneBar(ui, $, 'PR REVIEWS', repo)}
1076        {heading(ui, 'Waiting on you', direct.length, direct.length ? t.run : t.accent)}
1077        {direct.length === 0 && <Text color={t.muted}>Nothing requested from you directly.</Text>}
1078        {direct.map(line)}
1079        {heading(ui, 'Waiting on your team', team.length)}
1080        {team.length === 0 && <Text color={t.muted}>Nothing requested from your teams.</Text>}
1081        {team.map(line)}
1082      </Box>
1083    )
1084  })
1085}
1086
types/index.d.ts 74 lines
1export type Check = {
2  name: string
3  workflow: string
4  status: string
5  conclusion: string
6  url: string
7}
8export type Pr = {
9  repo: string
10  number: number
11  title: string
12  url: string
13  state: string
14  isDraft: boolean
15  review: string
16  mergeable: string
17  checks: Check[]
18  unresolved: number
19  threads: number
20  mergeState: string
21  base: string
22  behind: number
23  required: string[]
24  latestReviews: { author: string; state: string }[]
25  commentCount: number
26  lastCommenter: string
27}
28export type Alert = { at: number; text: string; tone: 'ok' | 'fail' | 'run' | 'info'; url: string }
29export type PrRow = {
30  number: number
31  title: string
32  url: string
33  state: string
34  isDraft: boolean
35  author: string
36  when: string
37  // open-PR rows carry a check and review summary for the collapsed band
38  checks?: 'failed' | 'running' | 'passed' | 'skipped' | 'none'
39  failing?: number
40  review?: string
41}
42export type ReviewRow = PrRow & { direct: boolean; teams: string[] }
43export type Step = { name: string; status: string; conclusion: string }
44export type View = { workflow?: string; job?: Check; comments?: boolean }
45export type Comment = { author: string; body: string; when: string; url: string; state: string }
46export type Thread = { path: string; line: number; isResolved: boolean; isOutdated: boolean; url: string; comments: Comment[] }
47
48declare module 'claude-code' {
49  interface PluginState {
50    'pr-pulse': {
51      pr: Pr | null
52      error: string
53      loadedAt: number
54      view: View
55      steps: Step[]
56      failedLog: string[]
57      repo: string
58      openPrs: PrRow[]
59      history: PrRow[]
60      teamMerged: PrRow[]
61      reviews: ReviewRow[]
62      selected: number
63      threads: Thread[]
64      conversation: Comment[]
65      commentsLoaded: boolean
66      watching: boolean
67      target: string
68      repoCache: string
69      alerts: Alert[]
70      collapsed: boolean
71    }
72  }
73}
74