Follows the GitHub Actions runs of a commit after git push: status line while they run, toast with the failed workflows and their link when they end

Follows the GitHub Actions runs of a commit right after you (or Claude) push it, so a red build does not go unnoticed.
git push (also behind rtk, env, VAR=x, cd x && or git -C x), it reads the branch and HEAD sha of that folder and the push remote (git push <remote>, else the branch's remote, else origin), then polls gh run list -R owner/repo --commit <sha> --json … every 30 s.CI ⏳ 2 running, then CI ✓ 3 passed or CI ✗ 1 failed.CI ✓ all 3 passed on main, or CI ✗ failed on main: Lint, Test — <first failed run URL>). The final status line stays 10 minutes, then clears.--dry-run), deletes (--delete, :branch), backgrounded or failed pushes and subagent pushes are ignored.gh is missing or not authenticated, or the remote is not on github.com, nothing is shown. /ci-watch says why.success, neutral or skipped. Any other conclusion counts as failed.| Command | Effect |
|---|---|
/ci-watch | Shows the watched commit and its runs with their URLs, or why nothing is watched |
/ci-watch stop | Stops the current watch and clears the status line |
/ci-watch off | Stops following pushes (kept across sessions) |
/ci-watch on | Follows pushes again |
claude --plugin-dir /path/to/ModsTools/mods/ci-watch
HEAD after the push. A push of another ref (git push origin other:main) is followed under HEAD's sha.cd made in an earlier Bash call is not known. The folder is the session folder plus the cd/-C of the push command itself.workflow_run) is not waited for.claude plugin validate mods/ci-watch
claude plugin test mods/ci-watch # 18 testshooks/register.ts 214 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { RUN_FIELDS, allDone, finalToast, githubRepo, isUrl, parsePush, parseRuns, resolveDir, runLine, shortSha, statusLine } from './ci'
4import type { Push, Run } from './ci'
5
6const POLL_MS = 30_000
7const APPEAR_MS = 2 * 60_000
8const MAX_MS = 45 * 60_000
9const KEEP_MS = 10 * 60_000
10const GH_MS = 20_000
11const GIT_MS = 10_000
12
13type Timer = { cancel: () => void }
14
15type Watch = {
16 sha: string
17 branch: string
18 repo: string
19 dir: string
20 startedAt: number
21 runs: Run[]
22 phase: 'waiting' | 'running' | 'done' | 'no workflow' | 'stopped' | 'timed out'
23 timer: Timer | null
24 clear: Timer | null
25 busy: boolean
26}
27
28type Ctx = {
29 watch: Watch | null
30 // Bumped by every push seen: a start that is no longer the latest drops its work.
31 seq: number
32 // Why nothing is watched, for /ci-watch.
33 reason: string | null
34}
35
36type Ran = { exitCode: number; stdout: string } | null
37
38async function run($: EngineInterface, argv: string[], cwd: string, timeoutMs: number): Promise<Ran> {
39 try {
40 const ran = await $.process.run(argv, { cwd, timeoutMs })
41 return { exitCode: ran.exitCode, stdout: ran.stdout }
42 } catch {
43 return null
44 }
45}
46
47async function git($: EngineInterface, dir: string, args: string[]): Promise<string | null> {
48 const ran = await run($, ['git', '-C', dir, ...args], dir, GIT_MS)
49
50 return ran !== null && ran.exitCode === 0 && ran.stdout.trim() !== '' ? ran.stdout.trim() : null
51}
52
53async function enabled($: EngineInterface): Promise<boolean> {
54 return (await $.store.get('enabled')) !== false
55}
56
57const active = (watch: Watch | null): watch is Watch => watch !== null && (watch.phase === 'waiting' || watch.phase === 'running')
58
59function halt(watch: Watch, phase: Watch['phase']) {
60 watch.timer?.cancel()
61 watch.timer = null
62 watch.phase = phase
63}
64
65function drop($: EngineInterface, ctx: Ctx) {
66 const watch = ctx.watch
67 if (watch === null) return
68 watch.clear?.cancel()
69 watch.clear = null
70 if (active(watch)) halt(watch, 'stopped')
71 $.ui.status(undefined)
72}
73
74async function tick($: EngineInterface, ctx: Ctx, watch: Watch) {
75 if (watch.busy || !active(watch)) return
76 watch.busy = true
77 try {
78 const now = await $.clock.now()
79 if (now - watch.startedAt >= MAX_MS) {
80 halt(watch, 'timed out')
81 if (ctx.watch === watch) {
82 $.ui.status(undefined)
83 $.ui.toast(`CI: stopped watching ${shortSha(watch.sha)} after 45 min`)
84 }
85 return
86 }
87 const ran = await run($, ['gh', 'run', 'list', '-R', watch.repo, '--commit', watch.sha, '--json', RUN_FIELDS], watch.dir, GH_MS)
88 // A push that replaced this watch, or a stop, while gh ran: its answer is stale.
89 if (ctx.watch !== watch || !active(watch)) return
90 const runs = ran !== null && ran.exitCode === 0 ? parseRuns(ran.stdout) : null
91 if (runs === null) ctx.reason = ran === null ? 'gh run list could not run or timed out' : 'gh run list failed'
92 if (runs === null || runs.length === 0) {
93 if (watch.runs.length === 0 && now - watch.startedAt >= APPEAR_MS) {
94 halt(watch, 'no workflow')
95 ctx.reason = `no workflow run for ${shortSha(watch.sha)} after 2 min`
96 $.ui.status(undefined)
97 }
98 return
99 }
100 ctx.reason = null
101 watch.runs = runs
102 watch.phase = 'running'
103 $.ui.status(statusLine(runs))
104 if (allDone(runs)) {
105 halt(watch, 'done')
106 $.ui.toast(finalToast(runs, watch.branch), { timeoutMs: 8000 })
107 watch.clear = $.clock.after(KEEP_MS, () => {
108 if (ctx.watch === watch) $.ui.status(undefined)
109 })
110 }
111 } finally {
112 watch.busy = false
113 }
114}
115
116// Everything a push sets off, deferred: git and gh are asked here, never while the tool result waits.
117async function startWatch($: EngineInterface, ctx: Ctx, push: Push, seq: number) {
118 const latest = () => seq === ctx.seq
119 if (!(await enabled($))) return
120 const dir = resolveDir(await $.session.cwd(), push.dirs)
121 const sha = await git($, dir, ['rev-parse', 'HEAD'])
122 const branch = (await git($, dir, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? 'HEAD'
123 if (!latest()) return
124 if (sha === null) {
125 ctx.reason = 'the pushed folder is not a git repository'
126 return
127 }
128 if (active(ctx.watch) && ctx.watch.sha === sha) return
129 const remote = push.remote ?? (await git($, dir, ['config', '--get', `branch.${branch}.remote`])) ?? 'origin'
130 const url = isUrl(remote) ? remote : await git($, dir, ['remote', 'get-url', '--push', remote])
131 const repo = url === null ? null : githubRepo(url)
132 if (!latest()) return
133 if (repo === null) {
134 ctx.reason = `remote ${remote} is not a GitHub repository`
135 return
136 }
137 const auth = await run($, ['gh', 'auth', 'status', '--hostname', 'github.com'], dir, GH_MS)
138 if (!latest()) return
139 if (auth === null) {
140 ctx.reason = 'gh is not installed (or did not answer)'
141 return
142 }
143 if (auth.exitCode !== 0) {
144 ctx.reason = 'gh is not authenticated (gh auth login)'
145 return
146 }
147 drop($, ctx)
148 ctx.reason = null
149 const watch: Watch = { sha, branch, repo, dir, startedAt: await $.clock.now(), runs: [], phase: 'waiting', timer: null, clear: null, busy: false }
150 ctx.watch = watch
151 watch.timer = $.clock.every(POLL_MS, () => void tick($, ctx, watch))
152 await tick($, ctx, watch)
153}
154
155function listing(ctx: Ctx, isOn: boolean): string {
156 const lines: string[] = []
157 if (!isOn) lines.push('ci-watch is off (/ci-watch on to resume).')
158 const watch = ctx.watch
159 if (watch !== null) {
160 lines.push(`${watch.repo} · ${watch.branch} @ ${shortSha(watch.sha)} · ${watch.phase}`)
161 lines.push(...watch.runs.map(runLine))
162 if (watch.runs.length === 0 && watch.phase === 'waiting') lines.push('Waiting for the first workflow run to appear…')
163 }
164 if (ctx.reason !== null) lines.push(`Note: ${ctx.reason}.`)
165 if (lines.length === 0) lines.push('No push watched this session.')
166
167 return lines.join('\n')
168}
169
170export const register: Register = on => {
171 const ctx: Ctx = { watch: null, seq: 0, reason: null }
172
173 on('session.start', async ($, e, next) => {
174 await $.command.register({ name: 'ci-watch', description: 'Show the GitHub Actions runs of the last push (stop | off | on)' })
175
176 return next(e)
177 })
178
179 on('tool.call', async ($, e, next) => {
180 const ran = await next(e)
181 if (e.agentId !== undefined || e.tool !== 'Bash' || ran.deny !== undefined || ran.isError === true) return ran
182 const result = ran.result as { backgroundTaskId?: unknown; interrupted?: unknown } | undefined
183 if (result?.backgroundTaskId !== undefined || result?.interrupted === true) return ran
184 const push = parsePush(e.command)
185 if (push === null) return ran
186 ctx.seq += 1
187 const seq = ctx.seq
188 $.clock.after(0, () => void startWatch($, ctx, push, seq))
189
190 return ran
191 })
192
193 on('command.run', { command: 'ci-watch' }, async ($, e) => {
194 const arg = e.args.trim()
195 if (arg === 'stop') {
196 const was = active(ctx.watch)
197 drop($, ctx)
198 return { text: was ? 'Stopped watching CI.' : 'Nothing was being watched.' }
199 }
200 if (arg === 'off') {
201 await $.store.set('enabled', false)
202 ctx.seq += 1
203 drop($, ctx)
204 return { text: 'ci-watch off: pushes are no longer followed.' }
205 }
206 if (arg === 'on') {
207 await $.store.set('enabled', true)
208 return { text: 'ci-watch on: the next push is followed.' }
209 }
210
211 return { text: listing(ctx, await enabled($)) }
212 })
213}
214hooks/ci.ts 234 lines1// Pure logic of ci-watch: reading `git push` command lines, GitHub remotes and `gh run list --json` output.
2
3export const RUN_FIELDS = 'databaseId,name,status,conclusion,url,workflowName'
4
5export type Run = {
6 databaseId: number
7 name: string
8 workflowName: string
9 status: string
10 conclusion: string
11 url: string
12}
13
14export type Push = {
15 // `cd` targets and `git -C` folders in the order the shell applies them, relative to the session folder.
16 dirs: string[]
17 // The remote named on the command line (`git push upstream …`), if any.
18 remote: string | null
19}
20
21type Token = { text: string; expands: boolean }
22
23const PREFIXES = new Set(['rtk', 'env', 'time', 'command', 'exec', 'nohup'])
24
25// Splits a shell command into simple commands (on unquoted && || ; | & and newlines) of words, quotes removed.
26// A word that holds an unquoted or double-quoted `$` or a backquote is marked: its value is the shell's, not ours.
27export function simpleCommands(command: string): Token[][] {
28 const commands: Token[][] = [[]]
29 let word: Token | null = null
30 let quote: '"' | "'" | null = null
31 let redirect = false
32 const push = () => {
33 if (word !== null && redirect) redirect = false
34 else if (word !== null) commands[commands.length - 1]!.push(word)
35 word = null
36 }
37 const add = (ch: string, expands = false) => {
38 word ??= { text: '', expands: false }
39 word.text += ch
40 if (expands) word.expands = true
41 }
42 for (let i = 0; i < command.length; i += 1) {
43 const ch = command[i]!
44 if (quote === "'") {
45 if (ch === "'") quote = null
46 else add(ch)
47 } else if (quote === '"') {
48 if (ch === '"') quote = null
49 else if (ch === '\\' && i + 1 < command.length) add(command[++i]!)
50 else add(ch, ch === '$' || ch === '`')
51 } else if (ch === "'" || ch === '"') {
52 quote = ch
53 word ??= { text: '', expands: false }
54 } else if (ch === '\\' && i + 1 < command.length) {
55 if (command[i + 1] === '\n') i += 1
56 else add(command[++i]!)
57 } else if (/\s/.test(ch) && ch !== '\n') {
58 push()
59 } else if (ch === '>' || ch === '<') {
60 if (word !== null && /^\d+$/.test(word.text)) word = null
61 else push()
62 while (command[i + 1] === '>' || command[i + 1] === '<') i += 1
63 if (command[i + 1] === '&') {
64 i += 1
65 while (/[\d-]/.test(command[i + 1] ?? '')) i += 1
66 } else redirect = true
67 } else if (ch === '\n' || ch === ';' || ch === '|' || ch === '&') {
68 push()
69 if (commands[commands.length - 1]!.length > 0) commands.push([])
70 } else {
71 add(ch, ch === '$' || ch === '`')
72 }
73 }
74 push()
75
76 return commands.filter(words => words.length > 0)
77}
78
79const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1)
80
81// Skips `VAR=x`, `rtk`, `env`, … in front of a simple command.
82function skipPrefixes(words: Token[]): number {
83 let i = 0
84 while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!.text) || PREFIXES.has(baseName(words[i]!.text)))) i += 1
85
86 return i
87}
88
89// Push options that take a separate value.
90const PUSH_VALUED = new Set(['--repo', '--receive-pack', '--exec', '-o', '--push-option'])
91
92// Reads a command line that runs `git push` (behind rtk, env assignments, `cd x &&`, `git -C x`).
93// Null when no push runs, or when it is a dry run or a delete, or a folder comes from a shell expansion.
94export function parsePush(command: string): Push | null {
95 const dirs: string[] = []
96 for (const words of simpleCommands(command)) {
97 let i = skipPrefixes(words)
98 const head = words[i]
99 if (head === undefined) continue
100 if (head.text === 'cd' || head.text === 'pushd') {
101 const target = words[i + 1]
102 if (target === undefined || target.expands || target.text.startsWith('~') || target.text === '-') dirs.push('\0')
103 else dirs.push(target.text)
104 continue
105 }
106 if (baseName(head.text) !== 'git') continue
107 i += 1
108 const local: string[] = []
109 let unresolvable = false
110 while (i < words.length && words[i]!.text.startsWith('-')) {
111 const flag = words[i]!.text
112 if (flag === '-C') {
113 const target = words[i + 1]
114 if (target === undefined || target.expands || target.text.startsWith('~')) unresolvable = true
115 else local.push(target.text)
116 i += 2
117 } else if (flag === '-c') i += 2
118 else i += 1
119 }
120 if (words[i]?.text !== 'push') continue
121 if (unresolvable || dirs.includes('\0')) return null
122 let remote: string | null = null
123 const args = words.slice(i + 1)
124 if (args.some(arg => ['--dry-run', '-n', '--delete', '-d', '--help', '-h'].includes(arg.text) || arg.text.startsWith(':'))) return null
125 for (let j = 0; j < args.length; j += 1) {
126 const text = args[j]!.text
127 if (PUSH_VALUED.has(text)) j += 1
128 else if (!text.startsWith('-')) {
129 remote = args[j]!.expands ? null : text
130 break
131 }
132 }
133
134 return { dirs: [...dirs, ...local], remote }
135 }
136
137 return null
138}
139
140// Applies `cd` targets to the session folder, as plain path arithmetic.
141export function resolveDir(cwd: string, dirs: string[]): string {
142 let parts = cwd.split('/').filter(Boolean)
143 for (const dir of dirs) {
144 if (dir.startsWith('/')) parts = []
145 for (const part of dir.split('/')) {
146 if (part === '' || part === '.') continue
147 if (part === '..') parts.pop()
148 else parts.push(part)
149 }
150 }
151
152 return `/${parts.join('/')}`
153}
154
155// `owner/repo` of a github.com remote URL (https, ssh or scp-like), else null.
156export function githubRepo(url: string): string | null {
157 const match = /^(?:(?:https?|ssh|git):\/\/(?:[^@/]+@)?github\.com(?::\d+)?\/|[^@\s]+@github\.com:)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(url.trim())
158
159 return match === null ? null : `${match[1]}/${match[2]}`
160}
161
162// A remote given on the command line may be a URL instead of a name.
163export const isUrl = (remote: string): boolean => /^[a-z]+:\/\//.test(remote) || /^[^/\s]+@[^:\s]+:/.test(remote)
164
165// Parses `gh run list --json …` output; null when it is not an array of runs.
166export function parseRuns(stdout: string): Run[] | null {
167 let raw: unknown
168 try {
169 raw = JSON.parse(stdout)
170 } catch {
171 return null
172 }
173 if (!Array.isArray(raw)) return null
174 const str = (x: unknown) => (typeof x === 'string' ? x : '')
175
176 return raw
177 .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null && typeof (v as Record<string, unknown>).databaseId === 'number')
178 .map(v => ({
179 databaseId: v.databaseId as number,
180 name: str(v.name),
181 workflowName: str(v.workflowName),
182 status: str(v.status),
183 conclusion: str(v.conclusion),
184 url: str(v.url),
185 }))
186}
187
188const PASSED = ['success', 'neutral', 'skipped']
189
190export type Verdict = 'running' | 'passed' | 'failed'
191
192export function verdict(run: Run): Verdict {
193 if (run.status !== 'completed') return 'running'
194
195 return PASSED.includes(run.conclusion) ? 'passed' : 'failed'
196}
197
198export const allDone = (runs: Run[]): boolean => runs.length > 0 && runs.every(run => verdict(run) !== 'running')
199
200export const label = (run: Run): string => run.workflowName || run.name || `run ${run.databaseId}`
201
202// "CI ⏳ 2 running", "CI ✓ 3 passed", "CI ✗ 1 failed"; undefined with no run.
203export function statusLine(runs: Run[]): string | undefined {
204 if (runs.length === 0) return undefined
205 const count = (v: Verdict) => runs.filter(run => verdict(run) === v).length
206 const running = count('running')
207 const failed = count('failed')
208 if (running > 0) return failed > 0 ? `CI ⏳ ${running} running · ✗ ${failed} failed` : `CI ⏳ ${running} running`
209 if (failed > 0) return `CI ✗ ${failed} failed`
210
211 return `CI ✓ ${count('passed')} passed`
212}
213
214// The toast once every run is completed.
215export function finalToast(runs: Run[], branch: string): string {
216 const failed = runs.filter(run => verdict(run) === 'failed')
217 if (failed.length === 0) return `CI ✓ all ${runs.length} passed on ${branch}`
218 const names = [...new Set(failed.map(label))].join(', ')
219 const url = failed[0]!.url
220
221 return url === '' ? `CI ✗ failed on ${branch}: ${names}` : `CI ✗ failed on ${branch}: ${names} — ${url}`
222}
223
224const MARK: Record<Verdict, string> = { running: '⏳', passed: '✓', failed: '✗' }
225
226export function runLine(run: Run): string {
227 const state = run.status === 'completed' ? run.conclusion : run.status
228 const name = run.name !== '' && run.name !== run.workflowName ? `${label(run)} · ${run.name}` : label(run)
229
230 return `${MARK[verdict(run)]} ${name} (${state})${run.url === '' ? '' : ` ${run.url}`}`
231}
232
233export const shortSha = (sha: string) => sha.slice(0, 7)
234