SLOPSHOPPER

pr-status

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…

newbandguardcommandtoastprompt
★ 3v1.0.0MITupdated 2026-10-15omeerdvrn/o-mods/mods/pr-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-status
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pr-status ⎿ pr-status: No open PR for feat/auth-refresh on GitHub. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

pr-status

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:

A band above the prompt shows a pull request's checks running, then one failing. After Claude fixes it, all five pass and a toast says so.

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.

Forges

The mod reads the branch's upstream remote (or origin) and detects the forge on its own:

ForgeHow it reads the PRCredentials
GitHub, GitHub Enterprisegh pr view, or the REST APIgh auth login, or GH_TOKEN/GITHUB_TOKEN
GitLab, self-hosted GitLabglab api, or the REST API: the MR's head pipeline jobsglab auth login, or GITLAB_TOKEN/GL_TOKEN
Gitea, Forgejo, Codebergtea pulls list, or the REST API: commit statuses (Gitea Actions report here)tea login add, or GITEA_TOKEN/FORGEJO_TOKEN/CODEBERG_TOKEN
Bitbucket CloudREST API: the PR's statusesBITBUCKET_TOKEN, or BITBUCKET_USERNAME + BITBUCKET_APP_PASSWORD; public repos need none
Azure DevOpsREST API: builds of the PR's merge ref and PR statusesAZURE_DEVOPS_EXT_PAT

Detection, in order:

  1. An SSH alias such as github.com-work resolves to its real host through ssh -G.
  2. Well-known hosts: github.com, gitlab.com, bitbucket.org, dev.azure.com, *.visualstudio.com, codeberg.org, and hosts named github.*, gitlab.*, gitea.* or forgejo.*.
  3. A host that tea, glab or gh is signed in to.
  4. A probe of the host's API: /api/v1/version (Gitea, Forgejo), /api/v4/version (GitLab), /api/v3/meta (GitHub Enterprise).

Each host's result is remembered on this machine.

Check states

GlyphStateIncludes
✓passedsuccess
✗failedfailure, error, timed out, action required
⟳runningin progress, pending
○queuedqueued, waiting, created, scheduled
⚠warningGitea warning, GitLab allowed failure, Azure partially succeeded
⊘cancelledcancelled, stopped, stale
–skippedskipped, neutral, manual

When a check is re-run, only its newest result is counted.

When it updates

  • Every 10 seconds while any check is running or queued.
  • Every 60 seconds once all checks have settled.
  • Every 30 seconds while the branch has no PR, so the band appears shortly after a PR is opened from anywhere.
  • Right away after Claude runs 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.

Command

/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

Develop

claude plugin validate mods/pr-status
claude plugin test mods/pr-status
Source 3 files
hooks/register.tsx 559 lines
1import { 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}
559
hooks/forges.ts 343 lines
1// 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/
343
types/index.d.ts 44 lines
1export 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