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

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

/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.
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.
✓ 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.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.
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.
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.
This repository doubles as a demo target. scripts/demo.sh <scenario> opens a throwaway draft PR in a known state:
| Scenario | What PR Pulse shows |
|---|---|
green | all checks pass, the "✓ All checks passed" alert |
fail | a failing build with its error log, and Fix with Claude |
slow | a two-minute check: the pane goes from running to green |
flaky | fails, then passes after gh run rerun --failed without a false alert |
skip | a skipped optional check |
title | the required pr-title check failing |
scripts/demo.sh clean closes them all.
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.
MIT
hooks/register.tsx 1086 lines1import { 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}
1086types/index.d.ts 74 lines1export 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