A pane with the PRs this session created and the open PRs of this repo, with live CI checks; a failed check log goes into the prompt with one key

My personal dotfiles managed with YADM.
Tested on Ubuntu 25.10 and Ubuntu 25.04.
yadm clone <your-repo-url>
yadm config local.class work # or personal
yadm config yadm.alt-copy true
yadm alt
yadm bootstrap
SUPER + Return - TerminalSUPER + T - Terminal with tmuxSUPER + Space/D - Application launcherhooks/register.tsx 160 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PrCheck, PrSource, TrackedPr } from '../types'
5import { GROUPS, ICONS, PR_FIELDS, buildFailureText, buildLogArgv, describePrChanges, findPrUrl, parsePrUrls, parsePrView, resolvePrBucket } from '../core/github'
6
7const PANE = 'pr-ci'
8const REFRESH_MS = 60_000
9const REPO_PR_LIMIT = 20
10const HOTKEYS = 'fghjklmnpqrstuvwxyz'
11const prs = atom({ plugin: 'pr-ci', key: 'tracked' } as const, [])
12
13let isRefreshing = false
14
15export const register: Register = (on, options) => {
16 const repoPrAuthor = String(options.repoPrAuthor ?? '@me')
17
18 on('session.start', async ($, e, next) => {
19 await $.command.register({
20 name: 'prs',
21 description: 'Show the PRs this session created and the open PRs of this repo, with CI checks',
22 argumentHint: '[pr url]',
23 })
24 $.clock.after(1_000, () => void refresh($, repoPrAuthor))
25 $.clock.every(REFRESH_MS, () => void refresh($, repoPrAuthor))
26
27 return next(e)
28 })
29
30 on('command.run', { command: 'prs' }, async ($, e) => {
31 const url = findPrUrl(e.args)
32 if (url !== undefined) await trackPr($, url, 'session')
33 await refresh($, repoPrAuthor)
34 if ((await read($, prs)).length === 0) return { text: 'No PRs are tracked. Open one with gh, or run /prs <url>.' }
35
36 await $.ui.open({ id: PANE, title: 'PRs', focus: true })
37
38 return { text: 'PR pane opened.' }
39 })
40
41 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
42 const ran = await next(e)
43 if (!/\bgh\s+pr\s+create\b/.test(e.command) || ran.deny !== undefined) return ran
44
45 const url = findPrUrl(ran.text ?? JSON.stringify(ran.result ?? ''))
46 if (url !== undefined) await trackPr($, url, 'session')
47
48 return ran
49 })
50
51 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
52 const { Box, Button, Text } = $.ui.resolve(e)
53 const list = await read($, prs)
54 const width = Math.max(20, (e.props.bodyColumns ?? 60) - 4)
55 const ordered = GROUPS.flatMap(group => list.filter(pr => pr.source === group.source))
56 const failedChecks = ordered.flatMap(pr => pr.checks.filter(check => check.bucket === 'fail').map(check => ({ pr, check })))
57
58 return (
59 <Box flexDirection="column">
60 {GROUPS.map(group => {
61 const members = list.filter(pr => pr.source === group.source)
62
63 return (
64 <Box key={`group-${group.source}`} flexDirection="column" marginBottom={1}>
65 <Text bold underline>{group.title}</Text>
66 {members.length === 0 && <Text dimColor>None</Text>}
67 {members.map(pr => (
68 <Box key={pr.url} flexDirection="column">
69 <Text bold wrap="truncate-end">
70 {ICONS[resolvePrBucket(pr)]} {pr.repo.split('/')[1]}#{pr.number} {pr.title}
71 </Text>
72 {pr.checks.map(check => {
73 const failedIndex = failedChecks.findIndex(failed => failed.pr === pr && failed.check === check)
74 if (failedIndex === -1 || failedIndex >= HOTKEYS.length) {
75 return (
76 <Text key={`${pr.url}-${check.name}`} dimColor={check.bucket !== 'fail'} wrap="truncate-end">
77 {' '}{ICONS[check.bucket]} {check.name.slice(0, width)}
78 </Text>
79 )
80 }
81
82 return (
83 <Button
84 key={`fetch-${failedIndex}`}
85 plain
86 hotkey={HOTKEYS[failedIndex]}
87 label={`✗ ${check.name.slice(0, width - 16)} - fetch log`}
88 onPress={async () => {
89 $.ui.toast(`Fetching the log of ${check.name}...`)
90 await $.prompt.fill({ text: await fetchFailureText($, pr, check), mode: 'append' })
91 }}
92 />
93 )
94 })}
95 </Box>
96 ))}
97 </Box>
98 )
99 })}
100 </Box>
101 )
102 })
103}
104
105async function refresh($: EngineInterface, repoPrAuthor: string): Promise<void> {
106 if (isRefreshing) return
107 isRefreshing = true
108 try {
109 await syncRepoPrs($, repoPrAuthor)
110 for (const pr of await read($, prs)) await refreshPr($, pr.url, pr.source)
111 } finally {
112 isRefreshing = false
113 }
114}
115
116async function syncRepoPrs($: EngineInterface, repoPrAuthor: string): Promise<void> {
117 const author = repoPrAuthor === '' ? [] : ['--author', repoPrAuthor]
118 const listed = await $.process
119 .run(['gh', 'pr', 'list', '--state', 'open', ...author, '--limit', String(REPO_PR_LIMIT), '--json', 'url'], { cwd: await $.session.root(), timeoutMs: 20_000 })
120 .catch(() => undefined)
121 if (listed === undefined || listed.exitCode !== 0) return
122
123 const urls = parsePrUrls(listed.stdout)
124 const known = await read($, prs)
125 await update($, prs, list => list.filter(pr => pr.source === 'session' || urls.includes(pr.url)))
126 for (const url of urls) {
127 if (!known.some(pr => pr.url === url)) await refreshPr($, url, 'repo')
128 }
129}
130
131async function trackPr($: EngineInterface, url: string, source: PrSource): Promise<void> {
132 const isNew = !(await read($, prs)).some(pr => pr.url === url && pr.source === source)
133 await refreshPr($, url, source)
134 if (isNew && source === 'session' && (await read($, prs)).some(pr => pr.url === url)) void $.ui.open({ id: PANE, title: 'PRs' })
135}
136
137async function refreshPr($: EngineInterface, url: string, source: PrSource): Promise<void> {
138 const view = await $.process.run(['gh', 'pr', 'view', url, '--json', PR_FIELDS], { timeoutMs: 20_000 }).catch(() => undefined)
139 if (view === undefined || view.exitCode !== 0) return
140
141 const before = (await read($, prs)).find(pr => pr.url === url)
142 const fresh = parsePrView(view.stdout, before?.source === 'session' ? 'session' : source)
143 for (const notice of describePrChanges(before, fresh)) $.ui.toast(notice.text, notice.timeoutMs === undefined ? undefined : { timeoutMs: notice.timeoutMs })
144
145 if (fresh.state !== 'OPEN') {
146 await update($, prs, list => list.filter(pr => pr.url !== url))
147
148 return
149 }
150
151 await update($, prs, list => (list.some(pr => pr.url === url) ? list.map(pr => (pr.url === url ? fresh : pr)) : [...list, fresh]))
152}
153
154async function fetchFailureText($: EngineInterface, pr: TrackedPr, check: PrCheck): Promise<string> {
155 const argv = buildLogArgv(check)
156 const log = argv === undefined ? undefined : await $.process.run(argv, { timeoutMs: 60_000 }).catch(() => undefined)
157
158 return buildFailureText(pr, check, log !== undefined && log.exitCode === 0 ? log.stdout : undefined)
159}
160core/github.ts 125 lines1import type { PrCheck, PrCheckBucket, PrSource, TrackedPr } from '../types'
2
3const PR_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/
4const ACTIONS_JOB = /github\.com\/([\w.-]+\/[\w.-]+)\/actions\/runs\/\d+\/job\/(\d+)/
5
6type RollupEntry = {
7 __typename?: string
8 name?: string
9 context?: string
10 status?: string
11 conclusion?: string
12 state?: string
13 detailsUrl?: string
14 targetUrl?: string
15}
16
17type PrView = { url: string; number: number; title: string; state: string; statusCheckRollup?: RollupEntry[] | null }
18
19export function findPrUrl(text: string): string | undefined {
20 return text.match(PR_URL)?.[0]
21}
22
23export function findActionsJob(link: string): { repo: string; jobId: string } | undefined {
24 const match = link.match(ACTIONS_JOB)
25
26 return match === null ? undefined : { repo: match[1] ?? '', jobId: match[2] ?? '' }
27}
28
29export function parsePrView(json: string, source: PrSource): TrackedPr {
30 const view = JSON.parse(json) as PrView
31
32 return {
33 source,
34 url: view.url,
35 repo: view.url.match(PR_URL)?.[1] ?? '',
36 number: view.number,
37 title: view.title,
38 state: view.state,
39 checks: (view.statusCheckRollup ?? []).map(mapRollupEntry),
40 }
41}
42
43export function resolvePrBucket(pr: TrackedPr): PrCheckBucket | 'none' {
44 if (pr.checks.length === 0) return 'none'
45 if (pr.checks.some(check => check.bucket === 'fail')) return 'fail'
46 if (pr.checks.some(check => check.bucket === 'pending')) return 'pending'
47
48 return 'pass'
49}
50
51export function findNewFailures(before: TrackedPr | undefined, after: TrackedPr): PrCheck[] {
52 const failedBefore = new Set((before?.checks ?? []).filter(check => check.bucket === 'fail').map(check => check.name))
53
54 return after.checks.filter(check => check.bucket === 'fail' && !failedBefore.has(check.name))
55}
56
57function mapRollupEntry(entry: RollupEntry): PrCheck {
58 if (entry.__typename === 'StatusContext') {
59 return { name: entry.context ?? '', bucket: mapStatusState(entry.state ?? ''), link: entry.targetUrl ?? '' }
60 }
61
62 return { name: entry.name ?? '', bucket: mapCheckRun(entry.status ?? '', entry.conclusion ?? ''), link: entry.detailsUrl ?? '' }
63}
64
65function mapStatusState(state: string): PrCheckBucket {
66 if (state === 'SUCCESS') return 'pass'
67 if (state === 'FAILURE' || state === 'ERROR') return 'fail'
68
69 return 'pending'
70}
71
72function mapCheckRun(status: string, conclusion: string): PrCheckBucket {
73 if (status !== 'COMPLETED') return 'pending'
74 if (conclusion === 'SUCCESS' || conclusion === 'NEUTRAL') return 'pass'
75 if (conclusion === 'SKIPPED') return 'skip'
76
77 return 'fail'
78}
79
80export function parsePrUrls(json: string): string[] {
81 return (JSON.parse(json) as { url: string }[]).map(pr => pr.url)
82}
83
84export const PR_FIELDS = 'url,number,title,state,statusCheckRollup'
85
86export const LOG_LINES = 150
87
88export const ICONS: Record<PrCheckBucket | 'none', string> = { pass: '✓', fail: '✗', pending: '…', skip: '-', none: ' ' }
89
90export type PrNotice = { text: string; timeoutMs?: number }
91
92export function buildPrLabel(pr: TrackedPr): string {
93 return `${pr.repo.split('/')[1]}#${pr.number}`
94}
95
96export function describePrChanges(before: TrackedPr | undefined, fresh: TrackedPr): PrNotice[] {
97 const label = buildPrLabel(fresh)
98 if (fresh.state !== 'OPEN') return before === undefined ? [] : [{ text: `${label} is ${fresh.state.toLowerCase()}.` }]
99
100 const notices: PrNotice[] = findNewFailures(before, fresh).map(check => ({ text: `✗ ${label}: ${check.name} failed`, timeoutMs: 8_000 }))
101 if (before !== undefined && resolvePrBucket(before) === 'pending' && resolvePrBucket(fresh) === 'pass') notices.push({ text: `✓ ${label}: all checks passed` })
102
103 return notices
104}
105
106export function buildLogArgv(check: PrCheck): string[] | undefined {
107 const job = findActionsJob(check.link)
108
109 return job === undefined ? undefined : ['gh', 'run', 'view', '--repo', job.repo, '--job', job.jobId, '--log-failed']
110}
111
112export function buildFailureText(pr: TrackedPr, check: PrCheck, log?: string): string {
113 const heading = `CI check "${check.name}" failed on ${pr.repo}#${pr.number} (${check.link}).`
114 if (log === undefined || log.trim() === '') return `${heading}\n`
115
116 const tail = log.trimEnd().split('\n').slice(-LOG_LINES).join('\n')
117
118 return `${heading} Failed steps, last ${LOG_LINES} lines:\n\`\`\`\n${tail}\n\`\`\`\n`
119}
120
121export const GROUPS: { source: PrSource; title: string }[] = [
122 { source: 'session', title: 'This session' },
123 { source: 'repo', title: 'This repo' },
124]
125types/index.d.ts 22 lines1export type PrCheckBucket = 'pass' | 'fail' | 'pending' | 'skip'
2
3export type PrCheck = { name: string; bucket: PrCheckBucket; link: string }
4
5export type PrSource = 'session' | 'repo'
6
7export type TrackedPr = {
8 source: PrSource
9 url: string
10 repo: string
11 number: number
12 title: string
13 state: string
14 checks: PrCheck[]
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'pr-ci': { tracked: TrackedPr[] }
20 }
21}
22