Live CI status of the current branch's pull request above the prompt, or of any PR you pin with /pr-status <number>. Detects GitHub, GitLab, Gitea/Forgejo…

Shows the CI status of the current branch's pull request in a band above the prompt. The band appears as soon as the branch has an open PR, counts its checks by state, and updates while they run:

PR #842 ✗ 1 failed ⟳ 2 running ○ 1 queued ✓ 8 passed feat(ENG-3331): parity follow-ups [ Open ] [ Hide ]
failing: CI / integration (pull_request)
A toast tells you when CI finishes, passed or failed.
The mod reads the branch's upstream remote (or origin) and detects the forge on its own:
| Forge | How it reads the PR | Credentials |
|---|---|---|
| GitHub, GitHub Enterprise | gh pr view, or the REST API | gh auth login, or GH_TOKEN/GITHUB_TOKEN |
| GitLab, self-hosted GitLab | glab api, or the REST API: the MR's head pipeline jobs | glab auth login, or GITLAB_TOKEN/GL_TOKEN |
| Gitea, Forgejo, Codeberg | tea pulls list, or the REST API: commit statuses (Gitea Actions report here) | tea login add, or GITEA_TOKEN/FORGEJO_TOKEN/CODEBERG_TOKEN |
| Bitbucket Cloud | REST API: the PR's statuses | BITBUCKET_TOKEN, or BITBUCKET_USERNAME + BITBUCKET_APP_PASSWORD; public repos need none |
| Azure DevOps | REST API: builds of the PR's merge ref and PR statuses | AZURE_DEVOPS_EXT_PAT |
Detection, in order:
github.com-work resolves to its real host through ssh -G.github.com, gitlab.com, bitbucket.org, dev.azure.com, *.visualstudio.com, codeberg.org, and hosts named github.*, gitlab.*, gitea.* or forgejo.*.tea, glab or gh is signed in to./api/v1/version (Gitea, Forgejo), /api/v4/version (GitLab), /api/v3/meta (GitHub Enterprise).Each host's result is remembered on this machine.
| Glyph | State | Includes |
|---|---|---|
| ✓ | passed | success |
| ✗ | failed | failure, error, timed out, action required |
| ⟳ | running | in progress, pending |
| ○ | queued | queued, waiting, created, scheduled |
| ⚠ | warning | Gitea warning, GitLab allowed failure, Azure partially succeeded |
| ⊘ | cancelled | cancelled, stopped, stale |
| – | skipped | skipped, neutral, manual |
When a check is re-run, only its newest result is counted.
git push, git checkout/switch, gh pr create, glab mr create, tea pr create, az repos pr create, or a CI re-run, and again 5 seconds later. Also after Claude enters or leaves a worktree./pr-status # refresh, then list every check (failed ones with their links)
/pr-status 123 # pin PR 123 (or #123): the band follows that PR instead of the branch's
/pr-status branch # follow the branch's open PR again
/pr-status open # open the PR in the browser
/pr-status hide # hide the band until the next PR
/pr-status show
claude plugin validate mods/pr-status
claude plugin test mods/pr-statushooks/register.tsx 559 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Check, CheckState, Forge, Pr, View } from '../types'
5import {
6 PR_ACTIVITY,
7 forgeFromProbe,
8 fromAzure,
9 fromBitbucket,
10 fromGhView,
11 fromGiteaRest,
12 fromGithubRest,
13 fromGitlab,
14 isSettled,
15 knownForge,
16 parseRemote,
17 sshHostname,
18 tally,
19 teaChecks,
20} from './forges'
21import type { Probe, Remote } from './forges'
22
23// Reading the open PR for a branch from each forge: its CLI when installed and
24// signed in (gh, glab, tea), its REST API with a token from the environment
25// otherwise.
26
27const TIMEOUT_MS = 20_000
28/** How many pages of 50 to look back through for a pinned Gitea PR. */
29const TEA_PAGES = 20
30/** Where a pinned Gitea PR sits in tea's newest-first list, so its CI is one cheap row away. */
31const positions = new Map<string, number>()
32const BIN_FOLDERS = ['', '/opt/homebrew/bin/', '/usr/local/bin/', '/usr/bin/']
33const found = new Map<string, string | null>()
34
35type Ran = { exitCode: number; stdout: string; stderr: string }
36
37/**
38 * Runs a program, looked up on PATH and then in the usual Homebrew and system folders; null when it is not
39 * installed. Only a missing program counts as not installed: a call that times out or fails otherwise comes
40 * back as a failed run, so one slow forge answer never hides the CLI for the rest of the session.
41 */
42async function run($: EngineInterface, argv: string[], cwd: string): Promise<Ran | null> {
43 const [name, ...rest] = argv
44 if (!name) return null
45 const known = found.get(name)
46 if (known === null) return null
47 for (const folder of known !== undefined ? [''] : BIN_FOLDERS) {
48 const program = known ?? `${folder}${name}`
49 let failure = ''
50 const ran = await $.process.run([program, ...rest], { cwd, timeoutMs: TIMEOUT_MS }).catch((error: unknown) => {
51 failure = error instanceof Error ? error.message : String(error)
52 return null
53 })
54 if (ran === null && /ENOENT|not found|no such file|executable/i.test(failure)) continue
55 found.set(name, program)
56 if (ran === null) return { exitCode: 124, stdout: '', stderr: failure || `${name} did not answer` }
57 return { exitCode: ran.exitCode ?? 1, stdout: ran.stdout, stderr: ran.stderr }
58 }
59 found.set(name, null)
60
61 return null
62}
63
64async function getJson<T>($: EngineInterface, url: string, headers: Record<string, string>): Promise<T> {
65 const response = await $.http.fetch(url, { headers: { accept: 'application/json', ...headers } })
66 if (!response.ok) throw new Error(`${response.status} from ${new URL(url).host}${response.status === 401 || response.status === 403 ? ' (token missing or without access)' : ''}`)
67
68 return JSON.parse(response.text) as T
69}
70
71async function probe($: EngineInterface, url: string): Promise<Probe | undefined> {
72 const response = await $.http.fetch(url, { headers: { accept: 'application/json' } }).catch(() => undefined)
73
74 return response ? { status: response.status, text: response.text.slice(0, 400) } : undefined
75}
76
77/** The real host behind an SSH alias such as `github.com-work`. */
78async function resolveHost($: EngineInterface, remote: Remote, cwd: string): Promise<string> {
79 if (!remote.isSsh) return remote.host
80 const ran = await run($, ['ssh', '-G', remote.host], cwd)
81
82 return (ran && sshHostname(ran.stdout)) || remote.host
83}
84
85/** Known host, then the signed-in CLIs, then a probe of the host's API. */
86async function detectForge($: EngineInterface, host: string, cwd: string): Promise<Forge | undefined> {
87 const known = knownForge(host)
88 if (known) return known
89 const tea = await run($, ['tea', 'login', 'list', '--output', 'json'], cwd)
90 if (tea?.exitCode === 0 && tea.stdout.includes(host)) return 'gitea'
91 const glab = await run($, ['glab', 'auth', 'status', '--hostname', host], cwd)
92 if (glab?.exitCode === 0) return 'gitlab'
93 const gh = await run($, ['gh', 'auth', 'status', '--hostname', host], cwd)
94 if (gh?.exitCode === 0) return 'github'
95 const base = `https://${host}`
96 const [gitea, gitlab, github] = await Promise.all([probe($, `${base}/api/v1/version`), probe($, `${base}/api/v4/version`), probe($, `${base}/api/v3/meta`)])
97
98 return forgeFromProbe({ gitea, gitlab, github })
99}
100
101/** Where to look: the repository, and either the branch's open PR or, with `number`, that PR. */
102type Target = { forge: Forge; host: string; path: string; branch: string; cwd: string; number?: number }
103
104async function readPr($: EngineInterface, target: Target): Promise<Pr | null> {
105 switch (target.forge) {
106 case 'github':
107 return readGithub($, target)
108 case 'gitlab':
109 return readGitlab($, target)
110 case 'gitea':
111 return readGitea($, target)
112 case 'bitbucket':
113 return readBitbucket($, target)
114 case 'azure':
115 return readAzure($, target)
116 }
117}
118
119async function readGithub($: EngineInterface, { host, path, branch, cwd, number }: Target): Promise<Pr | null> {
120 const fields = 'number,title,url,state,isDraft,statusCheckRollup'
121 const repo = host === 'github.com' ? path : `${host}/${path}`
122 const gh = await run($, ['gh', 'pr', 'view', number === undefined ? branch : String(number), '--repo', repo, '--json', fields], cwd)
123 if (gh?.exitCode === 0) return fromGhView(gh.stdout)
124 if (gh && /no (open )?pull requests? found/i.test(gh.stderr)) return null
125 const token = (await $.env.get('GH_TOKEN')) || (await $.env.get('GITHUB_TOKEN'))
126 if (!token && gh) throw new Error(`gh: ${gh.stderr.trim().split('\n')[0] ?? 'failed'}`)
127 const api = host === 'github.com' ? 'https://api.github.com' : `https://${host}/api/v3`
128 const headers: Record<string, string> = token ? { authorization: `Bearer ${token}` } : {}
129 const owner = path.split('/')[0]
130 type Pull = { number: number; title: string; html_url: string; draft?: boolean; head: { sha: string } }
131 const pull =
132 number === undefined
133 ? (await getJson<Pull[]>($, `${api}/repos/${path}/pulls?state=open&head=${encodeURIComponent(`${owner}:${branch}`)}`, headers))[0]
134 : await getJson<Pull>($, `${api}/repos/${path}/pulls/${number}`, headers)
135 if (!pull) return null
136 const [runs, statuses] = await Promise.all([
137 getJson<Parameters<typeof fromGithubRest>[1]>($, `${api}/repos/${path}/commits/${pull.head.sha}/check-runs?per_page=100`, headers),
138 getJson<Parameters<typeof fromGithubRest>[2]>($, `${api}/repos/${path}/commits/${pull.head.sha}/status?per_page=100`, headers),
139 ])
140
141 return fromGithubRest(pull, runs, statuses)
142}
143
144async function readGitlab($: EngineInterface, { host, path, branch, cwd, number }: Target): Promise<Pr | null> {
145 const project = encodeURIComponent(path)
146 const token = (await $.env.get('GITLAB_TOKEN')) || (await $.env.get('GL_TOKEN'))
147 const hasGlab = !token && (await run($, ['glab', 'auth', 'status', '--hostname', host], cwd))?.exitCode === 0
148 const get = async <T,>(endpoint: string): Promise<T> => {
149 if (hasGlab) {
150 const ran = await run($, ['glab', 'api', '--hostname', host, endpoint], cwd)
151 if (ran?.exitCode !== 0) throw new Error(`glab: ${ran?.stderr.trim().split('\n')[0] ?? 'failed'}`)
152 return JSON.parse(ran.stdout) as T
153 }
154 return getJson<T>($, `https://${host}/api/v4/${endpoint}`, token ? { 'private-token': token } : {})
155 }
156 type Mr = Parameters<typeof fromGitlab>[0] & { head_pipeline?: { id: number } | null }
157 const iid =
158 number ?? (await get<Mr[]>(`projects/${project}/merge_requests?state=opened&source_branch=${encodeURIComponent(branch)}`))[0]?.iid
159 if (iid === undefined) return null
160 const mr = await get<Mr>(`projects/${project}/merge_requests/${iid}`)
161 const pipeline = mr.head_pipeline?.id
162 const jobs = pipeline ? await get<Parameters<typeof fromGitlab>[1]>(`projects/${project}/pipelines/${pipeline}/jobs?per_page=100`) : []
163
164 return fromGitlab(mr, jobs)
165}
166
167/** The branch's open PR through tea: the first page of open PRs, with their CI. Undefined when tea is missing. */
168async function teaBranchPr($: EngineInterface, path: string, branch: string, cwd: string): Promise<Pr | null | undefined> {
169 const common = ['--repo', path, '--state', 'open', '--limit', '50']
170 const list = await run($, ['tea', 'pulls', 'list', ...common, '--fields', 'index,title,head,url', '--output', 'json'], cwd)
171 if (!list) return undefined
172 if (list.exitCode !== 0) throw new Error(`tea: ${list.stderr.trim().split('\n')[0] || 'failed'}`)
173 const pulls = JSON.parse(list.stdout || '[]') as { index: string; title: string; head: string; url: string }[]
174 const pull = pulls.find(one => one.head === branch || one.head.endsWith(`:${branch}`))
175 if (!pull) return null
176 const index = Number(pull.index)
177 // tea fetches CI for every row it prints: ask for the PR's own row, and the full page only if it moved.
178 const position = pulls.indexOf(pull) + 1
179 const row = await run($, ['tea', 'pulls', 'list', '--repo', path, '--state', 'open', '--limit', '1', '--page', String(position), '--fields', 'index,ci', '--output', 'simple'], cwd)
180 let checks = row?.exitCode === 0 ? teaChecks(row.stdout, index) : undefined
181 if (checks === undefined) {
182 const ci = await run($, ['tea', 'pulls', 'list', ...common, '--fields', 'index,ci', '--output', 'simple'], cwd)
183 checks = ci?.exitCode === 0 ? (teaChecks(ci.stdout, index) ?? []) : []
184 }
185
186 return { forge: 'gitea', number: index, title: pull.title, url: pull.url, isDraft: /^(WIP|Draft)\b/i.test(pull.title), checks }
187}
188
189/**
190 * Any PR by number through tea, kept quick: the PR itself in one call, then its CI from a one-row page of
191 * the full list. tea's list with CI fetches statuses for every row it prints, so the row's position is
192 * found first by paging through numbers alone, and remembered.
193 */
194async function teaPinnedPr($: EngineInterface, path: string, number: number, cwd: string): Promise<Pr | null | undefined> {
195 const shown = await run($, ['tea', 'pulls', String(number), '--repo', path, '--output', 'json'], cwd)
196 if (!shown) return undefined
197 if (shown.exitCode === 124) throw new Error(`tea: ${shown.stderr}`)
198 if (shown.exitCode !== 0) return null
199 const pull = JSON.parse(shown.stdout) as { index: number; title: string; url: string }
200 const row = (position: number) =>
201 run($, ['tea', 'pulls', 'list', '--repo', path, '--state', 'all', '--limit', '1', '--page', String(position), '--fields', 'index,ci', '--output', 'simple'], cwd)
202
203 let position = positions.get(`${path}#${number}`)
204 let ci = position === undefined ? undefined : await row(position)
205 // New PRs push older ones down the list: find the row again when it moved.
206 if (!ci || ci.exitCode !== 0 || teaChecks(ci.stdout, number) === undefined) {
207 position = undefined
208 for (let page = 1; page <= TEA_PAGES && position === undefined; page++) {
209 const numbers = await run($, ['tea', 'pulls', 'list', '--repo', path, '--state', 'all', '--limit', '50', '--page', String(page), '--fields', 'index', '--output', 'simple'], cwd)
210 if (numbers?.exitCode !== 0) break
211 const indexes = numbers.stdout.split('\n').map(line => Number(line.trim())).filter(Number.isFinite).filter(index => index > 0)
212 const at = indexes.indexOf(number)
213 if (at >= 0) position = (page - 1) * 50 + at + 1
214 else if (indexes.length === 0 || indexes[indexes.length - 1]! < number) break
215 }
216 if (position !== undefined) positions.set(`${path}#${number}`, position)
217 ci = position === undefined ? undefined : await row(position)
218 }
219 const checks = ci?.exitCode === 0 ? (teaChecks(ci.stdout, number) ?? []) : []
220
221 return { forge: 'gitea', number: pull.index, title: pull.title, url: pull.url, isDraft: /^(WIP|Draft)\b/i.test(pull.title), checks }
222}
223
224async function readGitea($: EngineInterface, { host, path, branch, cwd, number }: Target): Promise<Pr | null> {
225 const token = (await $.env.get('GITEA_TOKEN')) || (await $.env.get('FORGEJO_TOKEN')) || (await $.env.get('CODEBERG_TOKEN'))
226 if (!token) {
227 const fromTea = number === undefined ? await teaBranchPr($, path, branch, cwd) : await teaPinnedPr($, path, number, cwd)
228 if (fromTea !== undefined) return fromTea
229 }
230 const api = `https://${host}/api/v1/repos/${path}`
231 const headers: Record<string, string> = token ? { authorization: `token ${token}` } : {}
232 type Pull = Parameters<typeof fromGiteaRest>[0] & { head: { ref: string; sha: string } }
233 const pull =
234 number === undefined
235 ? (await getJson<Pull[]>($, `${api}/pulls?state=open&limit=50`, headers)).find(one => one.head.ref === branch)
236 : await getJson<Pull>($, `${api}/pulls/${number}`, headers)
237 if (!pull) return null
238 const combined = await getJson<Parameters<typeof fromGiteaRest>[1]>($, `${api}/commits/${pull.head.sha}/status`, headers)
239
240 return fromGiteaRest(pull, combined)
241}
242
243async function readBitbucket($: EngineInterface, { path, branch, number }: Target): Promise<Pr | null> {
244 const bearer = await $.env.get('BITBUCKET_TOKEN')
245 const user = await $.env.get('BITBUCKET_USERNAME')
246 const password = await $.env.get('BITBUCKET_APP_PASSWORD')
247 const headers: Record<string, string> = bearer ? { authorization: `Bearer ${bearer}` } : user && password ? { authorization: `Basic ${btoa(`${user}:${password}`)}` } : {}
248 const api = `https://api.bitbucket.org/2.0/repositories/${path}/pullrequests`
249 const query = encodeURIComponent(`source.branch.name="${branch}" AND state="OPEN"`)
250 const pull =
251 number === undefined
252 ? (await getJson<{ values?: Parameters<typeof fromBitbucket>[0][] }>($, `${api}?q=${query}`, headers)).values?.[0]
253 : await getJson<Parameters<typeof fromBitbucket>[0]>($, `${api}/${number}`, headers)
254 if (!pull) return null
255 const statuses = await getJson<Parameters<typeof fromBitbucket>[1]>($, `${api}/${pull.id}/statuses?pagelen=100`, headers)
256
257 return fromBitbucket(pull, statuses)
258}
259
260async function readAzure($: EngineInterface, { path, branch, number }: Target): Promise<Pr | null> {
261 const pat = (await $.env.get('AZURE_DEVOPS_EXT_PAT')) || (await $.env.get('AZURE_DEVOPS_TOKEN'))
262 if (!pat) throw new Error('set AZURE_DEVOPS_EXT_PAT to a personal access token with Code and Build read access')
263 const [org, project, repo] = path.split('/')
264 if (!org || !project || !repo) throw new Error(`cannot read org/project/repo from ${path}`)
265 const headers = { authorization: `Basic ${btoa(`:${pat}`)}` }
266 const base = `https://dev.azure.com/${org}/${project}/_apis`
267 const version = 'api-version=7.1'
268 const ref = encodeURIComponent(`refs/heads/${branch}`)
269 const pull =
270 number === undefined
271 ? (await getJson<{ value?: Parameters<typeof fromAzure>[0][] }>($, `${base}/git/repositories/${repo}/pullrequests?searchCriteria.sourceRefName=${ref}&searchCriteria.status=active&${version}`, headers)).value?.[0]
272 : await getJson<Parameters<typeof fromAzure>[0]>($, `${base}/git/repositories/${repo}/pullrequests/${number}?${version}`, headers)
273 if (!pull) return null
274 const id = pull.pullRequestId
275 const merge = encodeURIComponent(`refs/pull/${id}/merge`)
276 const [builds, statuses] = await Promise.all([
277 getJson<Parameters<typeof fromAzure>[2]>($, `${base}/build/builds?branchName=${merge}&queryOrder=queueTimeDescending&$top=50&${version}`, headers),
278 getJson<Parameters<typeof fromAzure>[3]>($, `${base}/git/repositories/${repo}/pullRequests/${id}/statuses?${version}`, headers),
279 ])
280
281 return fromAzure(pull, `https://dev.azure.com/${org}/${project}/_git/${repo}/pullrequest/${id}`, builds, statuses)
282}
283
284const ACTIVE_MS = 10_000
285const SETTLED_MS = 60_000
286const NO_PR_MS = 30_000
287const FORGES_KEY = 'forges'
288
289const EMPTY: View = { branch: null, forge: null, pinned: null, pr: null, error: null, checkedAt: 0 }
290const view = atom({ plugin: 'pr-status', key: 'view' } as const, EMPTY)
291const isHidden = atom({ plugin: 'pr-status', key: 'isHidden' } as const, false)
292const pinnedPr = atom({ plugin: 'pr-status', key: 'pinned' } as const, null as number | null)
293
294const FORGE_NAMES: Record<Forge, string> = { github: 'GitHub', gitlab: 'GitLab', gitea: 'Gitea', bitbucket: 'Bitbucket', azure: 'Azure DevOps' }
295
296const BADGES: { state: CheckState; glyph: string; label: string; color: string }[] = [
297 { state: 'failed', glyph: '✗', label: 'failed', color: 'error' },
298 { state: 'running', glyph: '⟳', label: 'running', color: 'warning' },
299 { state: 'queued', glyph: '○', label: 'queued', color: 'inactive' },
300 { state: 'warning', glyph: '⚠', label: 'warning', color: 'warning' },
301 { state: 'passed', glyph: '✓', label: 'passed', color: 'success' },
302 { state: 'cancelled', glyph: '⊘', label: 'cancelled', color: 'inactive' },
303 { state: 'skipped', glyph: '–', label: 'skipped', color: 'inactive' },
304]
305
306let timer: { cancel: () => void } | null = null
307let inFlight: Promise<void> | null = null
308/** The read queued behind the one in flight; every caller who asked meanwhile waits for it. */
309let queued: Promise<void> | null = null
310
311async function git($: EngineInterface, args: string[], cwd: string): Promise<string | null> {
312 const ran = await run($, ['git', ...args], cwd)
313
314 return ran?.exitCode === 0 ? ran.stdout.trim() : null
315}
316
317/** The forge for a host: remembered on this machine once detected. */
318async function forgeOf($: EngineInterface, host: string, cwd: string): Promise<Forge | undefined> {
319 const saved = ((await $.store.get(FORGES_KEY)) ?? {}) as Record<string, Forge>
320 if (saved[host]) return saved[host]
321 const forge = await detectForge($, host, cwd)
322 if (forge) await $.store.set(FORGES_KEY, { ...saved, [host]: forge })
323
324 return forge
325}
326
327async function readView($: EngineInterface): Promise<View> {
328 const cwd = await $.session.cwd()
329 const checkedAt = await $.clock.now()
330 const pinned = await read($, pinnedPr)
331 const branch = await git($, ['symbolic-ref', '--short', '-q', 'HEAD'], cwd)
332 // A pinned PR needs only the repository, so it works on a detached HEAD too.
333 if (!branch && pinned === null) return { ...EMPTY, checkedAt }
334 const remoteName = (branch && (await git($, ['config', `branch.${branch}.remote`], cwd))) || 'origin'
335 const merge = branch ? await git($, ['config', `branch.${branch}.merge`], cwd) : null
336 const remoteBranch = merge?.replace(/^refs\/heads\//, '') || branch || ''
337 const url = await git($, ['remote', 'get-url', remoteName === '.' ? 'origin' : remoteName], cwd)
338 const remote = url ? parseRemote(url) : null
339 if (!remote) return { ...EMPTY, branch, pinned, checkedAt }
340 const host = await resolveHost($, remote, cwd)
341 const forge = await forgeOf($, host, cwd)
342 if (!forge) return { ...EMPTY, branch, pinned, error: `could not tell which forge ${host} runs`, checkedAt }
343 try {
344 const pr = await readPr($, { forge, host, path: remote.path, branch: remoteBranch, cwd, number: pinned ?? undefined })
345 return { branch, forge, pinned, pr, error: null, checkedAt }
346 } catch (error) {
347 const previous = await read($, view)
348 // Keep showing the last known PR through a passing network error.
349 const isSame = previous.branch === branch && previous.pinned === pinned
350 return { branch, forge, pinned, pr: isSame ? previous.pr : null, error: error instanceof Error ? error.message : String(error), checkedAt }
351 }
352}
353
354function summary(checks: Check[]): string {
355 const counts = tally(checks)
356 const parts = BADGES.filter(badge => counts[badge.state] > 0).map(badge => `${counts[badge.state]} ${badge.label}`)
357
358 return parts.length > 0 ? parts.join(', ') : 'no checks yet'
359}
360
361function announce($: EngineInterface, before: View, after: View) {
362 const pr = after.pr
363 if (!pr) return
364 const old = before.pr
365 // Pinning or unpinning is the person's own doing: nothing to announce.
366 if (before.pinned !== after.pinned) return
367 if (!old || old.number !== pr.number || old.forge !== pr.forge) {
368 if (before.checkedAt > 0) $.ui.toast(`PR #${pr.number} opened: ${summary(pr.checks)}`)
369 return
370 }
371 if (isSettled(old) || !isSettled(pr) || pr.checks.length === 0) return
372 const failed = pr.checks.filter(check => check.state === 'failed')
373 $.ui.toast(
374 failed.length > 0
375 ? `PR #${pr.number}: CI failed (${failed.length} of ${pr.checks.length}): ${failed.map(check => check.name).slice(0, 3).join(', ')}`
376 : `PR #${pr.number}: CI passed (${pr.checks.length} checks)`,
377 )
378}
379
380/**
381 * What to suggest in the empty input bar while the PR has failing checks: fix them. Null when nothing failed.
382 * Check names drop their trigger suffix ("CI / unit (pull_request)" reads "CI / unit").
383 */
384export function fixSuggestion(current: View): string | null {
385 const pr = current.pr
386 const failed = (pr?.checks ?? []).filter(check => check.state === 'failed').map(check => check.name.replace(/\s*\((pull_request|push)[^)]*\)$/, ''))
387 if (!pr || failed.length === 0) return null
388 const names = failed.length <= 2 ? failed.map(name => `\`${name}\``).join(' and ') : `\`${failed[0]}\`, \`${failed[1]}\` and ${failed.length - 2} more`
389 return `Fix the failing ${names} check${failed.length > 1 ? 's' : ''} on PR #${pr.number}.`
390}
391
392/** The suggestion last put in the input bar, so a refresh with the same failures does not repeat it. */
393let suggested: string | null = null
394
395/** Puts the fix suggestion in the empty input bar when the failing checks change; Tab takes it. */
396function suggestFix($: EngineInterface, current: View) {
397 const text = fixSuggestion(current)
398 if (text === suggested) return
399 suggested = text
400 if (text) void $.prompt.suggest({ text }).catch(() => undefined)
401}
402
403function nextDelay(current: View): number {
404 if (!current.pr) return NO_PR_MS
405 if (!isSettled(current.pr) || current.pr.checks.length === 0) return ACTIVE_MS
406
407 return SETTLED_MS
408}
409
410/** Reads the PR now, then keeps reading it: every 10 s while checks run, every 30–60 s otherwise. */
411function refresh($: EngineInterface): Promise<void> {
412 if (inFlight) {
413 // Something changed while a read runs (a pin, a push): read again after it, and let the caller wait
414 // for that read rather than the one already under way.
415 queued ??= inFlight.catch(() => undefined).then(() => {
416 queued = null
417 return refresh($)
418 })
419 return queued
420 }
421 inFlight = (async () => {
422 timer?.cancel()
423 const before = await read($, view)
424 const after = await readView($)
425 await update($, view, () => after)
426 if (before.pr?.number !== after.pr?.number) await update($, isHidden, () => false)
427 announce($, before, after)
428 suggestFix($, after)
429 timer = $.clock.after(nextDelay(after), () => void refresh($))
430 })().finally(() => {
431 inFlight = null
432 })
433
434 return inFlight
435}
436
437async function openUrl($: EngineInterface, url: string) {
438 const cwd = await $.session.cwd()
439 const opened = await run($, ['open', url], cwd)
440 if (opened?.exitCode !== 0) await run($, ['xdg-open', url], cwd)
441}
442
443function describe(current: View): string {
444 if (!current.branch && current.pinned === null) return 'Not on a branch in a git repository.'
445 const forge = current.forge ? FORGE_NAMES[current.forge] : 'unknown forge'
446 const error = current.error ? `\nLast error: ${current.error}` : ''
447 const pr = current.pr
448 if (!pr && current.pinned !== null) return `Could not find PR #${current.pinned} on ${forge}.${error}`
449 if (!pr) return `No open PR for ${current.branch} on ${forge}.${error}`
450 const rows = BADGES.flatMap(badge =>
451 pr.checks.filter(check => check.state === badge.state).map(check => ` ${badge.glyph} ${check.name}${check.state === 'failed' && check.url ? ` ${check.url}` : ''}`),
452 )
453
454 const pin = current.pinned !== null ? ' (pinned; /pr-status branch to follow the branch again)' : ''
455 return [`${forge} PR #${pr.number}${pr.isDraft ? ' (draft)' : ''}: ${pr.title}${pin}`, pr.url, `CI: ${summary(pr.checks)}`, ...rows].join('\n') + error
456}
457
458export const register: Register = on => {
459 // After each turn the engine proposes its own next prompt; while checks fail, the fix is the better guess.
460 on('prompt.suggest', async ($, e, next) => {
461 const text = fixSuggestion(await read($, view))
462 return next(text ? { ...e, text } : e)
463 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
464
465 on('session.start', async ($, e, next) => {
466 // Not awaited: registering makes Claude Code re-describe every command (~0.7 s), which would hold startup.
467 // Typed as /pr-status in the terminal; the command file is what the desktop app lists in its menu.
468 void $.command.register({
469 name: 'pr-status',
470 description: "CI status of this branch's pull request, or of any PR you pin",
471 argumentHint: '[<pr number>|branch|refresh|show|hide|open]',
472 }).catch(() => undefined)
473 void refresh($)
474
475 return next(e)
476 })
477
478 on('command.run', { command: ['pr-status', 'pr-status:pr-status'] }, async ($, e) => {
479 const verb = e.args.trim()
480 // `/pr-status 123` (or `#123`) pins that PR; `/pr-status branch` follows the branch's PR again.
481 const asked = /^#?(\d+)$/.exec(verb)
482 if (asked || verb === 'branch') {
483 await update($, pinnedPr, () => (asked ? Number(asked[1]) : null))
484 await update($, isHidden, () => false)
485 await refresh($)
486 return { text: describe(await read($, view)) }
487 }
488 if (verb === 'hide' || verb === 'show') {
489 await update($, isHidden, () => verb === 'hide')
490 return { text: `Band ${verb === 'hide' ? 'hidden until the next PR' : 'shown'}.` }
491 }
492 if (verb === 'open') {
493 const pr = (await read($, view)).pr
494 if (pr) await openUrl($, pr.url)
495 return { text: pr ? `Opened ${pr.url}` : 'No PR to open.' }
496 }
497 await refresh($)
498
499 return { text: describe(await read($, view)) }
500 })
501
502 // A push or a new PR: look now, and again while the forge catches up.
503 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
504 const result = await next(e)
505 if (PR_ACTIVITY.test(e.command)) {
506 void refresh($)
507 $.clock.after(5_000, () => void refresh($))
508 }
509
510 return result
511 })
512
513 // The session moved to another worktree.
514 on('tool.call', { tool: 'EnterWorktree' }, async ($, e, next) => {
515 const result = await next(e)
516 void refresh($)
517 return result
518 })
519
520 on('tool.call', { tool: 'ExitWorktree' }, async ($, e, next) => {
521 const result = await next(e)
522 void refresh($)
523 return result
524 })
525
526 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
527 const current = await read($, view)
528 const pr = current.pr
529 if (!pr || e.props.hasSurvey || (await read($, isHidden))) return next(e)
530 const { Box, Button, Text } = $.ui.resolve(e)
531 const counts = tally(pr.checks)
532 const failed = pr.checks.filter(check => check.state === 'failed')
533 const below = await next(e)
534 const room = Math.max(10, e.props.bodyColumns - 40)
535 const title = pr.title.length > room ? `${pr.title.slice(0, room - 1)}…` : pr.title
536
537 return (
538 <Box flexDirection="column">
539 <Box flexDirection="row" gap={1}>
540 <Text color="suggestion" bold>{`PR #${pr.number}`}</Text>
541 {current.pinned !== null && <Text dimColor>pinned</Text>}
542 {pr.isDraft && <Text dimColor>draft</Text>}
543 {pr.checks.length === 0 && <Text dimColor>no checks yet</Text>}
544 {BADGES.filter(badge => counts[badge.state] > 0).map(badge => (
545 <Text color={badge.color}>{`${badge.glyph} ${counts[badge.state]} ${badge.label}`}</Text>
546 ))}
547 <Text dimColor wrap="truncate">{title}</Text>
548 <Button key="open" label="Open" onPress={() => openUrl($, pr.url)} />
549 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
550 </Box>
551 {failed.length > 0 && (
552 <Text color="error" wrap="truncate">{` failing: ${failed.map(check => check.name).join(', ')}`}</Text>
553 )}
554 {below}
555 </Box>
556 )
557 })
558}
559hooks/forges.ts 343 lines1// Pure parts: reading a git remote, naming its forge, and turning each forge's
2// answer into one list of checks.
3
4import type { Check, CheckState, Forge, Pr } from '../types'
5
6export type Remote = {
7 /** Host as written in the remote; an SSH alias until resolved. */
8 host: string
9 /** `owner/repo`, `group/sub/repo`, or `org/project/repo` on Azure DevOps. */
10 path: string
11 isSsh: boolean
12}
13
14/** Reads `git@host:a/b.git`, `ssh://git@host:2222/a/b.git`, `https://host/a/b`, and Azure DevOps' shapes. */
15export function parseRemote(url: string): Remote | null {
16 const text = url.trim()
17 let host: string
18 let path: string
19 let isSsh: boolean
20 const scp = text.match(/^(?:[^@/]+@)?([^:/]+):(?!\/)(.+)$/)
21 const full = text.match(/^(\w+(?:\+\w+)?):\/\/(?:[^@/]+@)?([^:/]+)(?::\d+)?\/(.+)$/)
22 if (full) {
23 host = full[2]!
24 path = full[3]!
25 isSsh = full[1]!.startsWith('ssh') || full[1]!.startsWith('git')
26 } else if (scp) {
27 host = scp[1]!
28 path = scp[2]!
29 isSsh = true
30 } else return null
31 path = path.replace(/\/+$/, '').replace(/\.git$/, '')
32 // Azure DevOps: ssh.dev.azure.com:v3/org/project/repo, dev.azure.com/org/project/_git/repo, org.visualstudio.com/project/_git/repo
33 if (host === 'ssh.dev.azure.com') path = path.replace(/^v3\//, '')
34 if (path.includes('/_git/')) path = path.replace('/_git/', '/')
35 const vs = host.match(/^([^.]+)\.visualstudio\.com$/)
36 if (vs) {
37 host = 'dev.azure.com'
38 path = `${vs[1]}/${path.replace(/^DefaultCollection\//, '')}`
39 }
40 if (host === 'ssh.dev.azure.com' || host === 'vs-ssh.visualstudio.com') host = 'dev.azure.com'
41 if (!path.includes('/')) return null
42
43 return { host, path, isSsh }
44}
45
46/** The forge a well-known host runs; undefined for one that must be asked. */
47export function knownForge(host: string): Forge | undefined {
48 const name = host.toLowerCase()
49 if (name === 'github.com' || name.endsWith('.ghe.com')) return 'github'
50 if (name === 'gitlab.com') return 'gitlab'
51 if (name === 'bitbucket.org') return 'bitbucket'
52 if (name === 'dev.azure.com') return 'azure'
53 if (name === 'codeberg.org' || name === 'gitea.com') return 'gitea'
54 if (/(^|\.)github\./.test(name)) return 'github'
55 if (/(^|\.)gitlab\./.test(name)) return 'gitlab'
56 if (/(^|\.)(gitea|forgejo)\./.test(name)) return 'gitea'
57
58 return undefined
59}
60
61/** `ssh -G alias` prints `hostname real.host`; SSH aliases like `github.com-work` resolve here. */
62export function sshHostname(output: string): string | undefined {
63 return output.match(/^hostname\s+(\S+)/m)?.[1]
64}
65
66/** What a probe of `https://host/<path>` says about the forge behind it. */
67export function forgeFromProbe(probe: { gitea?: Probe; gitlab?: Probe; github?: Probe }): Forge | undefined {
68 const isJson = (one?: Probe) => Boolean(one && one.status < 500 && /^\s*[{[]/.test(one.text))
69 if (isJson(probe.gitea) && (probe.gitea!.status === 200 || /signed in|token/i.test(probe.gitea!.text))) return 'gitea'
70 if (isJson(probe.gitlab) && (probe.gitlab!.status === 200 || probe.gitlab!.status === 401)) return 'gitlab'
71 if (isJson(probe.github) && probe.github!.status === 200) return 'github'
72
73 return undefined
74}
75
76export type Probe = { status: number; text: string }
77
78export function tally(checks: Check[]): Record<CheckState, number> {
79 const counts: Record<CheckState, number> = { passed: 0, failed: 0, running: 0, queued: 0, warning: 0, skipped: 0, cancelled: 0 }
80 for (const check of checks) counts[check.state] += 1
81
82 return counts
83}
84
85export function isSettled(pr: Pr): boolean {
86 return pr.checks.every(check => check.state !== 'running' && check.state !== 'queued')
87}
88
89/** The newest result per name, so a re-run replaces the run it repeats. */
90function latest(checks: (Check & { at?: string })[]): Check[] {
91 const byName = new Map<string, Check & { at?: string }>()
92 for (const check of checks) {
93 const seen = byName.get(check.name)
94 if (!seen || (check.at ?? '') >= (seen.at ?? '')) byName.set(check.name, check)
95 }
96
97 return [...byName.values()].map(({ at: _at, ...check }) => check)
98}
99
100// GitHub: `gh pr view --json statusCheckRollup`, or REST check runs and statuses.
101
102type GhRollup = {
103 __typename?: string
104 name?: string
105 context?: string
106 status?: string
107 conclusion?: string | null
108 state?: string
109 detailsUrl?: string
110 targetUrl?: string
111 workflowName?: string
112 startedAt?: string
113}
114
115export function githubRunState(status: string | undefined, conclusion: string | null | undefined): CheckState {
116 const done = (status ?? '').toUpperCase()
117 if (done && done !== 'COMPLETED') return done === 'IN_PROGRESS' ? 'running' : 'queued'
118 switch ((conclusion ?? '').toUpperCase()) {
119 case 'SUCCESS':
120 return 'passed'
121 case 'NEUTRAL':
122 case 'SKIPPED':
123 return 'skipped'
124 case 'CANCELLED':
125 case 'STALE':
126 return 'cancelled'
127 case '':
128 return 'queued'
129 default:
130 return 'failed'
131 }
132}
133
134export function githubStatusState(state: string | undefined): CheckState {
135 switch ((state ?? '').toUpperCase()) {
136 case 'SUCCESS':
137 return 'passed'
138 case 'PENDING':
139 return 'running'
140 case 'EXPECTED':
141 return 'queued'
142 default:
143 return 'failed'
144 }
145}
146
147export function fromGhView(json: string): Pr | null {
148 const view = JSON.parse(json) as {
149 number: number
150 title: string
151 url: string
152 state: string
153 isDraft: boolean
154 statusCheckRollup?: GhRollup[]
155 }
156 if (view.state !== 'OPEN') return null
157 const checks = (view.statusCheckRollup ?? []).map(item =>
158 item.__typename === 'StatusContext' || item.context
159 ? { name: item.context ?? 'status', state: githubStatusState(item.state), url: item.targetUrl }
160 : {
161 name: item.workflowName ? `${item.workflowName} / ${item.name}` : (item.name ?? 'check'),
162 state: githubRunState(item.status, item.conclusion),
163 url: item.detailsUrl,
164 at: item.startedAt,
165 },
166 )
167
168 return { forge: 'github', number: view.number, title: view.title, url: view.url, isDraft: view.isDraft, checks: latest(checks) }
169}
170
171export function fromGithubRest(
172 pull: { number: number; title: string; html_url: string; draft?: boolean },
173 runs: { check_runs?: { name: string; status: string; conclusion: string | null; html_url?: string; started_at?: string }[] },
174 statuses: { statuses?: { context: string; state: string; target_url?: string }[] },
175): Pr {
176 const checks = [
177 ...(runs.check_runs ?? []).map(run => ({ name: run.name, state: githubRunState(run.status, run.conclusion), url: run.html_url, at: run.started_at })),
178 ...(statuses.statuses ?? []).map(one => ({ name: one.context, state: githubStatusState(one.state), url: one.target_url })),
179 ]
180
181 return { forge: 'github', number: pull.number, title: pull.title, url: pull.html_url, isDraft: Boolean(pull.draft), checks: latest(checks) }
182}
183
184// Gitea and Forgejo: tea's `simple` output marks each status with a glyph; the REST API names it.
185
186const TEA_GLYPHS: Record<string, CheckState> = { '✓': 'passed', '⏳': 'running', '⚠': 'warning', '✘': 'failed', '❌': 'failed' }
187
188/** One PR's line of `tea pulls list --fields index,ci --output simple`: `842 ✓ CI / lint (pull_request), ❌ CI / unit (pull_request)`. */
189export function teaChecks(simple: string, index: number): Check[] | undefined {
190 const line = simple.split('\n').find(row => row.trim().startsWith(`${index} `) || row.trim() === `${index}`)
191 if (line === undefined) return undefined
192 const rest = line.trim().slice(String(index).length).trim()
193 if (rest === '') return []
194
195 // tea marks most checks with a symbol, but prints a check that has not started yet bare: queued.
196 return rest
197 .split(/,\s+/u)
198 .map(part => {
199 const glyph = [...part][0] ?? ''
200 const state = TEA_GLYPHS[glyph]
201 return { name: (state ? part.slice(glyph.length) : part).trim(), state: state ?? 'queued' }
202 })
203 .filter(check => check.name !== '')
204}
205
206export function giteaState(status: string): CheckState {
207 switch (status) {
208 case 'success':
209 return 'passed'
210 case 'pending':
211 return 'running'
212 case 'warning':
213 return 'warning'
214 case 'skipped':
215 return 'skipped'
216 case 'cancelled':
217 return 'cancelled'
218 default:
219 return 'failed'
220 }
221}
222
223export function fromGiteaRest(
224 pull: { number: number; title: string; html_url: string; draft?: boolean },
225 combined: { statuses?: { context: string; status: string; target_url?: string }[] | null },
226): Pr {
227 const checks = (combined.statuses ?? []).map(one => ({ name: one.context, state: giteaState(one.status), url: one.target_url || undefined }))
228
229 return { forge: 'gitea', number: pull.number, title: pull.title, url: pull.html_url, isDraft: Boolean(pull.draft) || /^(WIP|Draft)\b/i.test(pull.title), checks }
230}
231
232// GitLab: the merge request's head pipeline and its jobs.
233
234export function gitlabState(status: string, allowFailure = false): CheckState {
235 switch (status) {
236 case 'success':
237 return 'passed'
238 case 'failed':
239 return allowFailure ? 'warning' : 'failed'
240 case 'running':
241 return 'running'
242 case 'canceled':
243 case 'canceling':
244 return 'cancelled'
245 case 'skipped':
246 case 'manual':
247 return 'skipped'
248 default:
249 return 'queued'
250 }
251}
252
253export function fromGitlab(
254 mr: { iid: number; title: string; web_url: string; draft?: boolean; work_in_progress?: boolean },
255 jobs: { name: string; stage?: string; status: string; web_url?: string; allow_failure?: boolean }[],
256): Pr {
257 const checks = jobs.map(job => ({ name: job.stage ? `${job.stage} / ${job.name}` : job.name, state: gitlabState(job.status, job.allow_failure), url: job.web_url }))
258
259 return { forge: 'gitlab', number: mr.iid, title: mr.title, url: mr.web_url, isDraft: Boolean(mr.draft ?? mr.work_in_progress), checks }
260}
261
262// Bitbucket Cloud: commit statuses on the pull request.
263
264export function bitbucketState(state: string): CheckState {
265 switch (state) {
266 case 'SUCCESSFUL':
267 return 'passed'
268 case 'INPROGRESS':
269 return 'running'
270 case 'STOPPED':
271 return 'cancelled'
272 default:
273 return 'failed'
274 }
275}
276
277export function fromBitbucket(
278 pull: { id: number; title: string; draft?: boolean; links?: { html?: { href?: string } } },
279 statuses: { values?: { key: string; name?: string; state: string; url?: string; updated_on?: string }[] },
280): Pr {
281 const checks = (statuses.values ?? []).map(one => ({ name: one.name || one.key, state: bitbucketState(one.state), url: one.url, at: one.updated_on }))
282
283 return { forge: 'bitbucket', number: pull.id, title: pull.title, url: pull.links?.html?.href ?? '', isDraft: Boolean(pull.draft), checks: latest(checks) }
284}
285
286// Azure DevOps: builds of the PR's merge ref, plus statuses posted on the PR.
287
288export function azureBuildState(status: string, result?: string): CheckState {
289 if (status !== 'completed') return status === 'inProgress' || status === 'cancelling' ? 'running' : 'queued'
290 switch (result) {
291 case 'succeeded':
292 return 'passed'
293 case 'partiallySucceeded':
294 return 'warning'
295 case 'canceled':
296 return 'cancelled'
297 default:
298 return 'failed'
299 }
300}
301
302export function azureStatusState(state: string): CheckState {
303 switch (state) {
304 case 'succeeded':
305 return 'passed'
306 case 'pending':
307 return 'running'
308 case 'notApplicable':
309 return 'skipped'
310 case 'notSet':
311 return 'queued'
312 default:
313 return 'failed'
314 }
315}
316
317export function fromAzure(
318 pull: { pullRequestId: number; title: string; isDraft?: boolean },
319 webUrl: string,
320 builds: { value?: { definition?: { name?: string }; status: string; result?: string; queueTime?: string; _links?: { web?: { href?: string } } }[] },
321 statuses: { value?: { context?: { genre?: string; name?: string }; state: string; targetUrl?: string; creationDate?: string }[] },
322): Pr {
323 const checks = [
324 ...(builds.value ?? []).map(build => ({
325 name: build.definition?.name ?? 'build',
326 state: azureBuildState(build.status, build.result),
327 url: build._links?.web?.href,
328 at: build.queueTime,
329 })),
330 ...(statuses.value ?? []).map(one => ({
331 name: [one.context?.genre, one.context?.name].filter(Boolean).join('/') || 'status',
332 state: azureStatusState(one.state),
333 url: one.targetUrl,
334 at: one.creationDate,
335 })),
336 ]
337
338 return { forge: 'azure', number: pull.pullRequestId, title: pull.title, url: webUrl, isDraft: Boolean(pull.isDraft), checks: latest(checks) }
339}
340
341/** Shell commands after which a PR may have just been opened or its checks restarted. */
342export const PR_ACTIVITY = /\b(git\s+(push|checkout|switch|worktree)|gh\s+pr\s+(create|ready|reopen)|glab\s+mr\s+(create|update|reopen)|tea\s+(pr|pulls)\s+(create|c|reopen)|az\s+repos\s+pr\s+create|gh\s+run\s+rerun|glab\s+ci\s+(retry|run))\b/
343types/index.d.ts 44 lines1export type Forge = 'github' | 'gitlab' | 'gitea' | 'bitbucket' | 'azure'
2
3export type CheckState = 'passed' | 'failed' | 'running' | 'queued' | 'warning' | 'skipped' | 'cancelled'
4
5export type Check = {
6 name: string
7 state: CheckState
8 url?: string
9}
10
11export type Pr = {
12 forge: Forge
13 number: number
14 title: string
15 url: string
16 isDraft: boolean
17 checks: Check[]
18}
19
20export type View = {
21 /** The branch the session is on, as last read. */
22 branch: string | null
23 /** The forge the remote's host runs, once known. */
24 forge: Forge | null
25 /** The PR number `/pr-status <number>` pinned; null while following the branch. */
26 pinned: number | null
27 /** The PR shown: the pinned one, or the branch's open PR; null when there is none. */
28 pr: Pr | null
29 /** Why the last read failed, when it did. */
30 error: string | null
31 /** Milliseconds since the epoch of the last read. */
32 checkedAt: number
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 'pr-status': {
38 view: View
39 isHidden: boolean
40 pinned: number | null
41 }
42 }
43}
44