Shows CI and CodeRabbit review progress for your GitHub repos above the prompt, and toasts when they settle

Shows CI and CodeRabbit review progress for your GitHub repos above the Claude Code prompt, and toasts when they settle.
web #17 my-feature CI – CodeRabbit ● reviewing
web main CI ████░░░░░░ 2/5
gh on PATH, authenticated with access to the watched repos.At the prompt of a terminal Claude Code session:
/plugin install ci-watch --marketplace nuTTeLLo/claude-mods
From a shell: claude plugin install ci-watch --marketplace nuTTeLLo/claude-mods.
One option, repos, set in /config or on the install screen:
origin remote (git, or jj outside a git checkout), on its default branch. SSH host aliases (git@github-work:owner/name.git) count when ssh -G resolves them to github.com.owner/name or owner/name@branch, separated by commas, e.g. acme/web, acme/api@release. A repo without @branch uses its default branch.finished/total checks on the latest commit; ✓ when all passed, ✗ <check names> on failure. Yellow pending, green pass, red fail.waiting, reviewing, or ✓ N open, where N is unresolved review threads CodeRabbit opened. A repo counts as using CodeRabbit when the watched branch has a .coderabbit.yaml (or .yml), or a PR already carries a CodeRabbit status./ci-watch: polls now and prints every row, green branches included.One gh api graphql call per poll. Every 20s while anything is pending or for 10 minutes after a push (a Bash call running jj git push, git push or gh pr create/merge/ready/comment); every 5 minutes otherwise.
hooks/register.tsx 179 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Item, Snapshot } from '../types'
5import type { Repo } from './watch'
6import {
7 PUSH,
8 buildQuery,
9 changes,
10 ciText,
11 describe,
12 isPending,
13 isShown,
14 parse,
15 parseRemote,
16 parseRepos,
17 rabbitText,
18 sshHostname,
19} from './watch'
20
21const TICK_MS = 15_000
22const ACTIVE_MS = 20_000
23const IDLE_MS = 5 * 60_000
24// How long after a push to keep polling fast, while GitHub registers the checks.
25const HOT_MS = 10 * 60_000
26
27const EMPTY: Snapshot = { items: [], error: null, checkedAt: 0 }
28const snapshot = atom({ plugin: 'ci-watch', key: 'snapshot' } as const, EMPTY)
29
30const COLORS = { none: undefined, pending: 'yellow', pass: 'green', fail: 'red' } as const
31
32let nextAt = 0
33let hotUntil = 0
34let isPolling = false
35// The `repos` option; empty means the repo the session runs in.
36let configured: Repo[] = []
37// What the last poll watched.
38let watched: Repo[] = []
39
40/** Whether a remote's host is github.com, directly or through an SSH config alias. */
41async function isGitHub($: EngineInterface, host: string): Promise<boolean> {
42 if (host === 'github.com') return true
43 try {
44 const ran = await $.process.run(['ssh', '-G', host])
45
46 return ran.exitCode === 0 && sshHostname(ran.stdout) === 'github.com'
47 } catch {
48 return false
49 }
50}
51
52/** The `origin` remote of the session's directory, from git or, outside a git checkout, jj. */
53async function currentRepo($: EngineInterface): Promise<Repo | null> {
54 const asks = [
55 ['git', 'remote', 'get-url', 'origin'],
56 ['jj', 'git', 'remote', 'list'],
57 ]
58 for (const argv of asks) {
59 try {
60 const ran = await $.process.run(argv)
61 if (ran.exitCode !== 0) continue
62 const line = ran.stdout.split('\n').find(one => argv[0] === 'git' || one.startsWith('origin '))
63 const remote = parseRemote((line ?? '').replace(/^origin\s+/, ''))
64 if (remote !== null && (await isGitHub($, remote.host))) return remote.repo
65 } catch {
66 // Not installed: try the next one.
67 }
68 }
69
70 return null
71}
72
73async function poll($: EngineInterface): Promise<Snapshot> {
74 const now = await $.clock.now()
75 let items: Item[] = []
76 let error: string | null = null
77 try {
78 const here = configured.length > 0 ? null : await currentRepo($)
79 const repos = here === null ? configured : [here]
80 watched = repos
81 if (repos.length > 0) {
82 const ran = await $.process.run(['gh', 'api', 'graphql', '-f', `query=${buildQuery(repos)}`])
83 if (ran.exitCode === 0) items = parse(repos, JSON.parse(ran.stdout).data ?? {})
84 else error = ran.stderr.trim().split('\n')[0] || `gh exited ${ran.exitCode}`
85 }
86 } catch (thrown) {
87 error = thrown instanceof Error ? thrown.message : String(thrown)
88 }
89
90 const before = await read($, snapshot)
91 // A failed poll keeps the last good rows on screen beside the error.
92 const taken: Snapshot = { items: error === null ? items : before.items, error, checkedAt: now }
93 await update($, snapshot, () => taken)
94 if (error === null) {
95 for (const line of changes(before.items, items)) $.ui.toast(line)
96 }
97 const isActive = now < hotUntil || taken.items.some(isPending)
98 nextAt = now + (isActive ? ACTIVE_MS : IDLE_MS)
99
100 return taken
101}
102
103async function tick($: EngineInterface): Promise<void> {
104 if (isPolling || (await $.clock.now()) < nextAt) return
105 isPolling = true
106 try {
107 await poll($)
108 } finally {
109 isPolling = false
110 }
111}
112
113export const register: Register = (on, options) => {
114 configured = parseRepos(typeof options.repos === 'string' ? options.repos : '')
115
116 on('session.start', async ($, e, next) => {
117 await $.command.register({
118 name: 'ci-watch',
119 description: 'Check CI and CodeRabbit progress on the watched repos now',
120 })
121 $.clock.every(TICK_MS, () => void tick($))
122 void tick($)
123
124 return next(e)
125 })
126
127 on('command.run', { command: 'ci-watch' }, async $ => {
128 const taken = await poll($)
129 // The command's output is already shown under its name, so no `ci-watch:` prefix here.
130 const lines = taken.items.map(describe)
131 if (taken.error !== null) lines.unshift(taken.error)
132
133 if (watched.length === 0) lines.push('no GitHub repo here; set the repos option in /config to watch others.')
134
135 return { text: lines.join('\n') || 'nothing to report.' }
136 })
137
138 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
139 const ran = await next(e)
140 if (PUSH.test(e.command)) {
141 const now = await $.clock.now()
142 hotUntil = now + HOT_MS
143 // GitHub needs a moment before the new commit's checks exist.
144 nextAt = now + 5_000
145 }
146
147 return ran
148 })
149
150 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
151 const taken = await read($, snapshot)
152 const rows = taken.items.filter(isShown)
153 if (e.props.hasSurvey || (rows.length === 0 && taken.error === null)) {
154 return next(e)
155 }
156
157 const { Box, Text } = $.ui.resolve(e)
158
159 return (
160 <Box flexDirection="column">
161 {taken.error !== null && <Text color="red">ci-watch: {taken.error}</Text>}
162 {rows.map(item => (
163 <Box>
164 <Text dimColor>{item.repo} </Text>
165 <Text>{item.label} </Text>
166 <Text color={COLORS[item.ci]}>{ciText(item)}</Text>
167 {item.hasRabbit && (
168 <Text color={item.rabbit === 'none' ? 'yellow' : COLORS[item.rabbit]}>
169 {' '}
170 {rabbitText(item)}
171 </Text>
172 )}
173 </Box>
174 ))}
175 </Box>
176 )
177 })
178}
179hooks/watch.ts 223 lines1import type { Check, Item } from '../types'
2
3/** A repo to watch; with no `base`, its default branch. */
4export type Repo = { owner: string; name: string; base?: string }
5
6/** Reads the `repos` option: `owner/name` or `owner/name@branch`, split by commas or spaces. */
7export function parseRepos(text: string): Repo[] {
8 const repos: Repo[] = []
9 for (const one of text.split(/[\s,]+/).filter(Boolean)) {
10 const found = /^([\w.-]+)\/([\w.-]+)(?:@([^"\\]+))?$/.exec(one)
11 const [, owner, name, base] = found ?? []
12 if (owner !== undefined && name !== undefined) repos.push({ owner, name, base })
13 }
14
15 return repos
16}
17
18/** The host and repo a remote URL names, for HTTPS, SSH and scp-style remotes alike. */
19export function parseRemote(url: string): { host: string; repo: Repo } | null {
20 const [, host, owner, name] =
21 /^(?:[\w+.-]+:\/\/)?(?:[^@/]+@)?([^:/\s]+)(?::\d+)?[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(url.trim()) ?? []
22 if (host === undefined || owner === undefined || name === undefined) return null
23
24 return { host: host.toLowerCase(), repo: { owner, name } }
25}
26
27/** The real host name in `ssh -G <alias>` output, so `git@github-work:...` counts as GitHub. */
28export function sshHostname(config: string): string | null {
29 const line = config.split('\n').find(one => one.startsWith('hostname '))
30
31 return line === undefined ? null : line.slice('hostname '.length).trim().toLowerCase()
32}
33
34export const PUSH = /\b(jj\s+git\s+push|git\s+push|gh\s+pr\s+(create|merge|ready|comment))\b/
35
36const ROLLUP = `oid statusCheckRollup { contexts(first: 100) { nodes {
37 __typename
38 ... on CheckRun { name status conclusion }
39 ... on StatusContext { context state }
40} } }`
41
42export function buildQuery(repos: readonly Repo[]): string {
43 const parts = repos.map((repo, i) => {
44 // The branch watched decides whether CodeRabbit is configured.
45 const tree = repo.base ?? 'HEAD'
46
47 return `r${i}: repository(owner: "${repo.owner}", name: "${repo.name}") {
48 ${repo.base === undefined ? 'ref: defaultBranchRef' : `ref(qualifiedName: "refs/heads/${repo.base}")`} { name target { ... on Commit { ${ROLLUP} } } }
49 rabbitYaml: object(expression: "${tree}:.coderabbit.yaml") { id }
50 rabbitYml: object(expression: "${tree}:.coderabbit.yml") { id }
51 pullRequests(states: OPEN, first: 10, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes {
52 number headRefName url
53 commits(last: 1) { nodes { commit { ${ROLLUP} } } }
54 reviewThreads(first: 100) { nodes { isResolved comments(first: 1) { nodes { author { login } } } } }
55 } }
56}`
57 })
58
59 return `query {\n${parts.join('\n')}\n}`
60}
61
62type Context = {
63 __typename?: string
64 name?: string
65 status?: string
66 conclusion?: string | null
67 context?: string
68 state?: string
69}
70type Commit = { oid?: string; statusCheckRollup?: { contexts?: { nodes?: Context[] } } | null }
71type Pull = {
72 number: number
73 headRefName: string
74 url: string
75 commits?: { nodes?: { commit?: Commit }[] }
76 reviewThreads?: {
77 nodes?: { isResolved: boolean; comments?: { nodes?: { author?: { login?: string } | null }[] } }[]
78 }
79}
80type RepoData = {
81 ref?: { name?: string; target?: Commit } | null
82 rabbitYaml?: { id: string } | null
83 rabbitYml?: { id: string } | null
84 pullRequests?: { nodes?: Pull[] }
85} | null
86
87const PASSING = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
88const WAITING = new Set(['PENDING', 'EXPECTED'])
89
90function checkOf(one: Context): Check {
91 if (one.__typename === 'CheckRun') {
92 if (one.status !== 'COMPLETED') return 'pending'
93
94 return PASSING.has(one.conclusion ?? '') ? 'pass' : 'fail'
95 }
96 if (WAITING.has(one.state ?? '')) return 'pending'
97
98 return one.state === 'SUCCESS' ? 'pass' : 'fail'
99}
100
101function rollup(commit: Commit | undefined) {
102 const nodes = commit?.statusCheckRollup?.contexts?.nodes ?? []
103 const ci = nodes.filter(one => one.context !== 'CodeRabbit')
104 const rabbit = nodes.find(one => one.context === 'CodeRabbit')
105 const states = ci.map(checkOf)
106 const failed = ci.filter((_, i) => states[i] === 'fail').map(one => one.name ?? one.context ?? '?')
107 let state: Check = 'none'
108 if (failed.length > 0) state = 'fail'
109 else if (states.includes('pending')) state = 'pending'
110 else if (states.length > 0) state = 'pass'
111
112 return {
113 sha: commit?.oid ?? '',
114 ci: state,
115 ciDone: states.filter(one => one !== 'pending').length,
116 ciTotal: states.length,
117 failed,
118 rabbit: rabbit === undefined ? ('none' as const) : checkOf(rabbit),
119 }
120}
121
122/** Turns the GraphQL answer into one item per base branch and open pull request. */
123export function parse(repos: readonly Repo[], data: Record<string, RepoData>): Item[] {
124 const items: Item[] = []
125 repos.forEach((repo, i) => {
126 const found = data[`r${i}`]
127 if (found === null || found === undefined) return
128 const pulls = found.pullRequests?.nodes ?? []
129 // CodeRabbit counts where the repo configures it or it has already posted a status.
130 const hasRabbit =
131 Boolean(found.rabbitYaml ?? found.rabbitYml) ||
132 pulls.some(pull => rollup(pull.commits?.nodes?.[0]?.commit).rabbit !== 'none')
133 for (const pull of pulls) {
134 const threads = (pull.reviewThreads?.nodes ?? []).filter(
135 thread =>
136 !thread.isResolved &&
137 (thread.comments?.nodes?.[0]?.author?.login ?? '').startsWith('coderabbitai'),
138 ).length
139 items.push({
140 id: `${repo.name}#${pull.number}`,
141 repo: repo.name,
142 label: `#${pull.number} ${pull.headRefName}`,
143 isPr: true,
144 url: pull.url,
145 ...rollup(pull.commits?.nodes?.[0]?.commit),
146 hasRabbit,
147 threads,
148 })
149 }
150 const base = found.ref?.name ?? repo.base ?? '?'
151 items.push({
152 id: `${repo.name}@${base}`,
153 repo: repo.name,
154 label: base,
155 isPr: false,
156 ...rollup(found.ref?.target),
157 hasRabbit: false,
158 threads: 0,
159 })
160 })
161
162 return items
163}
164
165export function isPending(item: Item): boolean {
166 // An open PR with no CodeRabbit status yet is still waiting for its review.
167 return item.ci === 'pending' || item.rabbit === 'pending' || (item.hasRabbit && item.rabbit === 'none')
168}
169
170/** A base branch is only worth a row while its build runs or has failed. */
171export function isShown(item: Item): boolean {
172 return item.isPr || item.ci === 'pending' || item.ci === 'fail'
173}
174
175const BAR_CELLS = 10
176
177export function bar(done: number, total: number): string {
178 const filled = total === 0 ? 0 : Math.round((done / total) * BAR_CELLS)
179
180 return '█'.repeat(filled) + '░'.repeat(BAR_CELLS - filled)
181}
182
183export function ciText(item: Item): string {
184 if (item.ci === 'none') return 'CI –'
185 const progress = `CI ${bar(item.ciDone, item.ciTotal)} ${item.ciDone}/${item.ciTotal}`
186 if (item.ci === 'pending') return progress
187 if (item.ci === 'pass') return `${progress} ✓`
188
189 return `${progress} ✗ ${item.failed.join(', ')}`
190}
191
192export function rabbitText(item: Item): string {
193 if (!item.hasRabbit) return ''
194 const open = item.threads > 0 ? ` ${item.threads} open` : ''
195 if (item.rabbit === 'pass') return `CodeRabbit ✓${open}`
196 if (item.rabbit === 'fail') return `CodeRabbit ✗${open}`
197
198 return item.rabbit === 'pending' ? 'CodeRabbit ● reviewing' : 'CodeRabbit ● waiting'
199}
200
201export function describe(item: Item): string {
202 return [`${item.repo} ${item.label}`, ciText(item), rabbitText(item)].filter(Boolean).join(' ')
203}
204
205/** What changed for the same commit between two polls, as toast lines. */
206export function changes(before: readonly Item[], after: readonly Item[]): string[] {
207 const lines: string[] = []
208 for (const item of after) {
209 const was = before.find(one => one.id === item.id)
210 if (was === undefined || was.sha !== item.sha) continue
211 const name = `${item.repo} ${item.label}`
212 if (was.ci === 'pending' && item.ci === 'pass') lines.push(`${name}: CI passed`)
213 if (was.ci !== 'fail' && item.ci === 'fail') lines.push(`${name}: CI failed (${item.failed.join(', ')})`)
214 if (was.rabbit !== 'pass' && item.rabbit === 'pass') {
215 const found = item.threads > 0 ? `${item.threads} comments to address` : 'no open comments'
216 lines.push(`${name}: CodeRabbit reviewed, ${found}`)
217 }
218 if (was.rabbit !== 'fail' && item.rabbit === 'fail') lines.push(`${name}: CodeRabbit failed`)
219 }
220
221 return lines
222}
223types/index.d.ts 33 lines1export type Check = 'none' | 'pending' | 'pass' | 'fail'
2
3export type Item = {
4 /** `<repo>#<pr>` for a pull request, `<repo>@<branch>` for a base branch. */
5 id: string
6 repo: string
7 label: string
8 isPr: boolean
9 url?: string
10 sha: string
11 ci: Check
12 ciDone: number
13 ciTotal: number
14 failed: string[]
15 rabbit: Check
16 /** A PR in a repo that CodeRabbit reviews. */
17 hasRabbit: boolean
18 /** Unresolved review threads CodeRabbit opened. */
19 threads: number
20}
21
22export type Snapshot = {
23 items: Item[]
24 error: string | null
25 checkedAt: number
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'ci-watch': { snapshot: Snapshot }
31 }
32}
33