Live pull request checks beside the transcript: pass/fail per check, re-run failed jobs, open the PR. A status line appears only while something is failing…

Your pull request's checks, live, beside the transcript.
/ci opens the pane: every check with ✓/✗/•, a link to the PR, Refresh and Re-run failed buttons./ci rerun re-runs failed jobs from anywhere, including surfaces that don't draw panes.Requires the GitHub CLI signed in (gh auth login).
/plugin marketplace add claudemodz/mods
/plugin install ci-pane@claudemodz-mods
/config)Runs gh pr view and gh run rerun ($.process.run), polls on a timer, and draws a pane, toasts and a status line. It does not read or write files or make other network calls.
hooks/register.ts 121 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code'
2
3import {
4 errorStatus,
5 failingRunIds,
6 parsePrView,
7 rejectionStatus,
8 summaryLine,
9 tally,
10 transitionOf,
11 type PrStatus,
12} from './checks.js'
13import { paneView } from './view.js'
14
15export const PANE_ID = 'ci'
16
17const PR_FIELDS = 'number,title,url,statusCheckRollup'
18
19/** While gh is missing or there is no PR, poll only every this many intervals. */
20const IDLE_EVERY = 10
21
22/**
23 * What this mod knows between hooks: the last status, the last status that
24 * was a PR (transitions compare against it, so a network blip doesn't reset
25 * them), the last re-run note, and how many idle intervals have passed.
26 */
27type PaneState = { status: PrStatus; lastPr: PrStatus | null; note: string; idleTicks: number }
28
29export function register(on: On, options: PluginOptions): void {
30 const pollMs = Math.max(10, Number(options.pollSeconds ?? 30)) * 1000
31 const state: PaneState = { status: { kind: 'loading' }, lastPr: null, note: '', idleTicks: 0 }
32
33 on('session.start', async ($, e, next) => {
34 await $.command.register({
35 name: 'ci',
36 description: "Show CI checks for this branch's pull request (/ci rerun re-runs failed jobs)",
37 argumentHint: '[rerun]',
38 immediate: true,
39 })
40 // Scripted and SDK sessions draw nothing: /ci still answers there, but nothing polls.
41 if (e.isInteractive && e.surface !== null) {
42 void refresh($, state).catch(() => undefined)
43 $.clock.every(pollMs, () => {
44 void tick($, state).catch(() => undefined)
45 })
46 }
47 return next(e)
48 })
49
50 on('command.run', { command: 'ci' }, async ($, e) => {
51 if (e.args.trim() === 'rerun') {
52 await rerunFailed($, state)
53 return { text: state.note }
54 }
55 await refresh($, state)
56 await $.ui.open({ id: PANE_ID, title: 'CI' })
57 return { text: summaryLine(state.status) }
58 })
59
60 on('ui.render', { component: 'Pane' }, ($, e, next) => {
61 if (e.requestId !== PANE_ID) return next(e)
62 return paneView($.ui.resolve(e), state.status, state.note, {
63 refresh: () => void refresh($, state).catch(() => undefined),
64 rerun: () => void rerunFailed($, state).catch(() => undefined),
65 })
66 })
67}
68
69async function tick($: EngineInterface, state: PaneState): Promise<void> {
70 const isIdle = state.status.kind === 'no-gh' || state.status.kind === 'no-pr'
71 state.idleTicks = isIdle ? state.idleTicks + 1 : 0
72 if (isIdle && state.idleTicks % IDLE_EVERY !== 0) return
73 await refresh($, state)
74}
75
76async function refresh($: EngineInterface, state: PaneState): Promise<void> {
77 const next = await fetchStatus($)
78 state.status = next
79 if (next.kind === 'pr') {
80 const transition = transitionOf(state.lastPr ?? { kind: 'loading' }, next)
81 state.lastPr = next
82 if (transition === 'now-failing') $.ui.toast(summaryLine(next))
83 if (transition === 'now-passing') $.ui.toast(`CI #${next.number}: all checks passed`)
84 }
85 const isFailing = next.kind === 'pr' && tally(next.checks).failed > 0
86 $.ui.status(isFailing ? `${summaryLine(next)} · /ci` : undefined)
87 $.ui.invalidate('ui.render')
88}
89
90async function rerunFailed($: EngineInterface, state: PaneState): Promise<void> {
91 const ids = state.status.kind === 'pr' ? failingRunIds(state.status.checks) : []
92 let done = 0
93 const problems: string[] = []
94 for (const id of ids) {
95 const run = await $.process
96 .run(['gh', 'run', 'rerun', id, '--failed'], { timeoutMs: 30_000 })
97 .catch((error: unknown) => ({ exitCode: 1, stderr: String(error) }))
98 if (run.exitCode === 0) done += 1
99 else problems.push(run.stderr.trim().split('\n')[0] || `gh exited with ${run.exitCode}`)
100 }
101 state.note = noteOf(ids.length, done, problems)
102 await refresh($, state)
103}
104
105function noteOf(total: number, done: number, problems: readonly string[]): string {
106 const runs = (n: number) => `${n} workflow run${n === 1 ? '' : 's'}`
107 if (total === 0) return 'Nothing to re-run.'
108 if (problems.length === 0) return `Re-ran ${runs(done)}.`
109 const failed = `Couldn't re-run ${runs(problems.length)}: ${problems[0]}`
110 return done > 0 ? `${failed} Re-ran ${runs(done)}.` : failed
111}
112
113async function fetchStatus($: EngineInterface): Promise<PrStatus> {
114 try {
115 const run = await $.process.run(['gh', 'pr', 'view', '--json', PR_FIELDS], { timeoutMs: 15_000 })
116 return run.exitCode === 0 ? parsePrView(run.stdout) : errorStatus(run.exitCode, run.stderr)
117 } catch (error) {
118 return rejectionStatus(error instanceof Error ? error.message : String(error))
119 }
120}
121hooks/checks.ts 132 lines1export type CheckState = 'passed' | 'failed' | 'pending' | 'skipped'
2
3export type Check = {
4 readonly name: string
5 readonly state: CheckState
6 readonly url: string | null
7 readonly runId: string | null
8}
9
10export type PrStatus =
11 | { kind: 'loading' }
12 | { kind: 'no-gh' }
13 | { kind: 'no-pr' }
14 | { kind: 'error'; message: string }
15 | { kind: 'pr'; number: number; title: string; url: string; checks: readonly Check[] }
16
17export type Tally = { readonly passed: number; readonly failed: number; readonly pending: number }
18
19type RollupItem = {
20 readonly __typename?: string
21 readonly name?: string
22 readonly context?: string
23 readonly status?: string
24 readonly conclusion?: string | null
25 readonly state?: string
26 readonly detailsUrl?: string
27 readonly targetUrl?: string
28}
29
30const SHAPE_ERROR: PrStatus = { kind: 'error', message: 'gh returned an unexpected shape' }
31
32/** Parses `gh pr view --json number,title,url,statusCheckRollup` output. */
33export function parsePrView(stdout: string): PrStatus {
34 let data: unknown
35 try {
36 data = JSON.parse(stdout)
37 } catch {
38 return { kind: 'error', message: 'gh returned output that is not JSON' }
39 }
40 if (typeof data !== 'object' || data === null) return SHAPE_ERROR
41 const pr = data as { number?: unknown; title?: unknown; url?: unknown; statusCheckRollup?: unknown }
42 if (typeof pr.number !== 'number' || typeof pr.url !== 'string') return SHAPE_ERROR
43 const items = Array.isArray(pr.statusCheckRollup) ? (pr.statusCheckRollup as RollupItem[]) : []
44 return {
45 kind: 'pr',
46 number: pr.number,
47 title: typeof pr.title === 'string' ? pr.title : '',
48 url: pr.url,
49 checks: items.map(checkOf),
50 }
51}
52
53function checkOf(item: RollupItem): Check {
54 const url = item.detailsUrl ?? item.targetUrl ?? null
55 return { name: item.name ?? item.context ?? 'check', state: stateOf(item), url, runId: runIdOf(url) }
56}
57
58function stateOf(item: RollupItem): CheckState {
59 if (item.__typename === 'StatusContext') {
60 const state = (item.state ?? '').toUpperCase()
61 if (state === 'SUCCESS') return 'passed'
62 if (state === 'FAILURE' || state === 'ERROR') return 'failed'
63 return 'pending'
64 }
65 if ((item.status ?? '').toUpperCase() !== 'COMPLETED') return 'pending'
66 const conclusion = (item.conclusion ?? '').toUpperCase()
67 if (conclusion === 'SUCCESS' || conclusion === 'NEUTRAL') return 'passed'
68 if (conclusion === 'SKIPPED') return 'skipped'
69 return 'failed'
70}
71
72function runIdOf(url: string | null): string | null {
73 return url?.match(/\/actions\/runs\/(\d+)/)?.[1] ?? null
74}
75
76/** The state for a `gh pr view` that exited non-zero. */
77export function errorStatus(exitCode: number, stderr: string): PrStatus {
78 if (/no pull requests found/i.test(stderr) || /not a git repository/i.test(stderr)) {
79 return { kind: 'no-pr' }
80 }
81 if (exitCode === 4 || /gh auth login|HTTP 401/i.test(stderr)) return { kind: 'no-gh' }
82 const first = stderr.trim().split('\n')[0] ?? ''
83 return { kind: 'error', message: first || `gh exited with ${exitCode}` }
84}
85
86/** The state for a `gh` run that could not finish: gh missing, or a timeout or refusal. */
87export function rejectionStatus(message: string): PrStatus {
88 if (/ENOENT|EACCES|no such file|command not found/i.test(message)) return { kind: 'no-gh' }
89 return { kind: 'error', message: `gh did not finish: ${message}` }
90}
91
92export function tally(checks: readonly Check[]): Tally {
93 return {
94 passed: checks.filter(c => c.state === 'passed').length,
95 failed: checks.filter(c => c.state === 'failed').length,
96 pending: checks.filter(c => c.state === 'pending').length,
97 }
98}
99
100export function summaryLine(status: PrStatus): string {
101 switch (status.kind) {
102 case 'loading':
103 return 'CI: checking…'
104 case 'no-gh':
105 return 'CI: install and sign in to the GitHub CLI (gh) to see checks.'
106 case 'no-pr':
107 return 'CI: no pull request for this branch.'
108 case 'error':
109 return `CI: ${status.message}`
110 case 'pr': {
111 if (status.checks.length === 0) return `CI #${status.number}: no checks reported`
112 const t = tally(status.checks)
113 return `CI #${status.number}: ${t.failed} failing · ${t.pending} pending · ${t.passed} passing`
114 }
115 }
116}
117
118/** Whether moving from `before` to `after` deserves a toast. */
119export function transitionOf(before: PrStatus, after: PrStatus): 'now-failing' | 'now-passing' | null {
120 if (after.kind !== 'pr') return null
121 const now = tally(after.checks)
122 const was = before.kind === 'pr' ? tally(before.checks) : null
123 if (now.failed > 0) return was === null || was.failed === 0 ? 'now-failing' : null
124 const isGreen = now.pending === 0 && now.passed > 0
125 return isGreen && was !== null && (was.failed > 0 || was.pending > 0) ? 'now-passing' : null
126}
127
128export function failingRunIds(checks: readonly Check[]): string[] {
129 const ids = checks.filter(c => c.state === 'failed' && c.runId !== null).map(c => c.runId as string)
130 return [...new Set(ids)]
131}
132hooks/view.tsx 46 lines1import type { Elements, RenderElement } from 'claude-code'
2
3import { summaryLine, type Check, type PrStatus } from './checks.js'
4
5export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Link'>
6
7export type PaneActions = { readonly refresh: () => void; readonly rerun: () => void }
8
9const MARK: Record<Check['state'], { glyph: string; color: string }> = {
10 passed: { glyph: '✓', color: 'green' },
11 failed: { glyph: '✗', color: 'red' },
12 pending: { glyph: '•', color: 'yellow' },
13 skipped: { glyph: '–', color: 'gray' },
14}
15
16export function paneView(ui: Kit, status: PrStatus, note: string, actions: PaneActions): RenderElement {
17 const { Box, Text, Button, Link } = ui
18 if (status.kind !== 'pr') {
19 return (
20 <Box flexDirection="column" gap={1}>
21 <Text>{summaryLine(status)}</Text>
22 <Button key="refresh" label="Refresh" onPress={actions.refresh} />
23 </Box>
24 )
25 }
26 const hasFailures = status.checks.some(c => c.state === 'failed')
27 return (
28 <Box flexDirection="column" gap={1}>
29 <Link href={status.url} label={`#${status.number} ${status.title}`} />
30 <Text dimColor>{summaryLine(status)}</Text>
31 <Box flexDirection="column">
32 {status.checks.map(check => (
33 <Text key={check.name} color={MARK[check.state].color}>
34 {`${MARK[check.state].glyph} ${check.name}`}
35 </Text>
36 ))}
37 </Box>
38 <Box gap={2}>
39 <Button key="refresh" label="Refresh" onPress={actions.refresh} />
40 {hasFailures ? <Button key="rerun" label="Re-run failed" onPress={actions.rerun} /> : null}
41 </Box>
42 {note ? <Text dimColor>{note}</Text> : null}
43 </Box>
44 )
45}
46