SLOPSHOPPER

gh-pulse

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

newpanebandguardcommandtoast
v0.2.1MITupdated 2026-10-09SID-Technologies/ci-invaders
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gh-pulse
│ ┃ gh-pulse ✕ › fix the failing auth test and ad╭────────────────────────────────╮ │ ┃ r: Refresh m: List only z: Undo (1 trac │ gh-pulse │ │ ┃ h: Keys ⏺ Read(src/auth.ts) │ Watching PR #0 after your push │ │ ┃ ctrl+x tab or click to use the keyboard ⎿ Read 6 lines ╰────────────────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ 1: PULL REQUESTS │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ None tracked. Press enter on one of y… │ │ ┃ │ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ │ ┃ ╰────────────────────────────────────────╯ › /pulse │ ┃ ⎿ gh-pulse: Board open. │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ 2: ACTIONS │ │ ┃ │ None tracked. /pulse-release, or a re… │ │ ┃ │ │ │ ┃ ╰────────────────────────────────────────╯ │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ 3: YOUR OPEN PRS │ │ ┃ │ JSON Parse error: Unexpected EOF │ │ ┃ │ │ │ ┃ ╰────────────────────────────────────────╯ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · gh-pulse
r: Refresh m: List only z: Undo (1 tracked item) h: Keys ctrl+x tab or click to use the keyboard ╭────────────────────────────────────────────────────────╮ │ 1: PULL REQUESTS │ │ None tracked. Press enter on one of your PRs, or /pul… │ │ │ │ │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ 2: ACTIONS │ │ None tracked. /pulse-release, or a release Claude cre… │ │ │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ 3: YOUR OPEN PRS │ │ JSON Parse error: Unexpected EOF │ │ │ ╰────────────────────────────────────────────────────────╯
README

gh-pulse

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 gh-pulse demo. -->

Install

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.

What it does

  • Tracks what Claude starts. 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.
  • Shows it everywhere. A status line entry (✕ #130 5/10 · ⠹ v1.4.0 4/6), a line per item above the prompt, and a board with the full picture.
  • Puts failures first. The open PR shows failing checks and the end of their log before anything else, plus CI history for each workflow on the base branch (flaky ones are flagged) and who has reviewed.
  • Hands failures to Claude. f fills the prompt with the failing checks and their logs. You press Enter.
  • Keeps Claude informed. While something is tracked, a one-line CI summary goes along with your prompts when it has changed, so "why is my PR red?" works without pasting anything.
  • Makes releases fun to watch. Each Actions job is an invader. Passing jobs get shot down; a failing one lands on your ship. Jobs waiting on needs count from the start. A merge sets off confetti.

The board

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

KeyDoes
↑ ↓Move through the lists
1 2 3Jump to Pull requests, Actions or Your open PRs, back to where you were
enterPin a tracked row in the detail, or track one of your PRs
fFix it: failing checks and logs into the prompt
eRerun failed jobs
l / yNext failing log / copy the log shown
aAsk Claude to review a tracked PR you didn't open
oOpen on GitHub
xRemove the highlighted row
dClear finished: merged or closed PRs and releases that passed
c / zClear everything / undo any removal for 10s
mLists and detail, lists only, detail only
rRefresh
hShow 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.

Commands

CommandDoes
/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-clearStop 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.

Settings

Set in /config, or under pluginConfigs.gh-pulse.options in settings.json.

SettingDefault
claudeContextonSend the CI summary with your prompts
confettionConfetti on merge
invadersonSpace Invaders for Actions runs
avatarsonReviewer avatars, in terminals that show images (Ghostty, kitty, iTerm2, WezTerm)
heatmaponCI history on the base branch
soundsoffA chime on green, a thud on failure (macOS)
speechoffAnnounce changes out loud ("PR 131 is green")

Pixel art only draws in the terminal.

Develop

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

Releasing

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.

License

MIT

Source 4 files
hooks/register.tsx 1930 lines
1import { 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 lines
1import 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}
873
hooks/sprites.ts 456 lines
1// 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}
456
types/index.d.ts 111 lines
1export 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