PR checks and GitHub Actions runs, live in Claude Code. Failing logs one key from Claude, and Space Invaders for your pipelines.

PR checks and GitHub Actions runs, live inside Claude Code. When CI fails, one key puts the failing log in front of Claude. Release pipelines play out as Space Invaders.
<!-- DEMO CLIP: put your video or GIF here. GitHub plays an .mp4 dragged into this file in the web editor; a GIF works as
. -->
Needs Claude Code 2.1.287 or later and the GitHub CLI, signed in (gh auth login).
claude plugin marketplace add SID-Technologies/gh-pulse
claude plugin install gh-pulse@gh-pulse
Or inside a session: /plugin marketplace add SID-Technologies/gh-pulse, then pick it from /plugin. Run /pulse to open the board.
If gh is missing or signed out, gh-pulse tells you what to run and starts working within 15 seconds of you fixing it.
gh pr create, gh release create, gh workflow run, gh pr ready, gh pr checkout, and pushes to a branch with a PR are picked up automatically. Tracking survives restarts.✕ #130 5/10 · ⠹ v1.4.0 4/6), a line per item above the prompt, and a board with the full picture.f fills the prompt with the failing checks and their logs. You press Enter.needs count from the start. A merge sets off confetti./pulse opens it with the keyboard; otherwise use ctrl+x tab or click it. esc returns to the prompt.
Three lists sit at the top at a fixed height and scroll, so the open item below them always starts in the same place: the pull requests you're tracking, the Actions runs you're tracking, and your open PRs in this repo, ready to track.
| Key | Does |
|---|---|
↑ ↓ | Move through the lists |
1 2 3 | Jump to Pull requests, Actions or Your open PRs, back to where you were |
enter | Pin a tracked row in the detail, or track one of your PRs |
f | Fix it: failing checks and logs into the prompt |
e | Rerun failed jobs |
l / y | Next failing log / copy the log shown |
a | Ask Claude to review a tracked PR you didn't open |
o | Open on GitHub |
x | Remove the highlighted row |
d | Clear finished: merged or closed PRs and releases that passed |
c / z | Clear everything / undo any removal for 10s |
m | Lists and detail, lists only, detail only |
r | Refresh |
h | Show all keys |
The pinned item stays in the detail while you move around the lists. Merged and closed PRs drop off by themselves 10 minutes after they end.
Buttons only appear when they apply. gh-pulse polls every 15 seconds; your open PRs every minute; CI history every 5 minutes. Finished releases are rechecked every minute so a re-run shows up.
| Command | Does | ||
|---|---|---|---|
/pulse [link] | Open the board. With a pasted PR or Actions link, track it first | ||
| `/pulse-pr [number\ | url\ | owner/repo#n]` | Track a PR; blank for the current branch's PR |
| `/pulse-release [owner/repo] [tag\ | workflow.yml\ | run id]` | Track a tag's runs, a workflow's latest run, one run, or (blank) the latest release. Add owner/repo (or -R owner/repo) for another repo, or paste a GitHub Actions run, workflow or release URL |
/pulse-clear | Stop tracking everything |
Any repo you can see with gh works, wherever Claude is running:
/pulse-release acme/infra deploy.yml
/pulse-release https://github.com/acme/infra/actions/runs/123456789
/pulse-release acme/infra # latest release, or latest run if it has none
Paste any GitHub link into any of the three commands and it goes to the right list: a PR link (including /checks or /files pages) is tracked as a PR, an Actions run, workflow or release link as an Action. PRs in other repos track by URL: /pulse-pr https://github.com/acme/infra/pull/42. When Claude runs gh workflow run or gh release create with -R, that repo is followed too.
Set in /config, or under pluginConfigs.gh-pulse.options in settings.json.
| Setting | Default | |
|---|---|---|
claudeContext | on | Send the CI summary with your prompts |
confetti | on | Confetti on merge |
invaders | on | Space Invaders for Actions runs |
avatars | on | Reviewer avatars, in terminals that show images (Ghostty, kitty, iTerm2, WezTerm) |
heatmap | on | CI history on the base branch |
sounds | off | A chime on green, a thud on failure (macOS) |
speech | off | Announce changes out loud ("PR 131 is green") |
Pixel art only draws in the terminal.
claude --plugin-dir . # run a local checkout
claude plugin validate .
claude plugin test .
hooks/register.tsx hooks: commands, auto-tracking, polling, the band and the board
hooks/lib.ts parsing gh output, tallies, glyphs, prompts, settings
hooks/sprites.ts half-block pixel art: confetti and Space Invaders
types/index.d.ts the $.state contract
tests/ claude plugin test suites
Run the Release workflow by hand: Actions > Release > Run workflow, or gh workflow run release.yml. It works out the next version from the PR titles merged since the last tag (feat: … bumps minor, fix: … and anything else bump patch, feat!: … or BREAKING CHANGE bumps major), commits it to .claude-plugin/plugin.json, tags gh-pulse--v<version> and publishes a GitHub release. Choose a bump when you run it to override that: gh workflow run release.yml -f bump=minor.
hooks/register.tsx 1930 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, RenderInput, Timer } from 'claude-code'
3
4import type { Celebration, History, Logs, OpenPrs, Pr, Release, Run, Setup, Undo } from '../types'
5import {
6 FRAME_MS,
7 PR_URL,
8 RELEASE_URL,
9 SOUND_FOR,
10 SPINNER_EVERY,
11 actionsTarget,
12 byUrgency,
13 cells,
14 ciContext,
15 cleanLog,
16 clip,
17 duration,
18 fixPrompt,
19 glyph,
20 isFinished,
21 isFlaky,
22 isLive,
23 isPlainLogin,
24 isPrCreate,
25 isReleaseCreate,
26 isReleaseRunning,
27 isStale,
28 mergeLabel,
29 nextRow,
30 overall,
31 packRows,
32 parseActionsUrl,
33 parseHistory,
34 parseJobs,
35 parseOpenPrs,
36 parsePr,
37 pastedLink,
38 parseRuns,
39 plannedJobs,
40 prState,
41 prToast,
42 prTransition,
43 readSettings,
44 repoFlag,
45 releaseInvaders,
46 releaseItems,
47 releaseState,
48 releaseToast,
49 releaseTransition,
50 reviewLabel,
51 reviewPrompt,
52 reviewerGlyph,
53 scrollWindow,
54 segments,
55 setupAdvice,
56 setupText,
57 spoken,
58 statusLine,
59 summary,
60 tally,
61 tone,
62 trouble,
63 upsertBy,
64 verdict,
65 withWaitingJobs,
66 workflowRun,
67} from './lib'
68import type { Failure, PlannedJob, Settings, Shown, Tally, Transition } from './lib'
69import {
70 CONFETTI_FRAMES,
71 HIT_FRAMES,
72 LOSS_FRAMES,
73 VICTORY_FRAMES,
74 type Ship,
75 confettiFrame,
76 invadersFrame,
77 newShip,
78 placeInvaders,
79 toCells,
80} from './sprites'
81
82type $ = EngineInterface
83
84const PANE = 'gh-pulse'
85const TITLE = 'gh-pulse'
86const STORE_KEY = 'tracked'
87
88const POLL_MS = 15_000
89const OPEN_PRS_EVERY = 4 // polls between refreshes of your PRs and review requests
90const RESEAT_MS = 80 // long enough for the board to redraw
91const UNDO_MS = 10_000
92const HISTORY_TTL_MS = 5 * 60_000
93const REDRAW_MS = 3_000
94const AUTO_CLEAR_MS = 10 * 60_000 // merged or closed PRs drop off after this
95
96const MAX_PRS = 5
97const MAX_RELEASES = 3
98const MAX_OPEN_PRS = 15
99const MAX_CHECKS = 8
100const MAX_JOBS = 5
101const HISTORY_RUNS = 20
102const LOG_PREVIEW = 15
103
104const BAND_BAR = 10
105const TIME_COLUMN = 8
106
107const PR_FIELDS =
108 'number,title,url,state,isDraft,reviewDecision,mergeable,statusCheckRollup,headRefName,baseRefName,latestReviews,reviewRequests,mergedAt,closedAt,author'
109const OPEN_PR_FIELDS = 'number,title,url,isDraft,statusCheckRollup'
110const RUN_FIELDS = 'databaseId,name,workflowName,displayTitle,url,status,conclusion,headBranch,startedAt,updatedAt,attempt'
111
112// Board elements are keyed by prefix: tracked rows, your PRs, section headings.
113const ITEM = 'item:'
114const ADD = 'add:'
115const SECTION = 'section:'
116
117const prKey = (pr: Pr) => `pr:${pr.url}`
118const releaseKey = (r: Release) => `release:${r.key}`
119
120const CELEBRATION_FRAMES: Record<Celebration['kind'], number> = {
121 merged: CONFETTI_FRAMES,
122 released: 40,
123 scrubbed: LOSS_FRAMES + 4,
124}
125
126type Layout = 'both' | 'list' | 'detail'
127const NEXT_LAYOUT = { both: 'list', list: 'detail', detail: 'both' } as const
128const LAYOUT_LABEL = { both: 'List and detail', list: 'List only', detail: 'Detail only' } as const
129
130const ACTION_GAP = 3
131const UNDO_LABEL_MAX = 16
132// Every action at its widest, to reserve the button rows' height.
133const ALL_ACTION_LABELS = [
134 'Refresh',
135 'List and detail',
136 'Fix it',
137 'Rerun failed',
138 'Next log',
139 'Copy log',
140 'Review with Claude',
141 'Open',
142 'Remove',
143 'Clear finished (99)',
144 'Clear',
145 `Undo (${'x'.repeat(UNDO_LABEL_MAX)})`,
146 'Hide keys',
147]
148
149const prs = atom({ plugin: 'gh-pulse', key: 'prs' } as const, [])
150const releases = atom({ plugin: 'gh-pulse', key: 'releases' } as const, [])
151const frame = atom({ plugin: 'gh-pulse', key: 'frame' } as const, 0)
152const isBandHidden = atom({ plugin: 'gh-pulse', key: 'isBandHidden' } as const, false)
153const selected = atom({ plugin: 'gh-pulse', key: 'selected' } as const, '')
154const layout = atom({ plugin: 'gh-pulse', key: 'layout' } as const, 'both' as Layout)
155const setup = atom({ plugin: 'gh-pulse', key: 'setup' } as const, { gh: 'unknown', os: 'unknown' } as Setup)
156const celebration = atom({ plugin: 'gh-pulse', key: 'celebration' } as const, null as Celebration | null)
157const undo = atom({ plugin: 'gh-pulse', key: 'undo' } as const, null as Undo | null)
158const cursor = atom({ plugin: 'gh-pulse', key: 'cursor' } as const, '')
159const busy = atom({ plugin: 'gh-pulse', key: 'busy' } as const, '')
160const isKeysShown = atom({ plugin: 'gh-pulse', key: 'isKeysShown' } as const, false)
161const logs = atom({ plugin: 'gh-pulse', key: 'logs' } as const, {} as Logs)
162const viewer = atom({ plugin: 'gh-pulse', key: 'viewer' } as const, '')
163const logCheck = atom({ plugin: 'gh-pulse', key: 'logCheck' } as const, '')
164const history = atom({ plugin: 'gh-pulse', key: 'history' } as const, {} as Record<string, History>)
165const avatars = atom({ plugin: 'gh-pulse', key: 'avatars' } as const, {} as Record<string, string>)
166const openPrs = atom({ plugin: 'gh-pulse', key: 'openPrs' } as const, { repo: '', prs: [] } as OpenPrs)
167
168// Module state. Reset when the module reloads; $.state and $.store survive that.
169let settings: Settings = readSettings(undefined)
170const doneAt = new Map<string, number>() // `${release key}|${job key}` -> frame the job finished
171const fleets = new Map<string, ReturnType<typeof placeInvaders>>()
172const ships = new Map<string, Ship>()
173
174// ── gh ──────────────────────────────────────────────────────────────────
175
176async function gh($: $, args: string[]): Promise<string> {
177 const ran = await $.process.run(['gh', ...args], { timeoutMs: 20_000 })
178 if (ran.exitCode !== 0) {
179 const why = ran.stderr.trim().split('\n')[0] ?? ''
180 throw new Error(why || `gh ${args[0]} exited ${ran.exitCode}`)
181 }
182 return ran.stdout
183}
184
185function message(error: unknown): string {
186 return error instanceof Error ? error.message : String(error)
187}
188
189async function fetchPr($: $, ref: string): Promise<Pr> {
190 const args = ['pr', 'view', '--json', PR_FIELDS]
191 if (ref) args.splice(2, 0, ref)
192 return parsePr(await gh($, args))
193}
194
195// By run id. A run's workflow file is fixed at its commit, so this never goes stale.
196const plans = new Map<number, PlannedJob[]>()
197
198async function planFor($: $, run: Run, repo = '{owner}/{repo}'): Promise<PlannedJob[]> {
199 const known = plans.get(run.id)
200 if (known) return known
201 let planned: PlannedJob[] = []
202 try {
203 const [path = '', sha = ''] = (
204 await gh($, ['api', `repos/${repo}/actions/runs/${run.id}`, '--jq', '.path + "\n" + .head_sha'])
205 ).trim().split('\n')
206 const file = path.split('@')[0] ?? ''
207 if (file.startsWith('.github/workflows/') && sha) {
208 planned = plannedJobs(
209 await gh($, ['api', '-H', 'Accept: application/vnd.github.raw', `repos/${repo}/contents/${file}?ref=${sha}`]),
210 )
211 }
212 } catch {
213 // Fall back to the jobs GitHub lists.
214 }
215 plans.set(run.id, planned)
216 return planned
217}
218
219async function withJobs($: $, run: Run, previous: Run | undefined, repo: string | undefined): Promise<Run> {
220 // Finished and not re-run since: the jobs can't have changed.
221 const isSameFinish = previous?.state !== 'pending' && previous?.attempt === run.attempt
222 if (run.state !== 'pending' && previous && isSameFinish && previous.jobs.length > 0) {
223 return { ...run, jobs: previous.jobs }
224 }
225 try {
226 const jobs = parseJobs(await gh($, ['run', 'view', String(run.id), '--json', 'jobs', ...inRepo(repo)]))
227 // GitHub only lists a job once it starts; add the ones still waiting on `needs`.
228 return { ...run, jobs: withWaitingJobs(jobs, run.state === 'pending' ? await planFor($, run, repo) : [], run.state === 'pending') }
229 } catch {
230 return { ...run, jobs: previous?.jobs ?? [] }
231 }
232}
233
234async function fetchRelease($: $, release: Release): Promise<Release> {
235 const args = ['run', 'list', '--json', RUN_FIELDS, ...inRepo(release.repo)]
236 if (release.runId) args.splice(0, 2, 'run', 'view', String(release.runId))
237 else if (release.workflow) args.push('--workflow', release.workflow, '--limit', '1')
238 else if (release.tag) args.push('--branch', release.tag, '--limit', '10')
239 else args.push('--limit', '1')
240
241 const out = await gh($, args)
242 const found = parseRuns(release.runId ? `[${out}]` : out)
243 const runs = await Promise.all(
244 found.map(run => withJobs($, run, release.runs.find(old => old.id === run.id), release.repo)),
245 )
246 return { ...release, runs, error: undefined }
247}
248
249// A tag, a workflow file, a run, or (empty) the latest release; in this repo
250// or another one. See actionsTarget for the forms.
251async function resolveRelease($: $, arg: string, inRepoName?: string): Promise<Release> {
252 const target = actionsTarget(arg)
253 const isElsewhere = Boolean(target.repo ?? inRepoName)
254 const repo = target.repo ?? inRepoName ?? (await currentRepo($))
255 if (target.runId) {
256 const raw = JSON.parse(
257 await gh($, ['run', 'view', String(target.runId), '--json', 'workflowName,url', ...inRepo(repo)]),
258 ) as { workflowName?: string; url?: string }
259 const label = `${raw.workflowName ?? 'run'} ${target.runId}`
260 return { key: `run:${repo}:${target.runId}`, label, repo, runId: target.runId, url: raw.url, runs: [] }
261 }
262 if (target.workflow) {
263 const label = target.workflow.replace(/\.ya?ml$/, '')
264 return { key: `wf:${repo}:${target.workflow}`, label, repo, workflow: target.workflow, runs: [] }
265 }
266 const args = ['release', 'view', '--json', 'tagName,url', ...inRepo(isElsewhere ? repo : undefined)]
267 if (target.tag) args.splice(2, 0, target.tag)
268 let raw: { tagName?: string; url?: string }
269 try {
270 raw = JSON.parse(await gh($, args)) as { tagName?: string; url?: string }
271 } catch (error) {
272 // A repo with no releases: follow its latest run instead.
273 if (target.tag || !target.repo) throw error
274 return { key: `latest:${repo}`, label: 'latest run', repo, runs: [] }
275 }
276 const tag = raw.tagName ?? target.tag ?? ''
277 return { key: `tag:${repo}:${tag}`, label: tag, repo, tag, url: raw.url, runs: [] }
278}
279
280// Releases and runs are looked up by repo; one tracked elsewhere must say which.
281function inRepo(repo: string | undefined): string[] {
282 return repo ? ['-R', repo] : []
283}
284
285async function currentRepo($: $): Promise<string | undefined> {
286 const known = (await read($, openPrs)).repo
287 if (known) return known
288 try {
289 return (JSON.parse(await gh($, ['repo', 'view', '--json', 'nameWithOwner'])) as { nameWithOwner?: string }).nameWithOwner
290 } catch {
291 return undefined
292 }
293}
294
295// ── Setup ───────────────────────────────────────────────────────────────
296
297async function tryRun($: $, argv: string[]): Promise<{ exitCode: number; stdout: string; stderr: string } | undefined> {
298 try {
299 return await $.process.run(argv, { timeoutMs: 10_000 })
300 } catch {
301 return undefined // not installed
302 }
303}
304
305async function detectOs($: $): Promise<Setup['os']> {
306 const uname = (await tryRun($, ['uname', '-s']))?.stdout.trim()
307 if (uname === 'Darwin') return 'mac'
308 if (uname === 'Linux') return 'linux'
309 return uname === undefined ? 'windows' : 'unknown'
310}
311
312async function checkGh($: $): Promise<boolean> {
313 const before = await read($, setup)
314 const version = await tryRun($, ['gh', '--version'])
315 let state: Setup['gh'] = 'ready'
316 if (!version || version.exitCode !== 0) state = 'missing'
317 else {
318 const auth = await tryRun($, ['gh', 'auth', 'status'])
319 // `gh auth status` also fails with no network, e.g. just after the laptop
320 // wakes. Only "not logged in" means signed out; anything else, once we've
321 // been working, is the network, and each row shows its own error meanwhile.
322 const isSignedOut = /not logged in/i.test(`${auth?.stdout ?? ''}${auth?.stderr ?? ''}`)
323 if (auth?.exitCode !== 0 && (isSignedOut || before.gh !== 'ready')) state = 'signed-out'
324 }
325
326 const os = before.os === 'unknown' && state === 'missing' ? await detectOs($) : before.os
327 if (before.gh !== state || before.os !== os) await update($, setup, () => ({ gh: state, os }))
328 if (state === 'ready' && before.gh !== 'ready' && before.gh !== 'unknown') {
329 $.ui.toast('gh-pulse is ready: GitHub CLI found and signed in')
330 }
331 return state === 'ready'
332}
333
334async function needsSetup($: $): Promise<string | undefined> {
335 if (await checkGh($)) return undefined
336 return setupText(await read($, setup))
337}
338
339// ── State ───────────────────────────────────────────────────────────────
340
341async function refreshStatus($: $): Promise<void> {
342 $.ui.status(statusLine(await read($, prs), await read($, releases), await read($, frame)))
343}
344
345async function upsertPr($: $, next: Pr): Promise<void> {
346 const before = (await read($, prs)).find(pr => pr.url === next.url)
347 await update($, prs, list =>
348 upsertBy(list, next, pr => pr.url === next.url, MAX_PRS),
349 )
350 const toast = prToast(before, next)
351 if (toast) $.ui.toast(toast, { timeoutMs: 6000 })
352 const happened = prTransition(before, next)
353 if (happened) await onTransition($, prKey(next), happened, `PR ${next.number}`)
354 await persistTracking($)
355}
356
357function shipFor(key: string): Ship {
358 const ship = ships.get(key) ?? newShip()
359 ships.set(key, ship)
360 return ship
361}
362
363async function upsertRelease($: $, next: Release): Promise<void> {
364 const before = (await read($, releases)).find(r => r.key === next.key)
365 const wasRunning = new Set(before ? releaseInvaders(before).filter(job => job.state === 'pending').map(job => job.key) : [])
366 const now = await read($, frame)
367 for (const job of releaseInvaders(next)) {
368 if (job.state !== 'pending' && wasRunning.has(job.key)) doneAt.set(`${next.key}|${job.key}`, now)
369 }
370 fleets.set(next.key, placeInvaders(fleets.get(next.key) ?? new Map(), releaseInvaders(next), now))
371 await update($, releases, list =>
372 upsertBy(list, next, r => r.key === next.key, MAX_RELEASES),
373 )
374 const toast = releaseToast(before, next)
375 if (toast) $.ui.toast(toast, { timeoutMs: 8000 })
376 const happened = releaseTransition(before, next)
377 if (happened) await onTransition($, releaseKey(next), happened, next.label)
378 await persistTracking($)
379}
380
381async function onTransition($: $, key: string, happened: Transition, label: string): Promise<void> {
382 const sound = SOUND_FOR[happened]
383 if (settings.sounds && sound) void $.audio.play({ asset: sound }).catch(() => undefined)
384 if (settings.speech) void $.audio.speak(spoken(label, happened)).catch(() => undefined)
385 const kind =
386 happened === 'merged' && settings.confetti
387 ? 'merged'
388 : (happened === 'released' || happened === 'scrubbed') && settings.invaders
389 ? happened
390 : undefined
391 if (!kind) return
392 const startFrame = await read($, frame)
393 await update($, celebration, () => ({ key, kind, startFrame }))
394}
395
396function isCelebrating(party: Celebration | null, now: number): party is Celebration {
397 return party !== null && now - party.startFrame < CELEBRATION_FRAMES[party.kind]
398}
399
400// ── Fix it ──────────────────────────────────────────────────────────────
401
402async function fetchFailedLog($: $, jobId: string, repo: string | undefined): Promise<string[]> {
403 const cached = (await read($, logs))[jobId]
404 if (cached) return cached.lines
405 const lines = cleanLog(await gh($, ['run', 'view', '--job', jobId, '--log-failed', ...inRepo(repo)]))
406 await update($, logs, all => ({ ...all, [jobId]: { lines, at: Date.now() } }))
407 return lines
408}
409
410async function fixIt($: $, key: string, surface: string): Promise<void> {
411 const pr = (await read($, prs)).find(one => prKey(one) === key)
412 if (!pr) return
413 const failing = pr.checks.filter(c => c.state === 'fail').slice(0, 3)
414 if (failing.length === 0) {
415 $.ui.toast(`PR #${pr.number} has nothing failing`)
416 return
417 }
418 const failures: Failure[] = await Promise.all(
419 failing.map(async c => {
420 const jobId = parseActionsUrl(c.url)?.jobId
421 let log: string[] | undefined
422 if (jobId) {
423 try {
424 log = await fetchFailedLog($, jobId, pr.repo)
425 } catch {
426 log = undefined
427 }
428 }
429 return { name: c.name, workflow: c.workflow, url: c.url, log }
430 }),
431 )
432 const text = fixPrompt(pr, failures)
433 const filled = await $.prompt.fill({ text })
434 if (filled.isFilled) {
435 $.ui.toast('Fix prompt ready: esc to the prompt, then Enter to send', { timeoutMs: 6000 })
436 return
437 }
438 const copied = await $.ui.copy({ text, surface: surface as never })
439 $.ui.toast(copied.isCopied ? 'Fix prompt copied: paste it into the prompt' : "Couldn't fill or copy the fix prompt", { timeoutMs: 6000 })
440}
441
442async function trackPr($: $, ref: string): Promise<Pr> {
443 const pr = await fetchPr($, ref)
444 await upsertPr($, pr)
445 await refreshStatus($)
446 return pr
447}
448
449async function trackRelease($: $, release: Release): Promise<Release> {
450 let next = release
451 try {
452 next = await fetchRelease($, release)
453 } catch (error) {
454 next = { ...release, error: message(error) }
455 }
456 await upsertRelease($, next)
457 await refreshStatus($)
458 return next
459}
460
461async function refreshOpenPrs($: $): Promise<void> {
462 try {
463 const known = await read($, openPrs)
464 const repo =
465 known.repo ||
466 (JSON.parse(await gh($, ['repo', 'view', '--json', 'nameWithOwner'])) as { nameWithOwner?: string }).nameWithOwner ||
467 ''
468 const list = parseOpenPrs(
469 await gh($, ['pr', 'list', '--author', '@me', '--state', 'open', '--limit', String(MAX_OPEN_PRS), '--json', OPEN_PR_FIELDS]),
470 )
471 await update($, openPrs, () => ({ repo, prs: list }))
472 } catch (error) {
473 await update($, openPrs, old => ({ ...old, error: message(error) }))
474 }
475}
476
477// Your login, so `a` (review with Claude) only shows on PRs you didn't open.
478async function learnViewer($: $): Promise<void> {
479 if (await read($, viewer)) return
480 try {
481 const login = (await gh($, ['api', 'user', '--jq', '.login'])).trim()
482 if (login) await update($, viewer, () => login)
483 } catch {
484 // Without it, `a` stays hidden.
485 }
486}
487
488async function refreshLists($: $): Promise<void> {
489 await Promise.all([refreshOpenPrs($), learnViewer($)])
490}
491
492let pollStartedAt = 0 // 0 when no poll is running
493let polls = 0
494// A poll this old is stuck (a gh call that never came back across a sleep);
495// let the next one start rather than never polling again.
496const STUCK_POLL_MS = 2 * 60_000
497let isEverythingDue = false // asked for while a poll was running
498
499// Finished releases are rechecked every few polls, or now with `everything`, to catch re-runs.
500async function poll($: $, everything = false): Promise<void> {
501 if (everything) isEverythingDue = true
502 const now = Date.now()
503 if (pollStartedAt && now - pollStartedAt < STUCK_POLL_MS) return
504 const startedAt = now
505 pollStartedAt = startedAt
506 const isSlowTick = polls++ % OPEN_PRS_EVERY === 0
507 const isEverything = isEverythingDue || isSlowTick
508 isEverythingDue = false
509 try {
510 if (!(await checkGh($))) return
511 const checkedAt = Date.now()
512 if ((await read($, prs)).some(pr => isStale(pr, checkedAt, AUTO_CLEAR_MS))) {
513 await update($, prs, list => list.filter(pr => !isStale(pr, checkedAt, AUTO_CLEAR_MS)))
514 }
515 for (const pr of await read($, prs)) {
516 if (pr.state !== 'OPEN') continue
517 try {
518 await upsertPr($, await fetchPr($, pr.url))
519 } catch (error) {
520 await update($, prs, list =>
521 list.map(one => (one.url === pr.url ? { ...one, error: message(error) } : one)),
522 )
523 }
524 }
525 for (const release of await read($, releases)) {
526 if (!isReleaseRunning(release) && !isEverything) continue
527 await trackRelease($, release)
528 }
529 if (isSlowTick) await refreshLists($)
530 await prefetchLog($)
531 await refreshStatus($)
532 } finally {
533 // A stuck poll that finishes late mustn't clear a newer one's mark.
534 if (pollStartedAt === startedAt) pollStartedAt = 0
535 }
536}
537
538// ── Board navigation ────────────────────────────────────────────────────
539
540let ringMoves = 0 // focus moves by the person; a newer one cancels a pending re-seat
541let focusedKey: string | undefined
542let rowKeys: readonly string[] = [] // as last drawn, top to bottom
543let sectionRows: Record<string, readonly string[]> = {}
544const lastInSection = new Map<string, string>()
545const windowStarts = new Map<string, number>()
546let drawnRows = new Set<string>() // rows inside their list's window
547// Rows each list shows, and whether Actions and your PRs sit side by side.
548let boardShape = { prRows: 4, sideRows: 3, isSideBySide: true }
549
550// Where a focusable sits top to bottom: action buttons first, then headings and rows.
551function orderOf(key: string): number {
552 const at = rowKeys.indexOf(key)
553 if (at >= 0) return at
554 if (!key.startsWith(SECTION)) return -1
555 const first = sectionRows[key.slice(SECTION.length)]?.[0]
556 const firstAt = first ? rowKeys.indexOf(first) : -1
557 return firstAt >= 0 ? firstAt - 0.5 : rowKeys.length
558}
559
560// The h panel, in two columns.
561const KEYS: readonly (readonly (readonly [string, string])[])[] = [
562 [
563 ['↑ ↓', 'move'],
564 ['enter', 'pin a row, or track one of yours'],
565 ['1 2 3', 'jump to a list'],
566 ['pgup pgdn', 'scroll the board'],
567 ['m', 'lists / detail layout'],
568 ['esc', 'back to the prompt'],
569 ],
570 [
571 ['f', 'fix it with Claude'],
572 ['e', 'rerun failed jobs'],
573 ['l / y', 'next log / copy log'],
574 ['a', 'review with Claude'],
575 ['o', 'open on GitHub'],
576 ['x / d', 'remove row / clear finished'],
577 ['c / z', 'clear all / undo'],
578 ['r', 'refresh'],
579 ],
580]
581
582// Shows `label` in place of the help line until `work` finishes.
583async function showWhile<T>($: $, label: string, work: () => Promise<T>): Promise<T> {
584 await update($, busy, () => label)
585 try {
586 return await work()
587 } finally {
588 await update($, busy, () => '')
589 }
590}
591
592async function goToRow($: $, key: string): Promise<void> {
593 ringMoves += 1
594 // Moves the list's window first if the row is scrolled out of it.
595 await update($, cursor, () => key)
596 await $.ui.focus({ requestId: PANE, key }).catch(() => undefined)
597 await $.ui.scroll({ in: PANE, to: { key }, block: 'nearest' }).catch(() => undefined)
598}
599
600async function jumpToSection($: $, id: string): Promise<void> {
601 const rows = sectionRows[id] ?? []
602 const remembered = lastInSection.get(id)
603 const key = remembered && rows.includes(remembered) ? remembered : rows[0]
604 if (key) await goToRow($, key)
605 else await $.ui.scroll({ in: PANE, to: { key: `${SECTION}${id}` }, block: 'nearest' }).catch(() => undefined)
606}
607
608/**
609 * The pane's focus ring is an index into its focusable elements, not a key.
610 * Opening an item changes the buttons above the list, so after the redraw the
611 * index points at the wrong element. Put focus back on the row once it settles.
612 */
613async function openItem($: $, key: string): Promise<void> {
614 if ((await read($, selected)) === key) return
615 await update($, selected, () => key)
616 const move = ringMoves
617 $.clock.after(RESEAT_MS, () => {
618 if (move !== ringMoves) return
619 void $.ui.focus({ requestId: PANE, key: `${ITEM}${key}` }).catch(() => undefined)
620 void $.ui.scroll({ in: PANE, to: { key: `${ITEM}${key}` }, block: 'nearest' }).catch(() => undefined)
621 })
622 await prefetchLog($)
623}
624
625let removals = 0 // so an older removal's timer can't clear a newer undo
626
627async function keepForUndo($: $, label: string): Promise<void> {
628 const kept: Undo = { label, prs: await read($, prs), releases: await read($, releases), selected: await read($, selected) }
629 await update($, undo, () => kept)
630 const mine = ++removals
631 $.clock.after(UNDO_MS, () => {
632 if (mine === removals) void update($, undo, () => null)
633 })
634}
635
636async function undoRemoval($: $): Promise<void> {
637 const kept = await read($, undo)
638 if (!kept) return
639 removals += 1
640 await update($, undo, () => null)
641 await update($, prs, () => kept.prs)
642 await update($, releases, () => kept.releases)
643 await update($, selected, () => kept.selected)
644 await refreshStatus($)
645 await persistTracking($)
646 $.ui.toast(`Put back: ${kept.label}`)
647}
648
649async function clearAll($: $): Promise<void> {
650 const count = (await read($, prs)).length + (await read($, releases)).length
651 if (count > 0) await keepForUndo($, `${count} tracked item${count === 1 ? '' : 's'}`)
652 await update($, prs, () => [])
653 await update($, releases, () => [])
654 await update($, selected, () => '')
655 $.ui.status(undefined)
656 await persistTracking($)
657}
658
659// Actions and help, then each list's panel (border, heading, rows), a row apart.
660function listOnlyRows(): number {
661 const { prRows, sideRows, isSideBySide } = boardShape
662 const side = isSideBySide ? sideRows + 3 : 2 * (sideRows + 3)
663 return 2 + 1 + (prRows + 3) + 1 + side
664}
665
666async function openBoard($: $) {
667 const isSmall = (await read($, layout)) === 'list'
668 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true, rows: isSmall ? listOnlyRows() : undefined })
669 void refreshLists($)
670 return opened
671}
672
673async function cycleLayout($: $): Promise<void> {
674 const shown = NEXT_LAYOUT[await read($, layout)]
675 await update($, layout, () => shown)
676 const isSmall = shown === 'list'
677 // Re-opening sets the height, unless the person has resized the pane.
678 await $.ui.open({ id: PANE, title: TITLE, rows: isSmall ? listOnlyRows() : undefined })
679}
680
681// Through gh, so it works on every OS.
682async function openInBrowser($: $, key: string): Promise<void> {
683 try {
684 const pr = (await read($, prs)).find(one => prKey(one) === key)
685 if (pr) {
686 await gh($, ['pr', 'view', pr.url, '--web'])
687 return
688 }
689 const release = (await read($, releases)).find(one => releaseKey(one) === key)
690 if (release?.tag && release.url) await gh($, ['release', 'view', release.tag, '--web', ...inRepo(release.repo)])
691 else if (release?.runs[0]) await gh($, ['run', 'view', String(release.runs[0].id), '--web', ...inRepo(release.repo)])
692 } catch (error) {
693 $.ui.toast(`Couldn't open it: ${message(error)}`)
694 }
695}
696
697async function untrack($: $, key: string): Promise<void> {
698 const pr = (await read($, prs)).find(one => prKey(one) === key)
699 const release = (await read($, releases)).find(one => releaseKey(one) === key)
700 await keepForUndo($, pr ? `#${pr.number}` : (release?.label ?? 'the item'))
701 await update($, prs, list => list.filter(pr => prKey(pr) !== key))
702 await update($, releases, list => list.filter(r => releaseKey(r) !== key))
703 await update($, selected, open => (open === key ? '' : open))
704 await refreshStatus($)
705 await persistTracking($)
706}
707
708async function clearFinished($: $): Promise<void> {
709 const done = [...(await read($, prs)), ...(await read($, releases))].filter(isFinished).length
710 if (done === 0) return
711 await keepForUndo($, `${done} finished`)
712 await update($, prs, list => list.filter(pr => !isFinished(pr)))
713 await update($, releases, list => list.filter(r => !isFinished(r)))
714 await refreshStatus($)
715 await persistTracking($)
716}
717
718// ── Persistence ─────────────────────────────────────────────────────────
719
720type Stored = { prs: string[]; releases: Omit<Release, 'runs' | 'error'>[] }
721let lastStored = ''
722
723async function persistTracking($: $): Promise<void> {
724 const stored: Stored = {
725 prs: (await read($, prs)).filter(pr => pr.state === 'OPEN').map(pr => pr.url),
726 releases: (await read($, releases)).map(({ runs: _runs, error: _error, ...rest }) => rest),
727 }
728 const text = JSON.stringify(stored)
729 if (text === lastStored) return
730 try {
731 await $.store.set(STORE_KEY, stored)
732 lastStored = text
733 } catch {
734 // Not fatal; tracking works without it.
735 }
736}
737
738async function restoreTracking($: $): Promise<void> {
739 let stored: Stored | undefined
740 try {
741 stored = (await $.store.get(STORE_KEY)) as Stored | undefined
742 } catch {
743 return
744 }
745 if (!stored) return
746 lastStored = JSON.stringify(stored)
747 const known = new Set((await read($, prs)).map(pr => pr.url))
748 for (const url of stored.prs ?? []) {
749 if (known.has(url)) continue
750 try {
751 const pr = await fetchPr($, url)
752 if (pr.state === 'OPEN') await upsertPr($, pr)
753 } catch {
754 // Deleted or no longer reachable.
755 }
756 }
757 for (const release of stored.releases ?? []) {
758 const restored = await trackRelease($, { ...release, runs: [] })
759 // Saved before releases recorded their repo, and not in this one: there's no way to find it.
760 if (restored.error && !release.repo) {
761 await update($, releases, list => list.filter(r => r.key !== release.key))
762 await persistTracking($)
763 }
764 }
765 await refreshStatus($)
766}
767
768// ── Detail data ─────────────────────────────────────────────────────────
769
770function shownFailure(pr: Pr, chosen: string) {
771 const failing = pr.checks.filter(c => c.state === 'fail' && parseActionsUrl(c.url)?.jobId)
772 return failing.find(c => chosen === `${prKey(pr)}#${c.url}`) ?? failing[0]
773}
774
775// Log, CI history and avatars for the selected PR.
776async function prefetchLog($: $): Promise<void> {
777 const key = await read($, selected)
778 const list = await read($, prs)
779 const pr = list.find(one => prKey(one) === key) ?? list.at(-1)
780 if (!pr) return
781 const check = shownFailure(pr, await read($, logCheck))
782 const jobId = parseActionsUrl(check?.url)?.jobId
783 await Promise.all([
784 jobId ? fetchFailedLog($, jobId, pr.repo).catch(() => undefined) : undefined,
785 settings.heatmap ? fetchHistory($, pr) : undefined,
786 settings.avatars ? fetchAvatars($, pr) : undefined,
787 ])
788}
789
790function historyKey(pr: Pr, workflow: string): string {
791 return `${pr.repo}|${pr.base ?? ''}|${workflow}`
792}
793
794async function fetchHistory($: $, pr: Pr): Promise<void> {
795 if (!pr.base) return
796 const workflows = [...new Set(pr.checks.map(c => c.workflow).filter((w): w is string => Boolean(w)))]
797 const known = await read($, history)
798 for (const workflow of workflows.slice(0, 6)) {
799 const key = historyKey(pr, workflow)
800 if (known[key] && Date.now() - known[key].at < HISTORY_TTL_MS) continue
801 try {
802 const results = parseHistory(
803 await gh($, ['run', 'list', '-R', pr.repo, '--workflow', workflow, '--branch', pr.base, '--limit', String(HISTORY_RUNS), '--json', 'status,conclusion']),
804 )
805 await update($, history, all => ({ ...all, [key]: { results, at: Date.now() } }))
806 } catch {
807 await update($, history, all => ({ ...all, [key]: { results: [], at: Date.now() } }))
808 }
809 }
810}
811
812// $.http.fetch only returns text, so curl and base64 fetch the PNG.
813async function fetchAvatars($: $, pr: Pr): Promise<void> {
814 const have = await read($, avatars)
815 for (const reviewer of (pr.reviews ?? []).slice(0, 6)) {
816 const login = reviewer.login
817 if (login in have) continue
818 let png = ''
819 if (isPlainLogin(login)) {
820 const ran = await tryRun($, ['sh', '-c', 'curl -sfL --max-time 5 "https://github.com/$1.png?size=48" | base64 | tr -d "\\n"', 'sh', login])
821 if (ran?.exitCode === 0 && ran.stdout.startsWith('iVBOR')) png = ran.stdout.trim()
822 }
823 await update($, avatars, all => ({ ...all, [login]: png }))
824 }
825}
826
827async function nextLog($: $, key: string): Promise<void> {
828 const pr = (await read($, prs)).find(one => prKey(one) === key)
829 if (!pr) return
830 const failing = pr.checks.filter(c => c.state === 'fail' && parseActionsUrl(c.url)?.jobId)
831 if (failing.length === 0) return
832 const current = shownFailure(pr, await read($, logCheck))
833 const next = failing[(failing.indexOf(current ?? failing[0]!) + 1) % failing.length]!
834 await update($, logCheck, () => `${key}#${next.url}`)
835 await prefetchLog($)
836}
837
838async function copyLog($: $, key: string, surface: string): Promise<void> {
839 const pr = (await read($, prs)).find(one => prKey(one) === key)
840 const jobId = parseActionsUrl(pr && shownFailure(pr, await read($, logCheck))?.url)?.jobId
841 const lines = jobId ? (await read($, logs))[jobId]?.lines : undefined
842 if (!lines) {
843 $.ui.toast('No log loaded yet')
844 return
845 }
846 const copied = await $.ui.copy({ text: lines.join('\n'), surface: surface as never })
847 $.ui.toast(copied.isCopied ? `Copied ${lines.length} log lines` : "Couldn't copy the log")
848}
849
850// ── Rerun and review ────────────────────────────────────────────────────
851
852async function rerunFailed($: $, key: string): Promise<void> {
853 const pr = (await read($, prs)).find(one => prKey(one) === key)
854 const release = (await read($, releases)).find(one => releaseKey(one) === key)
855 const runIds = new Set<string>()
856 for (const c of pr?.checks ?? []) {
857 const id = c.state === 'fail' ? parseActionsUrl(c.url)?.runId : undefined
858 if (id) runIds.add(id)
859 }
860 for (const failed of release?.runs ?? []) if (failed.state === 'fail') runIds.add(String(failed.id))
861 if (runIds.size === 0) {
862 $.ui.toast('Nothing to rerun: no failed GitHub Actions runs')
863 return
864 }
865 let started = 0
866 for (const id of runIds) {
867 try {
868 await gh($, ['run', 'rerun', id, '--failed', ...inRepo(pr?.repo ?? release?.repo)])
869 started += 1
870 } catch (error) {
871 $.ui.toast(`Couldn't rerun run ${id}: ${message(error)}`)
872 }
873 }
874 if (started > 0) $.ui.toast(`Rerunning failed jobs in ${started} run${started === 1 ? '' : 's'}`)
875 void poll($, true)
876}
877
878async function askForReview($: $, url: string): Promise<void> {
879 const pr = (await read($, prs)).find(one => one.url === url)
880 if (!pr) return
881 const filled = await $.prompt.fill({ text: reviewPrompt(pr) })
882 $.ui.toast(filled.isFilled ? 'Review prompt ready: esc to the prompt, then Enter' : "Couldn't fill the prompt box")
883}
884
885async function trackFromCommand($: $, arg: string, asked: 'pr' | 'actions'): Promise<string> {
886 const link = pastedLink(arg)
887 if ((link?.kind ?? asked) === 'pr') {
888 try {
889 const pr = await trackPr($, link?.kind === 'pr' ? link.url : arg.trim())
890 return `Tracking PR #${pr.number}: ${pr.title}\n${pr.url}`
891 } catch (error) {
892 return `Couldn't find that PR: ${message(error)}`
893 }
894 }
895 try {
896 const release = await trackRelease($, await resolveRelease($, arg))
897 const runs = release.runs.map(r => ` ${glyph(r.state)} ${r.name} ${r.url}`).join('\n')
898 return `Tracking ${release.label}${release.url ? ` (${release.url})` : ''}\n${runs || ' No pipeline runs yet; watching for them.'}`
899 } catch (error) {
900 return `Couldn't find that in Actions: ${message(error)}`
901 }
902}
903
904// ── Board ──
905
906async function drawBoard($: $, e: RenderInput<'Pane'>) {
907 const elements = $.ui.resolve(e)
908 const { Box, Button, Link, Text } = elements
909 // Raster and Image are terminal-only.
910 const Raster = 'Raster' in elements ? elements.Raster : undefined
911 const Image = 'Image' in elements ? elements.Image : undefined
912 const party = await read($, celebration)
913 const pastRuns = await read($, history)
914 const faces = await read($, avatars)
915 const prList = await read($, prs)
916 const releaseList = await read($, releases)
917 const mine = await read($, openPrs)
918 const me = await read($, viewer)
919 const allLogs = await read($, logs)
920 const chosenLog = await read($, logCheck)
921 const f = await read($, frame)
922 const shown = await read($, layout)
923 const kept = await read($, undo)
924 const cursorKey = await read($, cursor)
925 const doing = await read($, busy)
926 const isShowingKeys = await read($, isKeysShown)
927 const props = e.props as { bodyColumns?: number; isFocused?: boolean; scroll?: { bodyRows?: number } }
928 const columns = Number(props.bodyColumns ?? 80)
929 const inner = Math.max(20, columns - 2)
930 const barWidth = Math.max(10, Math.min(64, inner))
931
932 const advice = setupAdvice(await read($, setup))
933 if (advice) {
934 return (
935 <Box flexDirection="column" paddingX={1} gap={1}>
936 <Text bold color="yellow">
937 {advice.title}
938 </Text>
939 <Box flexDirection="column">
940 {advice.steps.map((step, i) => (
941 <Box key={`step-${i}`} gap={2}>
942 <Text dimColor>{i + 1}</Text>
943 <Text>{step}</Text>
944 </Box>
945 ))}
946 </Box>
947 <Box gap={2}>
948 <Link href="https://cli.github.com" label="cli.github.com" />
949 <Button key="recheck" plain dimColor hotkey="r" label="Check again" onPress={() => checkGh($)} />
950 </Box>
951 </Box>
952 )
953 }
954
955 const Bar = ({ t, width }: { t: Tally; width: number }) => {
956 return (
957 <Box flexShrink={0}>
958 {segments(t, width).map((seg, i) => (
959 <Text key={`seg-${i}`} color={tone(seg.state)} dimColor={seg.state === 'skip'}>
960 {cells(seg, f)}
961 </Text>
962 ))}
963 </Box>
964 )
965 }
966
967 const Verdict = ({ state, t }: { state: Shown; t: Tally }) => (
968 <Box gap={1}>
969 <Text color={tone(state)}>{glyph(state, f)}</Text>
970 <Box width={10}>
971 <Text bold color={tone(state)}>
972 {verdict(state)}
973 </Text>
974 </Box>
975 <Text dimColor>{summary(t)}</Text>
976 </Box>
977 )
978
979 // A detail row: status, group (a check's workflow, a job's run), name, time.
980 // Fixed widths, shared by every row in the detail, so the columns line up.
981 const groupWidthOf = (groups: readonly string[]) => Math.min(24, Math.max(0, ...groups.map(group => group.length)))
982 const Row = ({ key, state, group, groupWidth, name, url, ms, isWaiting = false, isDim = false }: {
983 key: string
984 state: Shown
985 group: string
986 groupWidth: number
987 name: string
988 url?: string
989 ms?: number
990 isWaiting?: boolean
991 isDim?: boolean
992 }) => {
993 const columns = [1, groupWidth, TIME_COLUMN].filter(w => w > 0)
994 const nameWidth = Math.max(8, inner - columns.reduce((sum, w) => sum + w, 0) - 2 * columns.length)
995 return (
996 <Box key={key} gap={2}>
997 <Box width={1} flexShrink={0}>
998 <Text color={tone(state)}>{glyph(state, f)}</Text>
999 </Box>
1000 {groupWidth > 0 && (
1001 <Box width={groupWidth} flexShrink={0}>
1002 <Text dimColor wrap="truncate">
1003 {clip(group, groupWidth)}
1004 </Text>
1005 </Box>
1006 )}
1007 <Box width={nameWidth} flexShrink={0} overflow="hidden">
1008 {url ? (
1009 <Link href={url} label={clip(name, nameWidth)} />
1010 ) : (
1011 <Text dimColor={isDim} wrap="truncate">
1012 {clip(name, nameWidth)}
1013 </Text>
1014 )}
1015 </Box>
1016 <Box width={TIME_COLUMN} flexShrink={0} justifyContent="flex-end">
1017 <Text dimColor>{isWaiting ? 'waiting' : state === 'pending' ? 'running' : duration(ms)}</Text>
1018 </Box>
1019 </Box>
1020 )
1021 }
1022
1023 const items = [
1024 ...[...prList].reverse().map(pr => ({
1025 key: prKey(pr),
1026 label: `#${pr.number}`,
1027 title: pr.title,
1028 state: prState(pr) as Shown,
1029 t: tally(pr.checks),
1030 pr,
1031 })),
1032 ...[...releaseList].reverse().map(r => ({
1033 key: releaseKey(r),
1034 label: r.label,
1035 title: r.runs[0]?.title ?? '',
1036 state: releaseState(r) as Shown,
1037 t: tally(releaseItems(r)),
1038 release: r,
1039 })),
1040 ]
1041 const chosen = await read($, selected)
1042 const openKey = items.some(item => item.key === chosen) ? chosen : (items[0]?.key ?? '')
1043 const labelWidth = Math.min(14, Math.max(4, ...items.map(item => item.label.length), ...mine.prs.map(pr => `#${pr.number}`.length)))
1044
1045 const drawPr = (pr: Pr) => {
1046 const t = tally(pr.checks)
1047 const state = prState(pr)
1048 const workflows = [...new Set(pr.checks.map(c => c.workflow).filter((w): w is string => Boolean(w)))]
1049 const groupWidth = groupWidthOf(workflows)
1050 // Failures and their log first, then everything else.
1051 const failing = pr.checks.filter(c => c.state === 'fail')
1052 const others = byUrgency(pr.checks.filter(c => c.state !== 'fail'))
1053 const checkRow = (c: (typeof pr.checks)[number]) =>
1054 Row({
1055 key: `${pr.url}#${c.workflow ?? ''}/${c.name}`,
1056 state: c.state,
1057 group: c.workflow ?? '',
1058 groupWidth,
1059 name: c.name,
1060 url: c.url,
1061 ms: c.durationMs,
1062 })
1063 const review = reviewLabel(pr.review)
1064 const merge = mergeLabel(pr)
1065 return (
1066 <Box key={`detail-${pr.url}`} flexDirection="column" gap={1}>
1067 {Raster && isCelebrating(party, f) && party.kind === 'merged' && party.key === prKey(pr) && (
1068 <Raster
1069 key="confetti"
1070 {...toCells(confettiFrame(pr.number, f - party.startFrame, inner, 12))}
1071 />
1072 )}
1073 <Box flexDirection="column">
1074 <Box gap={2}>
1075 <Link href={pr.url} label={`#${pr.number}`} />
1076 <Box flexShrink={1} minWidth={0} overflow="hidden">
1077 <Text bold wrap="truncate-end">
1078 {pr.title}
1079 </Text>
1080 </Box>
1081 </Box>
1082 <Text dimColor wrap="truncate-end">
1083 {pr.repo.split('/')[1] ?? pr.repo} · {pr.branch}
1084 </Text>
1085 </Box>
1086
1087 <Box flexDirection="column">
1088 {Verdict({ state, t })}
1089 {Bar({ t, width: barWidth })}
1090 </Box>
1091
1092 {failing.length > 0 && (
1093 <Box flexDirection="column">
1094 {failing.map(checkRow)}
1095 </Box>
1096 )}
1097
1098 {(() => {
1099 const check = shownFailure(pr, chosenLog)
1100 const jobId = parseActionsUrl(check?.url)?.jobId
1101 if (!check || !jobId) return null
1102 const lines = allLogs[jobId]?.lines
1103 return (
1104 <Box flexDirection="column">
1105 <Text bold dimColor wrap="truncate-end">
1106 LOG · {check.workflow ? `${check.workflow} / ` : ''}{check.name}
1107 </Text>
1108 {lines ? (
1109 lines.slice(-LOG_PREVIEW).map((line, i) => (
1110 <Text key={`log-${i}`} dimColor wrap="truncate-end">
1111 {line}
1112 </Text>
1113 ))
1114 ) : (
1115 <Text dimColor>{glyph('pending', f)} loading the failed step's log…</Text>
1116 )}
1117 </Box>
1118 )
1119 })()}
1120
1121 {others.length > 0 && (
1122 <Box flexDirection="column">
1123 {others.slice(0, Math.max(0, MAX_CHECKS - failing.length)).map(checkRow)}
1124 {failing.length + others.length > MAX_CHECKS && (
1125 <Box paddingLeft={3}>
1126 <Text dimColor>{failing.length + others.length - Math.max(MAX_CHECKS, failing.length)} more</Text>
1127 </Box>
1128 )}
1129 </Box>
1130 )}
1131
1132 {settings.heatmap && pr.base && (() => {
1133 const rows = workflows
1134 .slice(0, 6)
1135 .map(w => ({ workflow: w, past: pastRuns[historyKey(pr, w)] }))
1136 .filter(row => row.past && row.past.results.length > 0)
1137 if (rows.length === 0) return null
1138 return (
1139 <Box flexDirection="column">
1140 <Text bold dimColor>
1141 HISTORY ON {pr.base.toUpperCase()}
1142 </Text>
1143 {rows.map(row => (
1144 <Box key={`history-${row.workflow}`} gap={2}>
1145 <Box width={1} flexShrink={0} />
1146 <Box width={Math.max(groupWidth, 1)} flexShrink={0}>
1147 <Text dimColor wrap="truncate">
1148 {clip(row.workflow, Math.max(groupWidth, 1))}
1149 </Text>
1150 </Box>
1151 <Box>
1152 {[...row.past!.results].reverse().map((result, i) => (
1153 <Text key={`h-${i}`} color={tone(result)} dimColor={result === 'skip'}>
1154 ⣿
1155 </Text>
1156 ))}
1157 </Box>
1158 {isFlaky(row.past!.results) && <Text color="yellow">flaky</Text>}
1159 </Box>
1160 ))}
1161 </Box>
1162 )
1163 })()}
1164
1165 {(pr.reviews?.length ?? 0) > 0 && (
1166 <Box gap={3} flexWrap="wrap">
1167 {(pr.reviews ?? []).map(reviewer => {
1168 const mark = reviewerGlyph(reviewer.state)
1169 const face = faces[reviewer.login]
1170 return (
1171 <Box key={`reviewer-${reviewer.login}`} gap={1}>
1172 {settings.avatars && Image && face ? (
1173 <Image key={`avatar-${reviewer.login}`} source={{ png: face }} columns={2} rows={1} alt={reviewer.login.slice(0, 2)} />
1174 ) : null}
1175 <Text color={mark.color}>{mark.glyph}</Text>
1176 <Text dimColor={reviewer.state === 'REQUESTED'}>{reviewer.login}</Text>
1177 </Box>
1178 )
1179 })}
1180 </Box>
1181 )}
1182
1183 <Box gap={4}>
1184 <Box gap={2}>
1185 <Text dimColor>Review</Text>
1186 <Text color={review.state === 'skip' ? undefined : tone(review.state)}>{review.text}</Text>
1187 </Box>
1188 <Box gap={2}>
1189 <Text dimColor>Merge</Text>
1190 <Text color={merge.state === 'skip' ? undefined : tone(merge.state)}>{merge.text}</Text>
1191 </Box>
1192 </Box>
1193 {pr.error && <Text color="yellow">{pr.error}</Text>}
1194 </Box>
1195 )
1196 }
1197
1198 const drawRelease = (r: Release) => {
1199 const t = tally(releaseItems(r))
1200 const runWidth = groupWidthOf(r.runs.map(run => run.name))hooks/lib.ts 873 lines1import type { Check, CheckState, Job, OpenPr, Pr, Release, Reviewer, Run, Setup } from '../types'
2
3// ── gh JSON → our shapes ────────────────────────────────────────────────
4
5type RawCheck = {
6 __typename?: string
7 name?: string
8 context?: string
9 workflowName?: string
10 status?: string
11 conclusion?: string
12 state?: string
13 detailsUrl?: string
14 targetUrl?: string
15 startedAt?: string
16 completedAt?: string
17}
18
19const FAIL = new Set([
20 'FAILURE',
21 'ERROR',
22 'CANCELLED',
23 'TIMED_OUT',
24 'ACTION_REQUIRED',
25 'STARTUP_FAILURE',
26 'STALE',
27])
28const SKIP = new Set(['SKIPPED', 'NEUTRAL'])
29
30// Works for a CheckRun's status/conclusion and a StatusContext's state.
31export function checkState(status?: string, conclusion?: string): CheckState {
32 const s = (status ?? '').toUpperCase()
33 const c = (conclusion ?? '').toUpperCase()
34 if (c === 'SUCCESS') return 'pass'
35 if (FAIL.has(c)) return 'fail'
36 if (SKIP.has(c)) return 'skip'
37 if (s === 'SUCCESS') return 'pass'
38 if (s === 'FAILURE' || s === 'ERROR') return 'fail'
39 return 'pending'
40}
41
42export function elapsed(from?: string, to?: string): number | undefined {
43 const start = Date.parse(from ?? '')
44 const end = Date.parse(to ?? '')
45 if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return undefined
46 return end - start
47}
48
49function safeUrl(url?: string): string | undefined {
50 return url && /^https:\/\/[\x21-\x7e]+$/.test(url) && !url.includes('@') ? url : undefined
51}
52
53export function parseChecks(rollup: unknown): Check[] {
54 if (!Array.isArray(rollup)) return []
55 return (rollup as RawCheck[]).map(raw => {
56 if (raw.__typename === 'StatusContext') {
57 return { name: raw.context ?? 'status', state: checkState(raw.state), url: safeUrl(raw.targetUrl) }
58 }
59 const state = checkState(raw.status, raw.conclusion)
60 return {
61 name: raw.name || 'check',
62 state,
63 url: safeUrl(raw.detailsUrl),
64 workflow: raw.workflowName || undefined,
65 durationMs: state === 'pending' ? undefined : elapsed(raw.startedAt, raw.completedAt),
66 }
67 })
68}
69
70export function repoOf(url: string): string {
71 const m = /github\.com\/([^/]+\/[^/]+)\//.exec(url)
72 return m?.[1] ?? ''
73}
74
75export function parsePr(json: string): Pr {
76 const raw = JSON.parse(json) as Record<string, unknown>
77 const url = String(raw.url ?? '')
78 return {
79 url,
80 number: Number(raw.number ?? 0),
81 repo: repoOf(url),
82 title: String(raw.title ?? ''),
83 branch: String(raw.headRefName ?? ''),
84 state: (String(raw.state ?? 'OPEN') as Pr['state']),
85 isDraft: raw.isDraft === true,
86 review: String(raw.reviewDecision ?? ''),
87 mergeable: String(raw.mergeable ?? 'UNKNOWN'),
88 checks: parseChecks(raw.statusCheckRollup),
89 base: raw.baseRefName ? String(raw.baseRefName) : undefined,
90 author: (raw.author as { login?: string } | undefined)?.login,
91 endedAt: raw.mergedAt ? String(raw.mergedAt) : raw.closedAt ? String(raw.closedAt) : undefined,
92 reviews: parseReviewers(raw.latestReviews, raw.reviewRequests),
93 }
94}
95
96export function parseOpenPrs(json: string): OpenPr[] {
97 const list = JSON.parse(json) as Record<string, unknown>[]
98 return list.map(raw => ({
99 url: String(raw.url ?? ''),
100 number: Number(raw.number ?? 0),
101 title: String(raw.title ?? ''),
102 isDraft: raw.isDraft === true,
103 checks: parseChecks(raw.statusCheckRollup),
104 }))
105}
106
107type RawRun = {
108 databaseId?: number
109 name?: string
110 workflowName?: string
111 displayTitle?: string
112 url?: string
113 status?: string
114 conclusion?: string
115 headBranch?: string
116 startedAt?: string
117 updatedAt?: string
118 attempt?: number
119}
120
121export function parseRuns(json: string): Run[] {
122 const list = JSON.parse(json) as RawRun[]
123 return list.map(raw => {
124 const state = checkState(raw.status, raw.conclusion)
125 return {
126 id: Number(raw.databaseId ?? 0),
127 name: raw.workflowName ?? raw.name ?? 'workflow',
128 title: raw.displayTitle ?? '',
129 url: safeUrl(raw.url) ?? '',
130 state,
131 branch: raw.headBranch ?? '',
132 attempt: Number(raw.attempt ?? 1),
133 durationMs: state === 'pending' ? undefined : elapsed(raw.startedAt, raw.updatedAt),
134 jobs: [],
135 }
136 })
137}
138
139export type PlannedJob = { key: string; name: string }
140
141function unquote(text: string): string {
142 const t = text.replace(/\s+#.*$/, '').trim()
143 return /^(['"]).*\1$/.test(t) ? t.slice(1, -1) : t
144}
145
146/**
147 * Job keys and names from a workflow file's `jobs:` block. Not a YAML parser,
148 * just enough to see jobs GitHub won't list until their `needs` finish.
149 */
150export function plannedJobs(yaml: string): PlannedJob[] {
151 const lines = yaml.split(/\r?\n/)
152 const start = lines.findIndex(line => /^jobs:\s*(#.*)?$/.test(line))
153 if (start < 0) return []
154 const jobs: PlannedJob[] = []
155 let jobIndent = -1
156 let propIndent = -1
157 for (const line of lines.slice(start + 1)) {
158 if (!line.trim() || line.trim().startsWith('#')) continue
159 const indent = line.length - line.trimStart().length
160 if (indent === 0) break
161 if (jobIndent < 0) jobIndent = indent
162 if (indent === jobIndent) {
163 const key = /^\s*([\w-]+):\s*(#.*)?$/.exec(line)?.[1]
164 if (key) jobs.push({ key, name: key })
165 propIndent = -1
166 continue
167 }
168 const job = jobs.at(-1)
169 if (!job || indent < jobIndent) continue
170 if (propIndent < 0) propIndent = indent
171 const name = indent === propIndent ? /^\s*name:\s*(.+)$/.exec(line)?.[1] : undefined
172 if (name) job.name = unquote(name)
173 }
174 return jobs
175}
176
177// Matrix jobs get " (...)" appended, reusable workflows " / ...".
178function isFrom(job: Job, planned: PlannedJob): boolean {
179 const name = planned.name.split('${{')[0]!.trim()
180 if (!name) return job.name.startsWith(planned.key)
181 return job.name === name || job.name.startsWith(`${name} (`) || job.name.startsWith(`${name} / `) || (planned.name.includes('${{') && job.name.startsWith(name))
182}
183
184export function withWaitingJobs(jobs: readonly Job[], planned: readonly PlannedJob[], isRunGoing: boolean): Job[] {
185 if (!isRunGoing) return [...jobs]
186 const waiting = planned
187 .filter(p => !jobs.some(job => isFrom(job, p)))
188 .map((p): Job => ({ name: p.name.split('${{')[0]!.trim() || p.key, state: 'pending', isWaiting: true }))
189 return [...jobs, ...waiting]
190}
191
192export function parseJobs(json: string): Job[] {
193 const raw = JSON.parse(json) as { jobs?: RawCheck[] }
194 return (raw.jobs ?? []).map(job => {
195 const state = checkState(job.status, job.conclusion)
196 return {
197 name: job.name ?? 'job',
198 state,
199 url: safeUrl((job as { url?: string }).url),
200 durationMs: state === 'pending' ? undefined : elapsed(job.startedAt, job.completedAt),
201 }
202 })
203}
204
205// ── Finding things in Bash output ───────────────────────────────────────
206
207export const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/
208export const RELEASE_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/releases\/tag\/([^\s"'<>]+)/
209
210export type ActionsTarget = { repo?: string; tag?: string; workflow?: string; runId?: number }
211
212const REPO = /^[\w.-]+\/[\w.-]+$/
213const GITHUB = /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)(?:\/(.*))?$/
214
215// What /pulse-release was given: a tag, a workflow file, a run, or a repo's
216// latest release, each optionally in another repo (`owner/repo`, `-R`, or a URL).
217export function actionsTarget(arg: string): ActionsTarget {
218 const tokens = arg.trim().split(/\s+/).filter(Boolean)
219 let repo: string | undefined
220 const rest: string[] = []
221 for (let i = 0; i < tokens.length; i++) {
222 const token = tokens[i] ?? ''
223 if (token === '-R' || token === '--repo') repo = tokens[++i]
224 else if (token.startsWith('--repo=')) repo = token.slice('--repo='.length)
225 else rest.push(token)
226 }
227
228 const url = GITHUB.exec(rest[0] ?? '')
229 if (url?.[1]) {
230 const name = url[1].replace(/\.git$/, '')
231 const path = (url[2] ?? '').split(/[?#]/)[0] ?? ''
232 const run = /^actions\/runs\/(\d+)/.exec(path)?.[1]
233 if (run) return { repo: name, runId: Number(run) }
234 const workflow = /^actions\/workflows\/([^/]+\.ya?ml)/.exec(path)?.[1]
235 if (workflow) return { repo: name, workflow }
236 const tag = /^releases\/tag\/(.+)$/.exec(path)?.[1]
237 if (tag) return { repo: name, tag: decodeURIComponent(tag) }
238 return { repo: name }
239 }
240
241 // `owner/repo` first, then what to track in it. Alone, it means that repo.
242 if (!repo && rest.length > 0 && REPO.test(rest[0] ?? '')) repo = rest.shift()
243 const what = rest[0]
244 if (!what) return { repo }
245 if (/\.ya?ml$/.test(what)) return { repo, workflow: what }
246 if (/^\d{6,}$/.test(what)) return { repo, runId: Number(what) }
247 return { repo, tag: what }
248}
249
250// A pasted link or reference: a PR, something in Actions, or neither.
251// PR links keep working with /checks, /files and the like on the end.
252export function pastedLink(arg: string): { kind: 'pr'; url: string } | { kind: 'actions' } | undefined {
253 const text = arg.trim()
254 const pr = /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/.exec(text) ?? /^([\w.-]+\/[\w.-]+)#(\d+)$/.exec(text)
255 if (pr) return { kind: 'pr', url: `https://github.com/${pr[1]}/pull/${pr[2]}` }
256 if (/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(actions|releases)(\/|$)/.test(text)) return { kind: 'actions' }
257 return undefined
258}
259
260// The repo a gh command was pointed at with -R / --repo.
261export function repoFlag(command: string): string | undefined {
262 const m = /(?:^|\s)(?:-R|--repo)(?:\s+|=)['"]?([\w.-]+\/[\w.-]+)/.exec(command.split(/&&|;|\|/)[0] ?? '')
263 return m?.[1]
264}
265
266export function isPrCreate(command: string): boolean {
267 return /\bgh\s+pr\s+create\b/.test(command)
268}
269
270export function isReleaseCreate(command: string): boolean {
271 return /\bgh\s+release\s+create\b/.test(command)
272}
273
274const VALUE_FLAGS = new Set(['-r', '--ref', '-f', '--raw-field', '-F', '--field', '-R', '--repo'])
275
276export function workflowRun(command: string): string | undefined {
277 const m = /\bgh\s+workflow\s+run\s+(.*)$/.exec(command.split(/&&|;|\|/)[0] ?? '')
278 const tokens = (m?.[1] ?? '').match(/"[^"]*"|'[^']*'|\S+/g) ?? []
279 for (let i = 0; i < tokens.length; i++) {
280 const token = tokens[i] ?? ''
281 if (token.startsWith('-')) {
282 if (VALUE_FLAGS.has(token)) i++
283 continue
284 }
285 return token.replace(/^['"]|['"]$/g, '')
286 }
287 return undefined
288}
289
290// ── Tallies ─────────────────────────────────────────────────────────────
291
292export type Tally = { pass: number; fail: number; pending: number; skip: number; total: number }
293
294export function tally(items: readonly { state: CheckState }[]): Tally {
295 const t: Tally = { pass: 0, fail: 0, pending: 0, skip: 0, total: items.length }
296 for (const item of items) t[item.state] += 1
297 return t
298}
299
300export function overall(items: readonly { state: CheckState }[]): CheckState {
301 const t = tally(items)
302 if (t.fail > 0) return 'fail'
303 if (t.pending > 0) return 'pending'
304 if (t.pass > 0) return 'pass'
305 return t.skip > 0 ? 'skip' : 'pending'
306}
307
308export function releaseState(release: Release): CheckState {
309 return release.runs.length === 0 ? 'pending' : overall(release.runs)
310}
311
312export function prState(pr: Pr): CheckState | 'merged' | 'closed' {
313 if (pr.state === 'MERGED') return 'merged'
314 if (pr.state === 'CLOSED') return 'closed'
315 return pr.checks.length === 0 ? 'pending' : overall(pr.checks)
316}
317
318// Not the same as overall() === 'pending': one failure decides the verdict
319// while other checks keep running.
320export function isRunning(items: readonly { state: CheckState }[]): boolean {
321 return items.some(item => item.state === 'pending')
322}
323
324export function isPrRunning(pr: Pr): boolean {
325 return pr.state === 'OPEN' && (pr.checks.length === 0 || isRunning(pr.checks))
326}
327
328export function isReleaseRunning(release: Release): boolean {
329 return release.runs.length === 0 || isRunning(release.runs) || isRunning(release.runs.flatMap(run => run.jobs))
330}
331
332export function isLive(prs: readonly Pr[], releases: readonly Release[]): boolean {
333 return prs.some(isPrRunning) || releases.some(isReleaseRunning)
334}
335
336// ── Look ────────────────────────────────────────────────────────────────
337
338export type Shown = CheckState | 'merged' | 'closed'
339
340export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
341
342// A cell filling one dot at a time, left column then right.
343export const FILL = ['⣀', '⣄', '⣆', '⣇', '⣧', '⣷', '⣿'] as const
344const STEPS_PER_CELL = FILL.length - 1
345export const FRAME_MS = 50
346export const SPINNER_EVERY = 2 // 100ms per spinner step
347export const HOLD_FRAMES = 8 // pause at full before the loader restarts
348
349function at<T>(list: readonly T[], n: number): T {
350 return list[((n % list.length) + list.length) % list.length] as T
351}
352
353export function glyph(state: Shown, frame = 0): string {
354 switch (state) {
355 case 'pass':
356 return '✓'
357 case 'fail':
358 return '✕'
359 case 'skip':
360 return '-'
361 // ● and ○ draw two cells wide in some terminals, which breaks alignment.
362 case 'merged':
363 return '✓'
364 case 'closed':
365 return '✕'
366 default:
367 return at(SPINNER, Math.floor(frame / SPINNER_EVERY))
368 }
369}
370
371export function tone(state: Shown): string {
372 switch (state) {
373 case 'pass':
374 return 'green'
375 case 'fail':
376 return 'red'
377 case 'pending':
378 return 'yellow'
379 case 'merged':
380 return 'magenta'
381 default:
382 return 'gray'
383 }
384}
385
386export function verdict(state: Shown): string {
387 switch (state) {
388 case 'pass':
389 return 'Passing'
390 case 'fail':
391 return 'Failing'
392 case 'skip':
393 return 'Skipped'
394 case 'merged':
395 return 'Merged'
396 case 'closed':
397 return 'Closed'
398 default:
399 return 'Running'
400 }
401}
402
403/** "5 passed · 1 failed · 3 running · 1 skipped", zero counts left out. */
404export function summary(t: Tally): string {
405 const parts = [
406 t.pass && `${t.pass} passed`,
407 t.fail && `${t.fail} failed`,
408 t.pending && `${t.pending} running`,
409 t.skip && `${t.skip} skipped`,
410 ].filter(Boolean)
411 return parts.length === 0 ? 'no checks yet' : parts.join(' · ')
412}
413
414/** The non-passing part of a summary, for the one-line band. */
415export function trouble(t: Tally): string {
416 const parts = [t.fail && `${t.fail} failed`, t.pending && `${t.pending} running`].filter(Boolean)
417 return parts.length === 0 ? (t.total > 0 ? 'all passed' : 'no checks yet') : parts.join(' · ')
418}
419
420export type Segment = { state: CheckState; width: number }
421
422const BAR_ORDER: readonly CheckState[] = ['pass', 'fail', 'pending', 'skip']
423
424// Largest-remainder rounding so the widths add up; every state present gets
425// at least one cell.
426export function segments(t: Tally, width: number): Segment[] {
427 if (t.total === 0) return [{ state: 'skip', width }]
428 const present = BAR_ORDER.filter(state => t[state] > 0)
429 const floor = Math.min(1, Math.floor(width / present.length))
430 const spare = width - floor * present.length
431 const exact = present.map(state => (t[state] / t.total) * spare)
432 const cells = exact.map(Math.floor)
433 let left = spare - cells.reduce((a, b) => a + b, 0)
434 const byRemainder = exact.map((x, i) => ({ i, r: x - Math.floor(x) })).sort((a, b) => b.r - a.r)
435 for (const { i } of byRemainder) {
436 if (left <= 0) break
437 cells[i] = (cells[i] ?? 0) + 1
438 left -= 1
439 }
440 return present.map((state, i) => ({ state, width: (cells[i] ?? 0) + floor }))
441}
442
443
444export function cells(seg: Segment, frame = 0): string {
445 if (seg.state === 'pass' || seg.state === 'fail') return '⣿'.repeat(seg.width)
446 if (seg.state === 'skip') return '⣀'.repeat(seg.width)
447 const total = seg.width * STEPS_PER_CELL
448 const cycle = total + HOLD_FRAMES
449 const filled = Math.min(total, ((frame % cycle) + cycle) % cycle)
450 let out = ''
451 for (let i = 0; i < seg.width; i++) {
452 out += FILL[Math.max(0, Math.min(STEPS_PER_CELL, filled - i * STEPS_PER_CELL))]
453 }
454 return out
455}
456
457/** 4s · 38s · 1m 02s · 1h 03m */
458export function duration(ms?: number): string {
459 if (ms === undefined) return ''
460 const s = Math.round(ms / 1000)
461 if (s < 60) return `${s}s`
462 const m = Math.floor(s / 60)
463 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
464 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
465}
466
467/** Failures first, then running, passed, skipped. */
468export function byUrgency<T extends { state: CheckState }>(items: readonly T[]): T[] {
469 const rank: Record<CheckState, number> = { fail: 0, pending: 1, pass: 2, skip: 3 }
470 return [...items].sort((a, b) => rank[a.state] - rank[b.state])
471}
472
473export function reviewLabel(review: string): { text: string; state: Shown } {
474 switch (review) {
475 case 'APPROVED':
476 return { text: 'approved', state: 'pass' }
477 case 'CHANGES_REQUESTED':
478 return { text: 'changes requested', state: 'fail' }
479 case 'REVIEW_REQUIRED':
480 return { text: 'required', state: 'pending' }
481 default:
482 return { text: 'none yet', state: 'skip' }
483 }
484}
485
486export function mergeLabel(pr: Pr): { text: string; state: Shown } {
487 if (pr.isDraft) return { text: 'draft', state: 'skip' }
488 switch (pr.mergeable) {
489 case 'MERGEABLE':
490 return { text: 'clean', state: 'pass' }
491 case 'CONFLICTING':
492 return { text: 'conflicts', state: 'fail' }
493 default:
494 return { text: 'checking', state: 'skip' }
495 }
496}
497
498// Keys include the attempt, so a re-run's jobs are new invaders.
499export function releaseInvaders(release: Release): { key: string; state: CheckState }[] {
500 const jobs = release.runs.flatMap(run => run.jobs.map(job => ({ key: `${run.id}:${run.attempt ?? 1}:${job.name}`, state: job.state })))
501 return jobs.length > 0 ? jobs : release.runs.map(run => ({ key: `${run.id}:${run.attempt ?? 1}`, state: run.state }))
502}
503
504export function releaseItems(release: Release): readonly { state: CheckState }[] {
505 const jobs = release.runs.flatMap(run => run.jobs)
506 return jobs.length > 0 ? jobs : release.runs
507}
508
509// ✕ #130 5/10 · ⠹ v1.4.0 4/6
510export function statusLine(prs: readonly Pr[], releases: readonly Release[], frame: number): string | undefined {
511 const parts: string[] = []
512 for (const pr of prs.slice(-2)) {
513 const t = tally(pr.checks)
514 parts.push(`${glyph(prState(pr), frame)} #${pr.number} ${t.pass}/${t.total}`)
515 }
516 for (const r of releases.slice(-1)) {
517 const t = tally(releaseItems(r))
518 parts.push(`${glyph(releaseState(r), frame)} ${r.label} ${t.pass}/${t.total}`)
519 }
520 return parts.length === 0 ? undefined : parts.join(' · ')
521}
522
523// ── Toasts ──────────────────────────────────────────────────────────────
524
525export function prToast(before: Pr | undefined, after: Pr): string | undefined {
526 const was = before ? prState(before) : undefined
527 const now = prState(after)
528 if (was === now || after.error) return undefined
529 const tag = `PR #${after.number}`
530 if (now === 'merged') return `${tag} · merged`
531 if (now === 'closed') return `${tag} · closed`
532 if (now === 'pass' && was !== undefined) return `${tag} · all checks passed`
533 if (now === 'fail') {
534 const failed = after.checks.filter(c => c.state === 'fail').map(c => c.name)
535 return `${tag} · ${failed.slice(0, 2).join(', ')}${failed.length > 2 ? ` +${failed.length - 2}` : ''} failed`
536 }
537 return undefined
538}
539
540export function releaseToast(before: Release | undefined, after: Release): string | undefined {
541 const was = before ? releaseState(before) : undefined
542 const now = releaseState(after)
543 if (was === now || after.error || after.runs.length === 0) return undefined
544 if (now === 'pass' && was !== undefined) return `${after.label} · released`
545 if (now === 'fail') {
546 const failed = after.runs.filter(run => run.state === 'fail').map(run => run.name)
547 return `${after.label} · ${failed.slice(0, 2).join(', ') || 'a pipeline'} failed`
548 }
549 return undefined
550}
551
552// ── Setup ───────────────────────────────────────────────────────────────
553
554export const GH_DOWNLOAD = 'https://cli.github.com'
555
556export function installCommand(os: Setup['os']): string | undefined {
557 switch (os) {
558 case 'mac':
559 return 'brew install gh'
560 case 'windows':
561 return 'winget install --id GitHub.cli'
562 default:
563 return undefined // varies by distro; the download page covers it
564 }
565}
566
567export function setupAdvice(setup: Setup): { title: string; steps: string[] } | undefined {
568 if (setup.gh === 'missing') {
569 const install = installCommand(setup.os)
570 return {
571 title: 'gh-pulse needs the GitHub CLI',
572 steps: [
573 install ? `Install it: ${install} (or ${GH_DOWNLOAD})` : `Install it: ${GH_DOWNLOAD}`,
574 'Then sign in: gh auth login',
575 ],
576 }
577 }
578 if (setup.gh === 'signed-out') {
579 return {
580 title: 'gh-pulse needs you signed in to GitHub',
581 steps: ['Run in a terminal: gh auth login', 'gh-pulse picks it up by itself within 15s'],
582 }
583 }
584 return undefined
585}
586
587export function setupText(setup: Setup): string | undefined {
588 const advice = setupAdvice(setup)
589 return advice && `${advice.title}.\n${advice.steps.map(step => ` ${step}`).join('\n')}`
590}
591
592// ── Logs and the fix prompt ─────────────────────────────────────────────
593
594export function parseActionsUrl(url?: string): { runId: string; jobId?: string } | undefined {
595 const m = /\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/.exec(url ?? '')
596 return m?.[1] ? { runId: m[1], jobId: m[2] } : undefined
597}
598
599const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g
600const STAMP = /^\d{4}-\d\d-\d\dT[\d:.]+Z ?/
601
602// `gh run view --log-failed` lines look like `job<TAB>step<TAB>timestamp text`.
603export function cleanLog(raw: string, keep = 80): string[] {
604 const lines: string[] = []
605 for (const line of raw.split('\n')) {
606 const parts = line.split('\t')
607 let text = (parts.length >= 3 ? parts.slice(2).join('\t') : line).replace(ANSI, '').replace(STAMP, '')
608 text = text.replace(/^##\[(group|endgroup)\]/, '').trimEnd()
609 if (text.startsWith('##[')) text = text.replace(/^##\[\w+\]/, '')
610 if (text.trim() === '') continue
611 lines.push(text)
612 }
613 return lines.slice(-keep)
614}
615
616export type Failure = { name: string; workflow?: string; url?: string; log?: string[] }
617
618export function fixPrompt(pr: Pr, failures: readonly Failure[]): string {
619 const parts = [
620 `CI is failing on PR #${pr.number} "${pr.title}" (${pr.repo}, branch \`${pr.branch}\`).`,
621 '',
622 ]
623 for (const f of failures) {
624 parts.push(`Failing check: ${f.workflow ? `${f.workflow} / ` : ''}${f.name}${f.url ? ` (${f.url})` : ''}`)
625 if (f.log && f.log.length > 0) {
626 parts.push('```', ...f.log.slice(-60), '```')
627 } else {
628 parts.push('(no log available from GitHub Actions for this check)')
629 }
630 parts.push('')
631 }
632 parts.push(`Find the cause and fix it on branch \`${pr.branch}\`.`)
633 return parts.join('\n')
634}
635
636// ── Transitions ─────────────────────────────────────────────────────────
637
638export type Transition = 'green' | 'failed' | 'merged' | 'closed' | 'released' | 'scrubbed'
639
640export function prTransition(before: Pr | undefined, after: Pr): Transition | undefined {
641 if (!before || after.error) return undefined
642 const was = prState(before)
643 const now = prState(after)
644 if (was === now) return undefined
645 if (now === 'merged') return 'merged'
646 if (now === 'closed') return 'closed'
647 if (now === 'pass') return 'green'
648 if (now === 'fail') return 'failed'
649 return undefined
650}
651
652export function releaseTransition(before: Release | undefined, after: Release): Transition | undefined {
653 if (!before || after.error || after.runs.length === 0) return undefined
654 const was = releaseState(before)
655 const now = releaseState(after)
656 if (was === now) return undefined
657 if (now === 'pass') return 'released'
658 if (now === 'fail') return 'scrubbed'
659 return undefined
660}
661
662// ── Settings ────────────────────────────────────────────────────────────
663
664export type Settings = {
665 claudeContext: boolean
666 confetti: boolean
667 invaders: boolean
668 avatars: boolean
669 heatmap: boolean
670 sounds: boolean
671 speech: boolean
672}
673
674export const DEFAULT_SETTINGS: Settings = {
675 claudeContext: true,
676 confetti: true,
677 invaders: true,
678 avatars: true,
679 heatmap: true,
680 sounds: false,
681 speech: false,
682}
683
684export function readSettings(options: Readonly<Record<string, unknown>> | undefined): Settings {
685 const out = { ...DEFAULT_SETTINGS }
686 for (const key of Object.keys(out) as (keyof Settings)[]) {
687 const value = options?.[key]
688 if (typeof value === 'boolean') out[key] = value
689 }
690 return out
691}
692
693// ── Review prompt ───────────────────────────────────────────────────────
694
695export function reviewPrompt(request: { url: string; number: number; title: string }): string {
696 return `Review PR #${request.number} "${request.title}" (${request.url}). Use gh to read the diff and any discussion, then give me a concise review: real problems first, then suggestions.`
697}
698
699// ── Prompt context ──────────────────────────────────────────────────────
700
701const CONTEXT_LIMIT = 600
702
703export function ciContext(prs: readonly Pr[], releases: readonly Release[]): string | undefined {
704 const lines: string[] = []
705 for (const pr of [...prs].reverse()) {
706 const state = prState(pr)
707 const t = tally(pr.checks)
708 const failing = pr.checks.filter(c => c.state === 'fail').map(c => (c.workflow ? `${c.workflow} / ${c.name}` : c.name))
709 const parts = [`PR #${pr.number} "${pr.title}" (${pr.repo}, branch ${pr.branch}): ${verdict(state).toUpperCase()}`]
710 if (failing.length > 0) parts.push(`failing: ${failing.join(', ')}`)
711 if (t.pending > 0) parts.push(`${t.pending} running`)
712 if (state !== 'merged' && state !== 'closed') parts.push(`${t.pass}/${t.total} passed`, `review ${reviewLabel(pr.review).text}`)
713 lines.push(parts.join('; '))
714 }
715 for (const r of [...releases].reverse()) {
716 const t = tally(releaseItems(r))
717 const failed = r.runs.filter(run => run.state === 'fail').map(run => run.name)
718 lines.push(
719 `Release ${r.label}: ${verdict(releaseState(r)).toUpperCase()}; ${t.pass}/${t.total} done${failed.length > 0 ? `; failed: ${failed.join(', ')}` : ''}`,
720 )
721 }
722 if (lines.length === 0) return undefined
723 let text = `gh-pulse (live CI status of what the user is tracking):\n${lines.map(line => `- ${line}`).join('\n')}`
724 if (text.length > CONTEXT_LIMIT) text = `${text.slice(0, CONTEXT_LIMIT - 1)}…`
725 return text
726}
727
728// ── Reviewers ───────────────────────────────────────────────────────────
729
730const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED'])
731
732// Latest review per person, then pending requests. Team requests are skipped.
733export function parseReviewers(latest: unknown, requested: unknown): Reviewer[] | undefined {
734 if (!Array.isArray(latest) && !Array.isArray(requested)) return undefined
735 const out: Reviewer[] = []
736 const seen = new Set<string>()
737 for (const raw of (Array.isArray(latest) ? latest : []) as { author?: { login?: string }; state?: string }[]) {
738 const login = raw.author?.login
739 const state = String(raw.state ?? '')
740 if (!login || seen.has(login) || !REVIEW_STATES.has(state)) continue
741 seen.add(login)
742 out.push({ login, state: state as Reviewer['state'] })
743 }
744 for (const raw of (Array.isArray(requested) ? requested : []) as { login?: string }[]) {
745 if (!raw.login || seen.has(raw.login)) continue
746 seen.add(raw.login)
747 out.push({ login: raw.login, state: 'REQUESTED' })
748 }
749 return out
750}
751
752export function reviewerGlyph(state: Reviewer['state']): { glyph: string; color: string } {
753 switch (state) {
754 case 'APPROVED':
755 return { glyph: '✓', color: 'green' }
756 case 'CHANGES_REQUESTED':
757 return { glyph: '✕', color: 'red' }
758 case 'COMMENTED':
759 return { glyph: '…', color: 'gray' }
760 default:
761 return { glyph: '?', color: 'yellow' }
762 }
763}
764
765export function isPlainLogin(login: string): boolean {
766 return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(login)
767}
768
769// ── CI history ──────────────────────────────────────────────────────────
770
771export function parseHistory(json: string): CheckState[] {
772 const list = JSON.parse(json) as { status?: string; conclusion?: string }[]
773 return list.map(raw => checkState(raw.status, raw.conclusion))
774}
775
776// Three or more pass/fail flips.
777export function isFlaky(results: readonly CheckState[]): boolean {
778 const decided = results.filter(r => r === 'pass' || r === 'fail')
779 let flips = 0
780 for (let i = 1; i < decided.length; i++) if (decided[i] !== decided[i - 1]) flips += 1
781 return flips >= 3
782}
783
784// ── Speech and sounds ───────────────────────────────────────────────────
785
786export function spoken(label: string, happened: Transition): string {
787 switch (happened) {
788 case 'green':
789 return `${label} is green`
790 case 'failed':
791 return `${label} failed`
792 case 'merged':
793 return `${label} merged`
794 case 'closed':
795 return `${label} was closed`
796 case 'released':
797 return `${label} released`
798 default:
799 return `${label} release failed`
800 }
801}
802
803export const SOUND_FOR: Record<Transition, string | undefined> = {
804 green: 'assets/sounds/green.wav',
805 failed: 'assets/sounds/failed.wav',
806 merged: 'assets/sounds/merged.wav',
807 closed: undefined,
808 released: 'assets/sounds/released.wav',
809 scrubbed: 'assets/sounds/failed.wav',
810}
811
812// ── Board ───────────────────────────────────────────────────────────────
813
814// undefined past either end, so the board scrolls instead.
815export function nextRow(rows: readonly string[], current: string | undefined, by: number): string | undefined {
816 const at = current === undefined ? -1 : rows.indexOf(current)
817 if (at === -1) return by > 0 ? rows[0] : undefined
818 return rows[at + Math.sign(by)]
819}
820
821// First index of a `size`-row window over `total` rows that keeps row `at`
822// in view, moving no further than it has to from `start`. `at` -1 keeps it put.
823export function scrollWindow(start: number, at: number, total: number, size: number): number {
824 let next = Math.min(Math.max(0, start), Math.max(0, total - size))
825 if (at >= 0 && at < next) next = at
826 if (at >= 0 && at >= next + size) next = at - size + 1
827 return next
828}
829
830// Cut to `width` cells ending in "...". ASCII on purpose: some terminals draw … two cells wide.
831export function clip(text: string, width: number): string {
832 if (width < 1) return ''
833 if (text.length <= width) return text
834 return width <= 3 ? text.slice(0, width) : `${text.slice(0, width - 3)}...`
835}
836
837// Lay items of these widths into rows no wider than `width`, `gap` apart.
838// Returns each row's item indexes. An item wider than a row gets one to itself.
839export function packRows(widths: readonly number[], width: number, gap: number): number[][] {
840 const rows: number[][] = []
841 let used = 0
842 widths.forEach((w, i) => {
843 const row = rows[rows.length - 1]
844 if (row && used + gap + w <= width) {
845 row.push(i)
846 used += gap + w
847 } else {
848 rows.push([i])
849 used = w
850 }
851 })
852 return rows
853}
854
855// Merged or closed PRs, and releases that finished without failing.
856export function isFinished(item: Pr | Release): boolean {
857 if ('checks' in item) return item.state !== 'OPEN'
858 return item.runs.length > 0 && !isReleaseRunning(item) && releaseState(item) !== 'fail'
859}
860
861export function isStale(pr: Pr, now: number, afterMs: number): boolean {
862 const ended = Date.parse(pr.endedAt ?? '')
863 return pr.state !== 'OPEN' && Number.isFinite(ended) && now - ended > afterMs
864}
865
866// Replace a matching item where it stands, or append a new one; keeps at most `max`.
867// Moving updated items to the end would reshuffle every list on each poll.
868export function upsertBy<T>(list: readonly T[], item: T, isSame: (one: T) => boolean, max: number): T[] {
869 const at = list.findIndex(isSame)
870 const next = at === -1 ? [...list, item] : list.map((one, i) => (i === at ? item : one))
871 return next.slice(-max)
872}
873hooks/sprites.ts 456 lines1// Pixel art for Raster elements: two pixel rows per terminal cell using half
2// blocks. Frames are functions of their inputs (the invaders' Ship aside).
3
4// Transparent: the terminal's background shows through.
5export const NONE = 0x01000000
6
7export type Pixels = { width: number; height: number; data: number[] }
8
9export function blank(width: number, height: number): Pixels {
10 return { width, height, data: new Array<number>(width * height).fill(NONE) }
11}
12
13export function set(p: Pixels, x: number, y: number, color: number): void {
14 const xi = Math.round(x)
15 const yi = Math.round(y)
16 if (xi < 0 || yi < 0 || xi >= p.width || yi >= p.height) return
17 p.data[yi * p.width + xi] = color
18}
19
20export function get(p: Pixels, x: number, y: number): number {
21 return p.data[y * p.width + x] ?? NONE
22}
23
24// Rows of palette letters; anything not in the palette is transparent.
25export function stamp(p: Pixels, sprite: readonly string[], x: number, y: number, palette: Record<string, number>): void {
26 sprite.forEach((row, dy) => {
27 for (let dx = 0; dx < row.length; dx++) {
28 const color = palette[row[dx] ?? '.']
29 if (color !== undefined) set(p, x + dx, y + dy, color)
30 }
31 })
32}
33
34const UPPER = 0x2580 // ▀
35const LOWER = 0x2584 // ▄
36const SPACE = 0x20
37
38const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
39
40export function base64(bytes: Uint8Array): string {
41 let out = ''
42 for (let i = 0; i < bytes.length; i += 3) {
43 const a = bytes[i] ?? 0
44 const b = bytes[i + 1] ?? 0
45 const c = bytes[i + 2] ?? 0
46 const n = (a << 16) | (b << 8) | c
47 out += B64[(n >> 18) & 63]
48 out += B64[(n >> 12) & 63]
49 out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : '='
50 out += i + 2 < bytes.length ? B64[n & 63] : '='
51 }
52 return out
53}
54
55// Raster cells are base64 little-endian u32 triplets [codePoint, fg, bg].
56// ▀ draws top as fg and bottom as bg; ▄ and space keep transparent pixels
57// transparent.
58export function toCells(p: Pixels): { columns: number; rows: number; cells: string } {
59 const columns = p.width
60 const rows = Math.ceil(p.height / 2)
61 const words = new Uint32Array(columns * rows * 3)
62 for (let row = 0; row < rows; row++) {
63 for (let x = 0; x < columns; x++) {
64 const top = get(p, x, row * 2)
65 const bottom = row * 2 + 1 < p.height ? get(p, x, row * 2 + 1) : NONE
66 const i = (row * columns + x) * 3
67 if (top === NONE && bottom === NONE) words.set([SPACE, NONE, NONE], i)
68 else if (top === NONE) words.set([LOWER, bottom, NONE], i)
69 else words.set([UPPER, top, bottom], i)
70 }
71 }
72 return { columns, rows, cells: base64(new Uint8Array(words.buffer)) }
73}
74
75// Seeded xorshift, so frames are repeatable.
76export function random(seed: number): () => number {
77 let s = seed >>> 0 || 1
78 return () => {
79 s ^= s << 13
80 s ^= s >>> 17
81 s ^= s << 5
82 return ((s >>> 0) % 100_000) / 100_000
83 }
84}
85
86// ── Confetti ─────────────────────────────────────────────────────────────
87
88const CONFETTI = [0xff5c7a, 0xffc93c, 0x4ade80, 0x38bdf8, 0xa78bfa, 0xfb923c, 0xf472b6]
89
90export const CONFETTI_FRAMES = 50 // 2.5s at 50ms a frame
91
92export function confettiFrame(seed: number, frame: number, width: number, height: number): Pixels {
93 const p = blank(width, height)
94 if (frame < 0 || frame >= CONFETTI_FRAMES) return p
95 const rand = random(seed)
96 const count = Math.max(20, Math.round(width * 0.9))
97 const gravity = 0.09
98 for (let i = 0; i < count; i++) {
99 const x0 = width * (0.15 + rand() * 0.7)
100 const vx = (rand() - 0.5) * 1.6
101 const vy = -(1.2 + rand() * 1.3)
102 const delay = Math.floor(rand() * 6)
103 const color = CONFETTI[Math.floor(rand() * CONFETTI.length)] ?? 0xffffff
104 const t = frame - delay
105 if (t < 0) continue
106 const x = x0 + vx * t
107 const y = height - 1 + vy * t + 0.5 * gravity * t * t
108 if ((t + i) % 7 === 0) continue
109 set(p, x, y, color)
110 }
111 return p
112}
113
114// ── Space invaders: one invader per Actions job ───────────────────────────
115//
116// Passed jobs burst, a failed one falls on the ship (GAME OVER), and clearing
117// them all wins. The ship and the formation's position persist between frames
118// in a Ship; everything else is a function of the frame.
119
120export const INVADERS_WIDTH = 64
121export const INVADERS_HEIGHT = 22
122export const MAX_INVADERS = 12
123export const HIT_FRAMES = 8
124export const FALL_FRAMES = 20
125export const BOOM_FRAMES = 14
126export const LOSS_FRAMES = FALL_FRAMES + BOOM_FRAMES
127export const VICTORY_FRAMES = 40
128export const ENTER_FRAMES = 16
129
130export type InvaderState = 'pending' | 'pass' | 'fail' | 'skip'
131
132// slot: fixed position in the formation. seenAt: frame it appeared (it flies
133// in). doneAt: frame it finished; unset means long ago.
134export type Invader = { state: InvaderState; slot?: number; seenAt?: number; doneAt?: number }
135
136export type Ship = {
137 x: number // NaN until first drawn
138 fx: number // formation's left edge
139 dir: 1 | -1
140 at?: number // last frame simulated
141 target?: number
142 dwell: number
143 cooldown: number
144 seed: number
145 shots: { x: number; y: number }[]
146 hits: { slot: number; age: number }[]
147}
148
149export function newShip(): Ship {
150 return { x: NaN, fx: NaN, dir: 1, dwell: 0, cooldown: 0, seed: 1, shots: [], hits: [] }
151}
152
153const PAL = {
154 a: 0xfacc15, // running
155 r: 0xf87171, // failed
156 s: 0x22d3ee, // ship
157 w: 0xf8fafc, // shots, hit flash
158 o: 0xfb923c,
159 y: 0xfde047,
160 g: 0x4b5563, // ground
161 v: 0x4ade80, // YOU WIN
162 d: 0x57534e, // debris
163 t: 0x64748b, // far stars
164 T: 0xcbd5e1, // near stars
165}
166
167const INVADER = [
168 ['.xxx.', 'x.x.x', 'xxxxx', '.x.x.'],
169 ['.xxx.', 'x.x.x', 'xxxxx', 'x...x'],
170]
171const BURST = [
172 ['.....', '..w..', '.w.w.', '..w..'],
173 ['w...w', '.o.o.', '..y..', '.o.o.'],
174 ['o...o', '.....', '.....', 'o...o'],
175]
176const SHIP = ['..s..', '.sss.', 's...s'] // ▄ over ▄▀▀▀▄
177const COLUMNS = 6
178const STEP_X = 7
179const STEP_Y = 5
180const TOP = 1
181const GROUND_Y = INVADERS_HEIGHT - 1
182// Odd, so the nose fills the bottom half of a cell and the body the next cell,
183// with a blank row between the ship and the ground.
184const SHIP_Y = GROUND_Y - 4
185const LAND_Y = SHIP_Y - 1
186const SHOT_SPEED = 2
187const FLASH_FRAMES = 3
188// Reload takes RELOAD_FRAMES plus up to RELOAD_JITTER more, at random.
189const RELOAD_FRAMES = 14
190const RELOAD_JITTER = 14
191const DWELL_FRAMES = 24
192const MAX_CATCH_UP = 60
193
194function tinted(sprite: readonly string[], letter: string): string[] {
195 return sprite.map(row => row.replace(/x/g, letter))
196}
197
198function approach(from: number, to: number, step: number): number {
199 return from + Math.sign(to - from) * Math.min(Math.abs(to - from), step)
200}
201
202function roomFor(count: number, width: number): number {
203 return Math.max(0, width - (Math.min(count, COLUMNS) * STEP_X - (STEP_X - 5)) - 2)
204}
205
206// Edge to edge and back, a pixel every 2 frames.
207function formationX(count: number, frame: number, width: number): number {
208 const room = roomFor(count, width)
209 if (room === 0) return 1
210 const step = Math.floor(frame / 2) % (room * 2)
211 return 1 + (step < room ? step : room * 2 - step)
212}
213
214export function invaderAt(index: number, count: number, frame: number, width = INVADERS_WIDTH): { x: number; y: number } {
215 return { x: formationX(count, frame, width) + (index % COLUMNS) * STEP_X, y: TOP + Math.floor(index / COLUMNS) * STEP_Y }
216}
217
218type Place = { slot: number; seenAt?: number; isRunning: boolean }
219
220/**
221 * Jobs keep their slots. A new job takes the slot of one that disappeared while
222 * running (a waiting job GitHub now lists, a matrix job expanding) without
223 * flying in; otherwise it gets a fresh slot, or a finished job's, and flies in.
224 */
225export function placeInvaders(
226 before: ReadonlyMap<string, Place>,
227 jobs: readonly { key: string; state: InvaderState }[],
228 frame: number,
229): Map<string, Place> {
230 const next = new Map<string, Place>()
231 for (const job of jobs) {
232 const was = before.get(job.key)
233 if (was) next.set(job.key, { ...was, isRunning: job.state === 'pending' })
234 }
235 const freed = [...before.entries()]
236 .filter(([key]) => !next.has(key))
237 .map(([, place]) => place)
238 .sort((a, b) => a.slot - b.slot)
239 let end = Math.max(-1, ...[...before.values()].map(place => place.slot)) + 1
240 for (const job of jobs) {
241 if (next.has(job.key)) continue
242 const reuse = freed.shift()
243 next.set(job.key, {
244 slot: reuse ? reuse.slot : end++,
245 seenAt: reuse?.isRunning ? reuse.seenAt : frame,
246 isRunning: job.state === 'pending',
247 })
248 }
249 return next
250}
251
252const LETTERS: Record<string, readonly string[]> = {
253 A: ['.#.', '#.#', '###', '#.#', '#.#'],
254 E: ['###', '#..', '##.', '#..', '###'],
255 G: ['.##', '#..', '#.#', '#.#', '.##'],
256 I: ['###', '.#.', '.#.', '.#.', '###'],
257 M: ['#...#', '##.##', '#.#.#', '#...#', '#...#'],
258 N: ['#..#', '##.#', '#.##', '#..#', '#..#'],
259 O: ['.#.', '#.#', '#.#', '#.#', '.#.'],
260 R: ['##.', '#.#', '##.', '#.#', '#.#'],
261 U: ['#.#', '#.#', '#.#', '#.#', '###'],
262 V: ['#.#', '#.#', '#.#', '#.#', '.#.'],
263 W: ['#...#', '#...#', '#.#.#', '##.##', '#...#'],
264 Y: ['#.#', '#.#', '.#.', '.#.', '.#.'],
265 ' ': ['..', '..', '..', '..', '..'],
266}
267
268function banner(p: Pixels, text: string, y: number, letter: string): void {
269 const glyphs = [...text].map(ch => LETTERS[ch] ?? LETTERS[' ']!)
270 const width = glyphs.reduce((sum, g) => sum + (g[0]?.length ?? 0) + 1, -1)
271 let x = Math.round((p.width - width) / 2)
272 for (const g of glyphs) {
273 stamp(p, g.map(row => row.replace(/#/g, letter)), x, y, PAL)
274 x += (g[0]?.length ?? 0) + 1
275 }
276}
277
278export function invadersFrame(args: { invaders: readonly Invader[]; frame: number; width?: number; ship?: Ship }): Pixels {
279 const { frame } = args
280 const W = Math.max(32, args.width ?? INVADERS_WIDTH)
281 const p = blank(W, INVADERS_HEIGHT)
282 const ship = args.ship ?? newShip()
283
284 const rand = random(41)
285 for (let i = 0; i < Math.round(W / 3.5); i++) {
286 const x = Math.floor(rand() * W)
287 const y0 = rand() * INVADERS_HEIGHT
288 const isNear = i % 3 === 0
289 const y = Math.floor(y0 + frame * (isNear ? 0.25 : 0.1)) % (INVADERS_HEIGHT - 1)
290 set(p, x, y, isNear ? PAL.T : PAL.t)
291 }
292 for (let x = 0; x < W; x++) set(p, x, GROUND_Y, PAL.g)
293
294 const invaders = args.invaders.map((inv, i) => ({ ...inv, slot: inv.slot ?? i })).filter(inv => inv.slot < MAX_INVADERS)
295 const count = Math.max(0, ...invaders.map(inv => inv.slot + 1))
296 const legs = Math.floor(frame / 8) % 2
297 const ago = (inv: Invader) => (inv.doneAt === undefined ? Infinity : frame - inv.doneAt)
298 const room = roomFor(count, W)
299 if (Number.isNaN(ship.fx)) {
300 ship.fx = formationX(count, frame, W)
301 ship.dir = Math.floor(frame / 2) % (Math.max(1, room) * 2) < room ? 1 : -1
302 }
303 // Like the arcade: the formation bounces off the edges at its outermost
304 // invaders still on screen, not at the columns already shot away.
305 const isShown = (inv: Invader) => (inv.state === 'pass' || inv.state === 'skip' ? ago(inv) < HIT_FRAMES : true)
306 const columns = invaders.filter(isShown).map(inv => inv.slot % COLUMNS)
307 const first = columns.length > 0 ? Math.min(...columns) : 0
308 const last = columns.length > 0 ? Math.max(...columns) : Math.min(count, COLUMNS) - 1
309 const minFx = 1 - first * STEP_X
310 const maxFx = Math.max(minFx, W - 6 - last * STEP_X)
311 // `ahead` frames from now, for leading a shot.
312 const homeOf = (slot: number, ahead = 0) => ({
313 x:
314 Math.floor(ahead === 0 ? ship.fx : Math.max(minFx, Math.min(maxFx, ship.fx + ship.dir * 0.5 * ahead))) +
315 (slot % COLUMNS) * STEP_X,
316 y: TOP + Math.floor(slot / COLUMNS) * STEP_Y,
317 })
318 const placeOf = (inv: Invader & { slot: number }) => {
319 const home = homeOf(inv.slot)
320 const since = inv.seenAt === undefined ? Infinity : frame - inv.seenAt
321 if (since >= ENTER_FRAMES) return home
322 const t = Math.max(0, since) / ENTER_FRAMES
323 return { x: home.x, y: Math.round(home.y - (1 - t * (2 - t)) * (home.y + 6)) }
324 }
325
326 const failed = invaders
327 .map(inv => ({ inv, since: ago(inv) - FALL_FRAMES }))
328 .filter(({ inv }) => inv.state === 'fail')
329 .sort((a, b) => b.since - a.since)[0]
330
331 if (failed && failed.since >= BOOM_FRAMES) {
332 const cx = homeOf(failed.inv.slot).x + 2
333 for (const inv of invaders) if (inv.state === 'fail') stamp(p, tinted(INVADER[0]!, 'r'), homeOf(inv.slot).x, LAND_Y, PAL)
334 for (const dx of [-7, -5, 4, 6, 8]) set(p, cx + dx, GROUND_Y - 1, PAL.d)
335 banner(p, 'GAME OVER', 2, 'r')
336 return p
337 }
338
339 const running = invaders.filter(inv => inv.state === 'pending')
340 const isCleared = running.length === 0 && !failed
341 const sinceWin = Math.min(...invaders.map(ago)) - HIT_FRAMES
342
343 if (Number.isNaN(ship.x)) ship.x = W / 2
344 if (ship.at === undefined || ship.at > frame || frame - ship.at > MAX_CATCH_UP) ship.at = frame - 1
345 ship.x = Math.max(2, Math.min(W - 3, ship.x))
346 for (let f = ship.at + 1; f <= frame; f++) {
347 ship.fx += ship.dir * 0.5
348 // Past an edge (the board shrank, or a new outer column appeared): turn
349 // around and walk back in rather than jumping.
350 if (maxFx === minFx) ship.fx = minFx
351 else if (ship.fx >= maxFx) ship.dir = -1
352 else if (ship.fx <= minFx) ship.dir = 1
353 for (const shot of ship.shots) shot.y -= SHOT_SPEED
354 for (const hit of ship.hits) hit.age++
355 ship.hits = ship.hits.filter(hit => hit.age < FLASH_FRAMES)
356 ship.cooldown = Math.max(0, ship.cooldown - 1)
357
358 const targets = running.filter(inv => inv.seenAt === undefined || f - inv.seenAt >= ENTER_FRAMES)
359 ship.shots = ship.shots.filter(shot => {
360 const struck = targets.find(inv => {
361 const at = homeOf(inv.slot)
362 return shot.x >= at.x && shot.x <= at.x + 4 && shot.y <= at.y + 3 && shot.y >= at.y
363 })
364 if (struck) ship.hits = [...ship.hits.filter(hit => hit.slot !== struck.slot), { slot: struck.slot, age: 0 }]
365 return !struck && shot.y > 0
366 })
367
368 if (failed) {
369 // Get under the falling invader by the time it lands.
370 const goal = homeOf(failed.inv.slot).x + 2
371 const left = frame - f - failed.since
372 if (left > 0) ship.x = approach(ship.x, goal, Math.max(2, Math.abs(goal - ship.x) / left))
373 continue
374 }
375 if (isCleared) {
376 ship.x = approach(ship.x, Math.round(W / 2), 2)
377 continue
378 }
379 if (targets.length === 0) continue
380
381 const aim = (inv: { slot: number }) => {
382 const flight = Math.max(0, Math.round((SHIP_Y - 1 - (homeOf(inv.slot).y + 3)) / SHOT_SPEED))
383 return homeOf(inv.slot, flight).x + 2
384 }
385 let target = targets.find(inv => inv.slot === ship.target)
386 if (!target || ship.dwell >= DWELL_FRAMES) {
387 const choices = targets.length > 1 ? targets.filter(inv => inv.slot !== ship.target) : targets
388 target = choices[Math.floor(random(ship.seed++)() * choices.length)]!
389 ship.target = target.slot
390 ship.dwell = 0
391 }
392 const goal = aim(target)
393 ship.x = approach(ship.x, goal, 1)
394 if (Math.abs(goal - ship.x) < 1) ship.dwell++
395 // Fire at whatever is overhead, not just the target.
396 if (ship.cooldown === 0 && targets.some(inv => Math.abs(aim(inv) - ship.x) <= 1)) {
397 ship.shots.push({ x: Math.round(ship.x), y: SHIP_Y - 1 })
398 ship.cooldown = RELOAD_FRAMES + Math.floor(random(ship.seed++)() * (RELOAD_JITTER + 1))
399 }
400 }
401 ship.at = frame
402
403 for (const inv of invaders) {
404 const at = placeOf(inv)
405 if (inv.state === 'pending') {
406 const isHit = ship.hits.some(hit => hit.slot === inv.slot)
407 stamp(p, tinted(INVADER[legs]!, isHit ? 'w' : 'a'), at.x, at.y, PAL)
408 } else if (inv.state === 'fail') {
409 const fell = Math.min(1, ago(inv) / FALL_FRAMES)
410 stamp(p, tinted(INVADER[legs]!, 'r'), at.x, Math.round(at.y + fell * (LAND_Y - at.y)), PAL)
411 } else if (ago(inv) < HIT_FRAMES) {
412 stamp(p, BURST[Math.min(BURST.length - 1, Math.floor((ago(inv) / HIT_FRAMES) * BURST.length))]!, at.x, at.y, PAL)
413 }
414 }
415
416 const shipX = Math.round(ship.x) - 2
417 if (failed) {
418 if (failed.since < 0) {
419 stamp(p, SHIP, shipX, SHIP_Y, PAL)
420 return p
421 }
422 const boom = random(97)
423 for (let i = 0; i < 24; i++) {
424 const angle = boom() * Math.PI * 2
425 const r = failed.since * (0.4 + boom() * 0.8)
426 set(p, ship.x + Math.cos(angle) * r * 1.6, SHIP_Y + 1 + Math.sin(angle) * r, [PAL.o, PAL.y, PAL.s, PAL.w][i % 4]!)
427 }
428 return p
429 }
430
431 for (const shot of ship.shots) {
432 set(p, shot.x, shot.y, PAL.w)
433 set(p, shot.x, shot.y + 1, PAL.w)
434 }
435
436 if (isCleared) {
437 const isHome = Math.abs(ship.x - Math.round(W / 2)) < 1
438 const isCelebrating = count > 0 && sinceWin >= 0 && sinceWin < VICTORY_FRAMES
439 // Hop a whole cell so the half-block shape stays intact.
440 const hop = isHome && isCelebrating ? (Math.floor(frame / 4) % 2) * 2 : 0
441 stamp(p, SHIP, shipX, SHIP_Y - hop, PAL)
442 if (count > 0 && sinceWin >= 0) banner(p, 'YOU WIN', 2, 'v')
443 if (isCelebrating) {
444 const sparks = random(Math.floor(frame / 6) + 1)
445 for (let i = 0; i < 8; i++) {
446 const side = i % 2 === 0 ? W * 0.2 : W * 0.8
447 set(p, side - 4 + sparks() * 8, 1 + sparks() * 8, [PAL.y, PAL.w, PAL.o, PAL.v][i % 4]!)
448 }
449 }
450 return p
451 }
452
453 stamp(p, SHIP, shipX, SHIP_Y, PAL)
454 return p
455}
456types/index.d.ts 111 lines1export type CheckState = 'pass' | 'fail' | 'pending' | 'skip'
2
3export type Check = {
4 name: string
5 state: CheckState
6 url?: string
7 workflow?: string // absent for status contexts
8 durationMs?: number
9}
10
11export type Pr = {
12 url: string
13 number: number
14 repo: string
15 title: string
16 branch: string
17 state: 'OPEN' | 'MERGED' | 'CLOSED'
18 isDraft: boolean
19 review: string
20 mergeable: string
21 checks: Check[]
22 base?: string
23 author?: string
24 endedAt?: string // when it merged or closed
25 reviews?: Reviewer[]
26 error?: string
27}
28
29export type Reviewer = { login: string; state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'REQUESTED' | 'DISMISSED' }
30
31export type History = { results: CheckState[]; at: number }
32
33export type Job = {
34 name: string
35 state: CheckState
36 url?: string
37 durationMs?: number
38 isWaiting?: boolean // declared in the workflow, not started yet
39}
40
41export type Run = {
42 id: number
43 name: string
44 title: string
45 url: string
46 state: CheckState
47 branch: string
48 attempt?: number
49 durationMs?: number
50 jobs: Job[]
51}
52
53export type Release = {
54 key: string
55 label: string
56 repo?: string // owner/name; older saved releases lack it and use the current repo
57 workflow?: string
58 tag?: string
59 runId?: number // one specific run
60 url?: string
61 runs: Run[]
62 error?: string
63}
64
65export type OpenPr = {
66 url: string
67 number: number
68 title: string
69 isDraft: boolean
70 checks: Check[]
71}
72
73export type OpenPrs = { repo: string; prs: OpenPr[]; error?: string }
74
75export type GhState = 'unknown' | 'ready' | 'missing' | 'signed-out'
76
77export type Setup = { gh: GhState; os: 'mac' | 'linux' | 'windows' | 'unknown' }
78
79export type Celebration = { key: string; kind: 'merged' | 'released' | 'scrubbed'; startFrame: number }
80
81// What Remove or Clear took, for z to restore.
82export type Undo = { label: string; prs: Pr[]; releases: Release[]; selected: string }
83
84// Failed-step logs by job id.
85export type Logs = Record<string, { lines: string[]; at: number }>
86
87declare module 'claude-code' {
88 interface PluginState {
89 'gh-pulse': {
90 prs: Pr[]
91 releases: Release[]
92 frame: number
93 isBandHidden: boolean
94 selected: string // `pr:<url>` or `release:<key>`
95 layout: 'both' | 'list' | 'detail'
96 openPrs: OpenPrs
97 setup: Setup
98 celebration: Celebration | null
99 undo: Undo | null
100 cursor: string // key of the focused board element
101 busy: string // label shown while an action runs, '' when idle
102 isKeysShown: boolean
103 logs: Logs
104 viewer: string // your GitHub login, '' until known
105 logCheck: string // `<item key>#<check url>`
106 history: Record<string, History> // by `<repo>|<branch>|<workflow>`
107 avatars: Record<string, string> // login -> base64 PNG, '' if unavailable
108 }
109 }
110}
111