SLOPSHOPPER

pr-ci

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

newpaneguardcommandtoastprocess
★ 1v0.2.0no licenseupdated 2026-10-04turanmahmudov/dotfiles/.claude/skills/pr-ci
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-ci
│ ┃ pr-ci ✕ › fix the failing auth test and add an audit log call │ ┃ This session │ ┃ None ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ This repo ⏺ Update(src/auth.ts) │ ┃ None ⎿ 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 │ │ › /prs │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pr-ci
This session None This repo None
README

Dotfiles

My personal dotfiles managed with YADM.

Tested on Ubuntu 25.10 and Ubuntu 25.04.

Installation

yadm clone <your-repo-url>
yadm config local.class work  # or personal
yadm config yadm.alt-copy true
yadm alt
yadm bootstrap

Important Keybindings

  • SUPER + Return - Terminal
  • SUPER + T - Terminal with tmux
  • SUPER + Space/D - Application launcher
Source 3 files
hooks/register.tsx 160 lines
1import { 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}
160
core/github.ts 125 lines
1import 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]
125
types/index.d.ts 22 lines
1export 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