SLOPSHOPPER

cc-pr-tracker

Watch GitHub PRs from a Claude Code session: merge state, review and required checks above the prompt, with alerts when they change

newpanebandguardtoastprompt
A shopper browsing a rack in a slop shop
README

cc-pr-tracker

Watch GitHub pull requests without leaving your Claude Code session.

Paste a PR URL, or let Claude open one, and it gets one line above the prompt: merge state, review decision and required checks, refreshed every minute. When a check flips or the merge state moves you get a toast, a one-second flash and a sound. You keep working; the PR tells you when it needs you.

Pasting three PR URLs; each becomes a line above the prompt, then the details panel opens for one of them

Three watched PRs above the prompt: one clean and approved, one approved with checks still running, one with a failing required check

One PR is always one line; the details panel names the checks. Reading the line explains each part.

The plugin is built on Claude Code function hooks: TypeScript that runs inside Claude Code's own process, instead of shell-command hooks. They are in early access, behind an environment variable that the quick start sets.

Requirements

  • A Claude Code build with function hooks (tested on 2.1.269), with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 set. The quick start shows where.
  • GitHub CLI (gh), logged in with access to the repos you watch. Check with gh auth status.
  • Optional: macOS for sounds (afplay with the system sounds) and open. On Linux, xdg-open is tried when open is absent, and there is no sound.
  • Optional: cmux for pane flashes and workspace notifications. Outside cmux an alert is the toast, the strip and the sound.

Quick start

  1. Turn function hooks on. Add this to ~/.claude/settings.json (create the file if it does not exist, or merge the env key into what is there). Without it the plugin installs fine but does nothing.
   {
     "env": {
       "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
     }
   }

This also loads the hooks module of any other installed plugin that ships one. For a single session instead, prefix the command: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.

  1. Install from GitHub. The repo is its own marketplace:
   claude plugin marketplace add sezaakgun/cc-pr-tracker
   claude plugin install cc-pr-tracker@cc-pr-tracker
  1. Start claude, paste a PR URL as the whole prompt and press Enter. No model turn runs; Claude Code shows Prompt dropped by a hook: watching owner/repo#N. Several URLs at once, separated by spaces or newlines, are all watched.

Pasting a PR URL starts watching it

  1. The line appears above the prompt and fills in within a few seconds.
  1. Paste the same URL again to stop watching.

If the line shows gh failed: … instead, see Troubleshooting.

To try it without installing, or to hack on it, clone and load it for one session:

git clone https://github.com/sezaakgun/cc-pr-tracker
cd cc-pr-tracker
claude --plugin-dir .

The repo's own .claude/settings.json sets the variable, so sessions started inside the folder need no prefix.

Use

  • Watch a PR: paste its URL as the whole prompt, several at once if you like. Or mention URLs in a normal prompt: the prompt runs as usual and the PRs are watched too.
  • Watch a PR Claude creates or talks about: nothing to do. Any open PR whose URL appears in Claude's answer is watched, and so is the URL gh pr create prints when it runs through the Bash tool. Subagent answers are not scanned.
  • Open a PR: Cmd+click its repo#number (needs a terminal that renders hyperlinks), or hover the line and press open.
  • See every check: hover the line and press details. A side panel lists every required check and every failing optional one, linked to its run when the run has an https link.

The details panel open beside the session, listing every required check with the failing one marked

  • Silence a PR: hover the line and press mute. The line keeps updating but that PR no longer toasts, flashes or plays a sound; the line ends in · muted. Press unmute to turn alerts back on.
  • Silence every PR: open /config and turn on Mute all PR alerts. It applies at once, is saved across sessions, and every line ends in · muted while it is on. Turn it off to get alerts back; PRs you muted one by one stay muted.
  • Stop watching: hover the line and press ×, or paste the same URL again as the whole prompt. A paste of several URLs toggles each one; a URL inside a normal prompt never stops anything.

Several PRs stack, one line each, in the order you added them.

Reading the line

  • Label: repo#number, then draft, merged or closed when the PR is not simply open. A merged or closed PR keeps its line until you stop it.
  • Merge state is GitHub's own value, lowercased. Green (clean, has_hooks) means mergeable now. Yellow (behind, unstable) means update the branch or an optional check failed. Red (blocked, dirty) means a required check or review is missing, or there are conflicts. Gray (draft, unknown) needs no action; unknown usually resolves on the next poll.
  • Review decision is approved in green, changes requested in red, review required in yellow, or no review in gray when the repo has no review rules.
  • Checks count only the required ones: ✓N passed, ✗N failed or cancelled, ●N pending. Skipped checks are not counted and show as ○ only in the details panel. Failing optional checks are summarised as (+N optional ✗) and listed there too.
  • · refresh failed in red at the end means the last poll errored and the line shows the previous values.

Alerts

Every poll is compared with the previous one. A required check changing bucket (for example pending → fail, or a new check appearing) or the merge state moving (for example blocked → clean) triggers:

  • a toast in the session, for example my-service#42 lint: pending → fail
  • a one-second white strip above the prompt reading ● PR checks changed
  • a sound on macOS: Basso when a required check just failed, Glass for any other change, including a cancelled check
  • inside cmux: a flash of the session's own pane and a notification that marks its workspace unread, so the change reaches you from another workspace

Two things never alert: the first load of a PR, and a move into or out of GitHub's temporary unknown merge state.

Troubleshooting

  • Pasting a URL just sends it to the model. Function hooks are off. Check CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is set in ~/.claude/settings.json under env (see Quick start), and that the plugin is loaded with /plugins.
  • gh failed: … on the line. Run gh pr view <url> in a terminal. Usually gh is not logged in or has no access to that repo.
  • Required checks: none reported in details. The repo has no branch protection with required checks. The line still shows the merge state and review.
  • No sound. Only macOS with /System/Library/Sounds present plays sounds.
  • Hover buttons never appear. Your terminal does not report the mouse. Cmd+click and pasting the URL again still work.
  • Cmd+click does nothing. Your terminal does not render hyperlinks. Hover the line and press open.
  • Watched PRs disappeared. The session restarted or the plugin hot-reloaded; watched PRs live in memory (see Limits). Paste the URLs again.

Limits

  • Polling is every 60 seconds through gh, one GraphQL call per PR per poll.
  • Watched PRs live in memory. Restarting the session, or a plugin reload after its files change, forgets them.
  • The area above the prompt has a limited number of rows, about half the terminal. Very many PRs will scroll.
  • A headless claude -p run never draws. Only interactive terminal sessions show the UI.
  • A PR created in the browser or from another terminal must be pasted. Claude only auto-watches PRs whose URL appears in its answer or in gh pr create output.
  • Merged and closed PRs keep polling until you stop them.
  • Only github.com URLs are recognised; GitHub Enterprise hosts are not.
  • GitHub's rate limit is not handled specially; a refused poll shows refresh failed and the next one retries.

How it works

The plugin is one hooks module, hooks/register.tsx. It hooks five events:

  • prompt.submit reads PR URLs from your prompt and starts or stops watching.
  • turn.complete reads PR URLs from Claude's final answer, and tool.call on Bash reads the URL gh pr create prints.
  • ui.render on AbovePrompt draws the lines; on Pane it draws the details panel.
  • session.start sets up a 60-second timer that polls every watched PR.

Each poll is one read-only GraphQL call through gh api graphql: the PR's title, state, merge state and review decision, plus every check on its head commit with GitHub's own isRequired flag. The plugin maps check states to the same pass / fail / pending / cancel / skipping buckets that gh pr checks uses. A failed poll keeps the previous values and marks the line refresh failed, so a network blip is not reported as a change. Every call has a 30-second timeout.

Develop

bun test                              # unit tests for the pure helpers
claude plugin validate .claude-plugin/plugin.json   # lists the hooked events and $ calls
claude plugin validate .                            # checks the marketplace manifest

For type checking, run the built-in /plugin-types command inside this folder in a Claude Code session. It writes .claude/types/, which is git-ignored; tsc fails with a missing claude-code module until it exists. Then:

bunx -p typescript tsc -p .

Edits hot-reload into a running session. If a reload fails partway, the transcript says so; restart the session.

License

MIT. See LICENSE.

Source 1 files
hooks/register.tsx 311 lines
1/* @jsx h */
2import type { Register } from 'claude-code'
3
4// A GitHub PR URL in a prompt adds a line above the prompt that polls gh for the merge state and
5// the required checks; a prompt that is only the URL toggles it without a model turn. A PR URL in
6// Claude's answer, or printed by `gh pr create`, is added too. When checks or the merge state change, it toasts,
7// flashes, plays a sound and, inside cmux, flashes the session's pane and posts a notification.
8
9const PR_URL = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/
10// a prompt that is nothing but PR URLs (one or more, any whitespace) toggles them without a model turn
11export const ONLY_URLS = new RegExp(`^\\s*(${PR_URL.source}\\S*\\s*)+$`)
12// fixed 1 min poll, one GraphQL call per PR; a webhook if rate limits bite
13const POLL_MS = 60_000
14// macOS system sounds, played with afplay when present; silent elsewhere
15const SOUND_CHANGE = '/System/Library/Sounds/Glass.aiff'
16const SOUND_FAIL = '/System/Library/Sounds/Basso.aiff'
17// the /config toggle from plugin.json's userConfig; silences every PR while on
18const MUTE_ALL = 'cc-pr-tracker.muteAll'
19
20type Check = { name: string; bucket: string; link: string }
21type View = { number: number; title: string; state: string; isDraft: boolean; mergeable: string; mergeStateStatus: string; reviewDecision: string }
22type Context = { __typename: string; isRequired: boolean; name?: string; status?: string | null; conclusion?: string | null; detailsUrl?: string; context?: string; state?: string; targetUrl?: string }
23
24// one call replaces `gh pr view` plus `gh pr checks`; isRequired is per PR
25const QUERY = `query($o: String!, $r: String!, $n: Int!) {
26  repository(owner: $o, name: $r) { pullRequest(number: $n) {
27    number title state isDraft mergeable mergeStateStatus reviewDecision
28    commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes {
29      __typename
30      ... on CheckRun { name status conclusion detailsUrl isRequired(pullRequestNumber: $n) }
31      ... on StatusContext { context state targetUrl isRequired(pullRequestNumber: $n) }
32    } } } } } }
33  } }
34}`
35
36// the same buckets `gh pr checks` derives: a check run is pending until COMPLETED, then its
37// conclusion decides; a commit status has only a state
38// GitHub text goes to the terminal as-is, so control characters are dropped first
39const clean = (s: string) => s.replace(/[\x00-\x1f\x7f]/g, '')
40export function toCheck(c: Context): Check {
41  if (c.__typename === 'StatusContext') {
42    const bucket = c.state === 'SUCCESS' ? 'pass' : c.state === 'PENDING' || c.state === 'EXPECTED' ? 'pending' : 'fail'
43    return { name: clean(c.context ?? ''), bucket, link: c.targetUrl ?? '' }
44  }
45  const bucket = c.status !== 'COMPLETED' ? 'pending'
46    : c.conclusion === 'SUCCESS' || c.conclusion === 'NEUTRAL' ? 'pass'
47    : c.conclusion === 'SKIPPED' ? 'skipping'
48    : c.conclusion === 'CANCELLED' ? 'cancel' : 'fail'
49  return { name: clean(c.name ?? ''), bucket, link: c.detailsUrl ?? '' }
50}
51type Pr = { url: string; id: string; label: string; pane: string; auto?: boolean; muted?: boolean; view?: View; required: Check[]; others: Check[]; updated?: number; error?: string; busy?: boolean }
52
53const ICON: Record<string, [string, string]> = { pass: ['✓', 'green'], fail: ['✗', 'red'], pending: ['●', 'yellow'], skipping: ['○', 'gray'], cancel: ['⊘', 'red'] }
54const MERGE: Record<string, string> = { CLEAN: 'green', HAS_HOOKS: 'green', UNSTABLE: 'yellow', BEHIND: 'yellow', BLOCKED: 'red', DIRTY: 'red', DRAFT: 'gray', UNKNOWN: 'gray' }
55const REVIEW: Record<string, string> = { APPROVED: 'green', CHANGES_REQUESTED: 'red', REVIEW_REQUIRED: 'yellow' }
56
57// a Link refuses the whole tree unless its href is canonical and https: (or http://localhost);
58// a plain-http status link draws as text
59export const linkable = (href: string) => {
60  try {
61    const u = new URL(href)
62    return u.href === href && (u.protocol === 'https:' || (u.protocol === 'http:' && u.hostname === 'localhost'))
63  } catch { return false }
64}
65
66// what changed between two polls; nothing on the first load, and GitHub's lazy UNKNOWN merge
67// state is not a change worth an alert
68export function prChanges(prevMerge: string | undefined, prevBuckets: Map<string, string>, merge: string, required: Check[]): string[] {
69  if (prevMerge === undefined) return []
70  const out = required.filter(c => prevBuckets.get(c.name) !== c.bucket).map(c => `${c.name}: ${prevBuckets.get(c.name) ?? 'new'} → ${c.bucket}`)
71  if (prevMerge !== merge && prevMerge !== 'UNKNOWN' && merge !== 'UNKNOWN') out.unshift(`merge: ${prevMerge.toLowerCase()} → ${merge.toLowerCase()}`)
72  return out
73}
74
75let flashing = false
76let muteAll = false
77let poll: { cancel(): void } | undefined
78const prs = new Map<string, Pr>()
79// built in session.start, where $ is in hand; later hooks call them
80let refresh: ((pr: Pr) => Promise<void>) | undefined
81let stop: ((pr: Pr) => void) | undefined
82let watch: ((m: RegExpExecArray, auto?: boolean) => void) | undefined
83let openUrl: ((url: string) => void) | undefined
84
85export const register: Register = on => {
86  on('session.start', async ($, e, next) => {
87    const r = await next(e)
88    const log = (text: string) => $.ui.log(`cc-pr-tracker: ${text}`)
89
90    // inside cmux the session's own pane is in the environment: cmux's CLI flashes that pane and
91    // posts notifications that mark its workspace unread, even while you are in another workspace
92    const cmuxBin = await $.env.get('CMUX_BUNDLED_CLI_PATH')
93    const surfaceId = await $.env.get('CMUX_SURFACE_ID')
94    const cmux = (args: string[]) => {
95      if (!cmuxBin || !surfaceId) return
96      $.process.run([cmuxBin, ...args, '--surface', surfaceId])
97        .then(res => { if (res.exitCode) log(`cmux ${args[0]} failed: ${res.stderr.trim()}`) })
98        .catch(err => log(`cmux ${args[0]} failed: ${err}`))
99    }
100    const hasSounds = await $.fs.exists(SOUND_CHANGE)
101    muteAll = (await $.config.list()).find(row => row.key === MUTE_ALL)?.value === true
102    const alert = (sound: string) => {
103      flashing = true
104      $.ui.invalidate('ui.render')
105      cmux(['trigger-flash'])
106      if (hasSounds) $.process.run(['afplay', sound]).catch(err => log(`afplay failed: ${err}`))
107      $.clock.after(1000, () => {
108        flashing = false
109        $.ui.invalidate('ui.render')
110      })
111    }
112    // `open` on macOS, `xdg-open` elsewhere
113    openUrl = url => {
114      $.process.run(['open', url])
115        .catch(() => $.process.run(['xdg-open', url]))
116        .catch(err => log(`could not open ${url}: ${err}`))
117    }
118
119    refresh = async pr => {
120      if (pr.busy) return
121      pr.busy = true
122      try {
123        const [, owner, repo, num] = PR_URL.exec(pr.url)!
124        const { stdout, stderr, exitCode } = await $.process.run(
125          // -f keeps owner and repo as strings (a repo named 2048 would otherwise be sent as a number)
126          ['gh', 'api', 'graphql', '-f', `query=${QUERY}`, '-f', `o=${owner}`, '-f', `r=${repo}`, '-F', `n=${num}`], { timeoutMs: 30_000 })
127        if (exitCode) throw new Error(stderr.trim() || `gh exited ${exitCode}`)
128        const found = JSON.parse(stdout).data?.repository?.pullRequest
129        if (!found) throw new Error('PR not found')
130        const { number, commits, ...view } = found
131        const contexts: Context[] = commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []
132        const prevMerge = pr.view?.mergeStateStatus
133        const prevBuckets = new Map(pr.required.map(c => [c.name, c.bucket]))
134        const v: View = { number, ...view, title: clean(view.title) }
135        // a PR Claude only mentioned that is already merged or closed is not worth a line
136        if (pr.auto && prevMerge === undefined && v.state !== 'OPEN') { stop?.(pr); return }
137        if (!prs.has(pr.id)) return
138        pr.view = v
139        pr.required = contexts.filter(c => c.isRequired).map(toCheck)
140        pr.others = contexts.filter(c => !c.isRequired).map(toCheck)
141        pr.error = undefined
142        const changes = prChanges(prevMerge, prevBuckets, v.mergeStateStatus, pr.required)
143        // a muted PR (or every PR, under muteAll) still updates its line, it just never alerts
144        if (changes.length && !pr.muted && !muteAll) {
145          const failed = pr.required.some(c => c.bucket === 'fail' && prevBuckets.get(c.name) !== 'fail')
146          $.ui.toast(`${pr.label} ${changes.join(' · ')}`, { timeoutMs: 8000 })
147          alert(failed ? SOUND_FAIL : SOUND_CHANGE)
148          cmux(['notify', '--title', `${pr.label}: ${failed ? 'a required check failed' : 'checks changed'}`, '--body', changes.join(' · ')])
149        }
150      } catch (err) {
151        pr.error = err instanceof Error ? err.message : String(err)
152      } finally {
153        pr.busy = false
154        pr.updated = $.clock.now()
155        $.ui.invalidate('ui.render')
156      }
157    }
158    poll?.cancel()
159    poll = $.clock.every(POLL_MS, () => { for (const pr of prs.values()) refresh?.(pr) })
160
161    stop = pr => {
162      prs.delete(pr.id)
163      $.ui.close({ id: pr.pane })
164      $.ui.invalidate('ui.render')
165    }
166    watch = ([url, owner, repo, num], auto = false) => {
167      const id = `${owner}/${repo}#${num}`
168      if (prs.has(id)) return
169      const pane = `pr-${repo}-${num}`.replace(/[^\w-]/g, '_').slice(0, 64)
170      const pr: Pr = { url, id, label: `${repo}#${num}`, pane, auto, required: [], others: [] }
171      prs.set(id, pr)
172      refresh?.(pr)
173      $.ui.invalidate('ui.render')
174    }
175    return r
176  })
177
178  // flipping the toggle in /config takes effect at once
179  on('config.set', { key: MUTE_ALL }, async ($, e, next) => {
180    const r = await next(e)
181    if (!r.deny) { muteAll = r.value === true; $.ui.invalidate('ui.render') }
182    return r
183  })
184
185  on('prompt.submit', async ($, e, next) => {
186    const seen = new Set<string>()
187    const found = [...e.text.matchAll(new RegExp(PR_URL.source, 'g'))].filter(m => !seen.has(m[0]) && seen.add(m[0]))
188    if (!found.length) return next(e)
189    // a prompt that is only PR URLs (one or several) toggles them without a model turn
190    if (!ONLY_URLS.test(e.text)) { found.forEach(m => watch?.(m)); return next(e) }
191    const done: string[] = []
192    for (const m of found) {
193      const id = `${m[1]}/${m[2]}#${m[3]}`
194      const pr = prs.get(id)
195      if (pr) { stop?.(pr); done.push(`stopped watching ${id}`) } else { watch?.(m); done.push(`watching ${id}`) }
196    }
197    return { drop: done.join(', ') }
198  })
199
200  // a PR Claude opens in this session is watched too: `gh pr create` prints its URL on stdout
201  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
202    const r = await next(e)
203    const command = (e as { command?: unknown }).command
204    if ('deny' in r || r.isError || typeof command !== 'string' || !/\bgh\s+pr\s+create\b/.test(command)) return r
205    const m = PR_URL.exec((r.result as { stdout?: string } | undefined)?.stdout ?? '')
206    if (m) watch?.(m, true)
207    return r
208  })
209
210  // a PR Claude mentions in its answer (one it opened through any tool, or one it was asked about)
211  // is watched too if it is open; subagent turns are skipped
212  on('turn.complete', async ($, e, next) => {
213    const r = await next(e)
214    if (!e.agentId) for (const m of e.answer.matchAll(new RegExp(PR_URL.source, 'g'))) watch?.(m, true)
215    return r
216  })
217
218  // above the prompt: a one-second strip on a change, then one line per watched PR; the failing
219  // checks are named in the details pane
220  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
221    if (e.props.hasSurvey || (!flashing && !prs.size)) return next(e)
222    const { Box, Text, Link, Button } = await $.ui.resolve(e)
223    const cols = e.viewport?.columns ?? 80
224    return (
225      <Box flexDirection="column">
226        {flashing ? (
227          <Box backgroundColor="white" width={cols}>
228            <Text color="black" backgroundColor="white" bold> ● PR checks changed</Text>
229          </Box>
230        ) : <Box />}
231        {[...prs.values()].map(pr => {
232          const v = pr.view
233          if (!v) return <Text dimColor wrap="truncate-end">{`${pr.label} ${pr.error ? `gh failed: ${pr.error}` : 'loading…'}`}</Text>
234          const n = (b: string) => pr.required.filter(c => c.bucket === b).length
235          const state = v.isDraft && v.state === 'OPEN' ? 'draft' : v.state.toLowerCase()
236          const otherFails = pr.others.filter(c => c.bucket === 'fail').length
237          // the label is a Link (cmd+click opens the PR); the rest is one Text, truncated, title
238          // last so it is what the width cuts; hovering the row reveals its buttons (no hotkeys:
239          // a band hotkey would press on a digit typed as the first character of a prompt)
240          return (
241            <Box key={`row:${pr.pane}`} flexDirection="row">
242              <Box flexShrink={0}><Link href={pr.url} label={pr.label} /></Box>
243              <Text wrap="truncate-end">
244                <Text dimColor>{state === 'open' ? ' ' : ` ${state} · `}</Text>
245                <Text color={MERGE[v.mergeStateStatus] ?? 'gray'}>{v.mergeStateStatus.toLowerCase()}</Text>
246                <Text dimColor> · </Text>
247                <Text color={REVIEW[v.reviewDecision] ?? 'gray'}>{(v.reviewDecision || 'no review').toLowerCase().replace(/_/g, ' ')}</Text>
248                <Text dimColor> · </Text>
249                <Text color="green">{`✓${n('pass')}`}</Text>
250                <Text color="red">{n('fail') + n('cancel') ? ` ✗${n('fail') + n('cancel')}` : ''}</Text>
251                <Text color="yellow">{n('pending') ? ` ●${n('pending')}` : ''}</Text>
252                <Text color="red">{otherFails ? ` (+${otherFails} optional ✗)` : ''}</Text>
253                <Text color="red">{pr.error ? ' · refresh failed' : ''}</Text>
254                <Text dimColor>{pr.muted || muteAll ? ' · muted' : ''}</Text>
255                <Text dimColor>{` · ${v.title}`}</Text>
256              </Text>
257              <Box display="none" hover={{ display: 'flex' }} flexShrink={0} flexDirection="row">
258                <Button key={`open:${pr.pane}`} label="open" onPress={() => openUrl?.(pr.url)} />
259                <Button key={`mute:${pr.pane}`} label={pr.muted ? 'unmute' : 'mute'} onPress={() => { pr.muted = !pr.muted; $.ui.invalidate('ui.render') }} />
260                <Button key={`details:${pr.pane}`} label="details" onPress={() => { $.ui.open({ id: pr.pane, title: pr.id, focus: true }) }} />
261                <Button key={`stop:${pr.pane}`} label="×" onPress={() => stop?.(pr)} />
262              </Box>
263            </Box>
264          )
265        })}
266        {await next(e)}
267      </Box>
268    )
269  })
270
271  // one PR's details, opened from its row's "details" button: every required check and any
272  // failing optional one, each linked to its run
273  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
274    const pr = [...prs.values()].find(p => p.pane === e.requestId)
275    if (!pr) return next(e)
276    const { Box, Text, Link, Button } = await $.ui.resolve(e)
277    const v = pr.view
278    if (!v) return <Text dimColor>{pr.error ? `gh failed: ${pr.error}` : 'loading…'}</Text>
279    const state = v.isDraft && v.state === 'OPEN' ? 'draft' : v.state.toLowerCase()
280    const optionalFails = pr.others.filter(c => c.bucket === 'fail')
281    return (
282      <Box flexDirection="column">
283        <Text bold wrap="truncate-end">{v.title}</Text>
284        <Text>
285          <Text color={state === 'open' ? 'green' : state === 'merged' ? 'magenta' : 'gray'}>{state}</Text>
286          <Text dimColor> · merge </Text>
287          <Text color={MERGE[v.mergeStateStatus] ?? 'gray'}>{v.mergeStateStatus.toLowerCase()}</Text>
288          <Text dimColor>{` (${v.mergeable.toLowerCase()}) · `}</Text>
289          <Text color={REVIEW[v.reviewDecision] ?? 'gray'}>{(v.reviewDecision || 'no review decision').toLowerCase().replace(/_/g, ' ')}</Text>
290        </Text>
291        <Box flexDirection="row">
292          <Button key={`pane-open:${pr.pane}`} label="open in browser" onPress={() => openUrl?.(pr.url)} />
293          <Text> </Text>
294          <Button key={`pane-stop:${pr.pane}`} label="stop watching" onPress={() => stop?.(pr)} />
295        </Box>
296        <Text bold>{pr.required.length ? `Required checks (${pr.required.length})` : 'Required checks: none reported'}</Text>
297        {[...pr.required, ...optionalFails].map((c, i) => (
298          <Box key={c.link || c.name} flexDirection="row">
299            <Box flexShrink={0}>
300              <Text color={ICON[c.bucket]?.[1] ?? 'gray'}>{`${ICON[c.bucket]?.[0] ?? '?'} `}</Text>
301            </Box>
302            {linkable(c.link) ? <Link href={c.link} label={c.name} /> : <Text wrap="truncate-end">{c.name}</Text>}
303            <Text dimColor>{i >= pr.required.length ? ' (optional)' : ''}</Text>
304          </Box>
305        ))}
306        <Text dimColor>{`+ ${pr.others.length} optional checks · updated ${new Date(pr.updated ?? 0).toTimeString().slice(0, 8)}${pr.error ? ` · refresh failed: ${pr.error}` : ''}`}</Text>
307      </Box>
308    )
309  })
310}
311