Shows GitHub Actions status for the checked-out commit in the status line

hooks/register.ts 90 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { summarize } from './summarize'
4import type { Run } from './summarize'
5
6const POLL_MS = 60_000
7
8let sha = ''
9let isDone = false
10let isBusy = false
11let cacheDir = ''
12let prBranch = ''
13let prCheckedAt = 0
14
15const PR_EVERY_MS = 5 * 60_000
16
17// ~/.claude/statusline.sh shows ~/.cache/claude-ci/<HEAD sha> next to the branch.
18async function publish($: EngineInterface, commit: string, line: string) {
19 if (!cacheDir) {
20 const home = await $.process.run(['printenv', 'HOME'])
21 cacheDir = `${home.stdout.trim()}/.cache/claude-ci`
22 }
23 await $.fs.write(`${cacheDir}/${commit}`, line)
24}
25
26// The open PR for a branch, as ~/.cache/claude-ci/pr-<branch with / as ->: its number, or empty.
27async function refreshPR($: EngineInterface) {
28 const b = await $.process.run(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'])
29 const branch = b.stdout.trim()
30 if (b.exitCode !== 0 || !branch) return
31 const now = await $.clock.now()
32 if (branch === prBranch && now - prCheckedAt < PR_EVERY_MS) return
33 prBranch = branch
34 prCheckedAt = now
35 const pr = await $.process.run(['gh', 'pr', 'view', branch, '--json', 'number,state', '-q', 'select(.state=="OPEN") | .number'])
36 await publish($, `pr-${branch.replaceAll('/', '-')}`, pr.exitCode === 0 ? pr.stdout.trim() : '')
37}
38
39async function refresh($: EngineInterface) {
40 if (isBusy) return
41 isBusy = true
42 try {
43 const head = await $.process.run(['git', 'rev-parse', 'HEAD'])
44 if (head.exitCode !== 0) return
45 const current = head.stdout.trim()
46 await refreshPR($)
47 // A finished commit never changes; only ask GitHub again when HEAD moves.
48 if (current === sha && isDone) return
49
50 const gh = await $.process.run([
51 'gh', 'run', 'list', '--commit', current, '--limit', '30',
52 '--json', 'status,conclusion,workflowName',
53 ])
54 if (gh.exitCode !== 0) {
55 await publish($, current, 'error\tgh failed')
56 return
57 }
58 const s = summarize(current, JSON.parse(gh.stdout) as Run[])
59 const wasRunning = current === sha && !isDone
60 await publish($, current, s.line)
61 if (wasRunning && s.isDone) {
62 $.ui.toast(s.failed.length > 0 ? `CI failed: ${s.failed.join(', ')}` : 'CI passed', { timeoutMs: 8000 })
63 }
64 sha = current
65 isDone = s.isDone
66 } catch {
67 // The status line simply shows nothing for this commit.
68 } finally {
69 isBusy = false
70 }
71}
72
73export const register: Register = on => {
74 on('session.start', async ($, e, next) => {
75 const r = await next(e)
76 // Earlier versions pinned a plugin status line; the shell status line has it now.
77 $.ui.status(undefined)
78 void refresh($)
79 $.clock.every(POLL_MS, () => void refresh($))
80 return r
81 })
82
83 // A turn may have committed or pushed.
84 on('turn.complete', async ($, e, next) => {
85 const r = await next(e)
86 void refresh($)
87 return r
88 })
89}
90hooks/summarize.ts 34 lines1export type Run = { status: string; conclusion: string; workflowName: string }
2
3const BAD = new Set(['failure', 'timed_out', 'startup_failure', 'action_required'])
4
5// gh lists newest first; a rerun shares its workflow's name, so the first one wins.
6export function latestPerWorkflow(runs: readonly Run[]): Run[] {
7 const seen = new Map<string, Run>()
8 for (const r of runs) {
9 if (!seen.has(r.workflowName)) seen.set(r.workflowName, r)
10 }
11 return [...seen.values()]
12}
13
14// `line` is what ~/.claude/statusline.sh reads: state<TAB>detail.
15export type Summary = { text: string; line: string; isDone: boolean; failed: string[] }
16
17export function summarize(sha: string, all: readonly Run[]): Summary {
18 const runs = latestPerWorkflow(all)
19 const head = `CI ${sha.slice(0, 7)}`
20 if (runs.length === 0) {
21 return { text: `${head}: no runs`, line: 'none\t', isDone: true, failed: [] }
22 }
23 const failed = runs.filter(r => r.status === 'completed' && BAD.has(r.conclusion)).map(r => r.workflowName)
24 const pending = runs.filter(r => r.status !== 'completed')
25 if (failed.length > 0) {
26 const more = pending.length > 0 ? `, ${pending.length} running` : ''
27 return { text: `${head} ✗ ${failed.join(', ')}${more}`, line: `fail\t${failed.join(', ')}${more}`, isDone: pending.length === 0, failed }
28 }
29 if (pending.length > 0) {
30 return { text: `${head} ⏳ ${runs.length - pending.length}/${runs.length} done`, line: `run\t${runs.length - pending.length}/${runs.length}`, isDone: false, failed }
31 }
32 return { text: `${head} ✓ ${runs.length} passed`, line: `pass\t${runs.length}`, isDone: true, failed }
33}
34