SLOPSHOPPER

pr-status

Shows live GitHub PR state beside PR links in the transcript, and tells Claude when a PR's state changes.

newrowstoastpromptprocesstimer
v0.1.0no licenseupdated 2026-10-06patrickdundas/claude-code-pr-status
A shopper browsing a rack in a slop shop
README

pr-status

A Claude Code mod that shows the live state of GitHub pull requests wherever Claude links to one.

  • Each PR link in Claude's replies becomes the PR's Octicon, its number and its title, for example #42 Fix flaky login test.
  • The icon and number are colored by state: green for open, gray for draft, purple for merged, red for closed. Approved and changes-requested PRs get a second icon.
  • The title is underlined and links to the PR.
  • PRs mentioned in the last 20 messages or the last 30 minutes are rechecked every 15 seconds. Older open PRs are rechecked every 5 minutes, closed PRs every 10 minutes, and merged PRs never. When a state changes, every message showing that PR redraws.
  • All due PRs go in one GraphQL request, which costs 1 point of your 5,000-per-hour GitHub API budget however many PRs it holds.
  • Checking pauses after 30 minutes without activity. Your next prompt refreshes every open PR before the turn starts.
  • On a real state change (for example open to merged), you get a toast and Claude gets a short note, so it stops treating a merged PR as open.
  • In the terminal and the Desktop app, the mod attaches a short guide to your first prompt of each session, and again after compaction. It asks Claude to write PR references as bare URLs, without repeating the title, number or state. If Claude repeats them anyway, the mod drops the copy beside the badge.

Requirements

  • Claude Code 2.1.287 or later, in the terminal or the Desktop app's Code tab.
  • The GitHub CLI (gh), logged in. The mod runs gh pr view to read PR state.
  • A Nerd Font in your terminal for the Octicon glyphs.

Install

At a Claude Code prompt:

/plugin install pr-status --marketplace patrickdundas/claude-code-pr-status

Answer y to add the marketplace, then pick a scope.

Notes

  • Colors only apply on lines the mod draws itself. A PR link inside a table or a code block shows the icon and title without color.
  • In the classic (non-fullscreen) renderer, messages that have scrolled into the terminal's own scrollback cannot be redrawn. Fullscreen mode ("tui": "fullscreen") redraws them all.
  • If links print as title (url) instead of being clickable, Claude Code does not think your terminal supports hyperlinks. Set FORCE_HYPERLINK=1 in your environment or in the env block of ~/.claude/settings.json.

Development

claude plugin validate .
claude plugin test .
Source 5 files
hooks/register.tsx 167 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PrStatus } from '../types'
5import { layout } from './layout'
6import type { Run } from './layout'
7import { decorate, describeChange, findPrs, textsOf } from './lib'
8import { BATCH_MAX, IDLE_MS, batchArgs, interval, isRecent, parseBatch, refOf } from './poll'
9import type { Seen } from './poll'
10
11const prs = atom({ plugin: 'pr-status', key: 'prs' } as const, {} as Record<string, PrStatus>)
12const isGuided = atom({ plugin: 'pr-status', key: 'isGuided' } as const, false)
13
14const TICK_MS = 5_000
15const DRAWN_ON = new Set(['terminal', 'desktop'])
16
17const GUIDE = `# GitHub pull request links
18This interface replaces every GitHub PR URL in your replies with the PR's live status icon, its number and its title, as one clickable link. Write PR references so that replacement reads cleanly:
19- Write the bare URL (https://github.com/<owner>/<repo>/pull/<number>) where the PR belongs in the sentence, as if it were the PR's name: "I opened https://github.com/o/r/pull/12 for this."
20- Never add the PR's title, its #number, or its open/draft/merged/closed state beside the URL. The replacement already shows them, so they would appear twice.
21- Never wrap the URL in markdown link syntax or backticks, and never put it in a table. Those stop the replacement or its colors.
22- For several PRs, use a list with one URL per item, plus any note about what you did with each.
23Wrong: It's PR #803, "Fix login": https://github.com/o/r/pull/803
24Right: It's https://github.com/o/r/pull/803.`
25const GH = ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh']
26
27const known = new Set<string>()
28const seen = new Map<string, Seen>()
29const checkedAt = new Map<string, number>()
30let row = 0
31let activeAt = 0
32let isPolling = false
33
34// Only conversation rows mark a PR recent; redrawing an old message must not.
35function track(text: string, now?: number) {
36  for (const { key } of findPrs(text)) {
37    known.add(key)
38    if (now !== undefined) seen.set(key, { row, at: now })
39  }
40}
41
42async function runGh($: EngineInterface, args: string[]): Promise<string | null> {
43  for (const gh of GH) {
44    try {
45      // gh exits non-zero when one PR in the batch is missing, but still prints the rest.
46      return (await $.process.run([gh, ...args], { timeoutMs: 15_000 })).stdout
47    } catch {
48      // gh not at this path; try the next one.
49    }
50  }
51  return null
52}
53
54async function poll($: EngineInterface, force = false) {
55  if (isPolling) return
56  isPolling = true
57  try {
58    const now = await $.clock.now()
59    if (!force && now - activeAt > IDLE_MS) return
60    const stored = await read($, prs)
61    const due = [...known]
62      .filter(key => {
63        const wait = interval(stored[key], isRecent(seen.get(key), row, now))
64        if (force) return wait !== Infinity
65        return now - (checkedAt.get(key) ?? -Infinity) >= wait
66      })
67      .map(refOf)
68      .filter(r => r !== null)
69      .slice(0, BATCH_MAX)
70    if (!due.length) return
71
72    const stdout = await runGh($, batchArgs(due))
73    const results = stdout ? parseBatch(stdout, due) : new Map<string, PrStatus>()
74    const changed: Record<string, PrStatus> = {}
75    const notes: string[] = []
76    for (const { key } of due) {
77      checkedAt.set(key, now)
78      const status = results.get(key)
79      const before = stored[key]
80      if (!status || (before && JSON.stringify(before) === JSON.stringify(status))) continue
81      changed[key] = status
82      const note = before && describeChange(key, before, status)
83      if (note) notes.push(note)
84    }
85
86    if (Object.keys(changed).length > 0) await update($, prs, current => ({ ...current, ...changed }))
87    for (const note of notes) {
88      $.ui.toast(note)
89      await $.session.append({
90        message: { type: 'user', content: [{ type: 'text', text: `[pr-status mod] ${note}` }] },
91      })
92    }
93  } finally {
94    isPolling = false
95  }
96}
97
98export const register: Register = on => {
99  on('session.start', async ($, e, next) => {
100    for (const key of Object.keys(await read($, prs))) known.add(key)
101    activeAt = await $.clock.now()
102    $.clock.every(TICK_MS, () => void poll($))
103    return next(e)
104  })
105
106  // A session renders its system prompt once, so the guide rides the first prompt instead.
107  on('prompt.submit', async ($, e, next) => {
108    activeAt = await $.clock.now()
109    await poll($, true)
110    const surfaces = await $.session.surfaces()
111    const isDrawn = !surfaces.length || surfaces.some(s => DRAWN_ON.has(s))
112    if (!isDrawn || (await read($, isGuided))) return next(e)
113    await update($, isGuided, () => true)
114    return next({ ...e, context: [...(e.context ?? []), GUIDE] })
115  }).catch(($, e, next) => next(e))
116
117  // Compaction can drop the guide from the conversation, so send it again afterwards.
118  on('session.compact', async ($, e, next) => {
119    const result = await next(e)
120    if (!('skip' in result)) await update($, isGuided, () => false)
121    return result
122  }).catch(($, e, next) => next(e))
123
124  on('session.append', async ($, e, next) => {
125    if (!e.agentId) {
126      const now = await $.clock.now()
127      activeAt = now
128      row += 1
129      for (const text of textsOf(e.message.content)) track(text, now)
130    }
131    return next(e)
132  }).catch(($, e, next) => next(e))
133
134  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
135    track(e.props.text)
136    const stored = await read($, prs)
137    const blocks = e.props.isSummary ? null : layout(e.props.text, stored)
138    if (!blocks) {
139      const text = decorate(e.props.text, stored)
140      return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
141    }
142
143    const { Box, Text, Link, Markdown } = $.ui.resolve(e)
144    const run = (r: Run, i: number) => {
145      const body = (
146        <Text key={`t${i}`} color={r.color} bold={r.bold} italic={r.italic} underline={r.underline}>
147          {r.text}
148        </Text>
149      )
150      return r.href ? <Link key={`k${i}`} href={r.href}>{body}</Link> : body
151    }
152    return (
153      <Box flexDirection="column">
154        {blocks.map((b, i) =>
155          b.kind === 'md' ? (
156            <Markdown key={`m${i}`} text={b.text} />
157          ) : b.kind === 'blank' ? (
158            <Text key={`b${i}`}> </Text>
159          ) : (
160            <Text key={`l${i}`}>{b.runs.map(run)}</Text>
161          ),
162        )}
163      </Box>
164    )
165  }).catch(($, e, next) => next(e))
166}
167
hooks/layout.ts 191 lines
1import type { PrStatus } from '../types'
2import { decorate, prKey } from './lib'
3
4export type Run = {
5  text: string
6  color?: string
7  bold?: boolean
8  italic?: boolean
9  underline?: boolean
10  href?: string
11}
12
13export type Block =
14  | { kind: 'md'; text: string }
15  | { kind: 'blank' }
16  | { kind: 'line'; runs: Run[] }
17
18// Nerd Font Octicons, coloured as GitHub does, using Claude Code theme keys.
19const OCT = { open: '', draft: '', merged: '', closed: '', check: '', diff: '' }
20const TITLE_MAX = 72
21
22const PR = (n: string) => String.raw`https://github\.com/(?<o${n}>[\w.-]+)/(?<r${n}>[\w.-]+)/pull/(?<n${n}>\d+)`
23const INLINE = new RegExp(
24  [
25    String.raw`(?<code>\x60[^\x60\n]+\x60)`,
26    String.raw`(?<prmd>\[[^\]\n]*\]\(${PR('1')}[^)\s]*\))`,
27    String.raw`(?<md>\[(?<mdt>[^\]\n]*)\]\((?<mdu>[^)\s]+)\))`,
28    String.raw`(?<bold>\*\*[^*\n]+\*\*)`,
29    String.raw`(?<it>\*[^*\s][^*\n]*\*)`,
30    String.raw`(?<prbare><?${PR('2')}(?![\w/])[^\s)>\]]*>?)`,
31    String.raw`(?<url>https?://[^\s)>\]]+)`,
32  ].join('|'),
33  'g',
34)
35const PR_ANY = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/g
36
37export function iconRuns(s: PrStatus): Run[] {
38  if (s.state === 'MERGED') return [{ text: OCT.merged, color: 'merged' }]
39  if (s.state === 'CLOSED') return [{ text: OCT.closed, color: 'error' }]
40  if (s.isDraft) return [{ text: OCT.draft, color: 'inactive' }]
41  const open: Run = { text: OCT.open, color: 'success' }
42  if (s.reviewDecision === 'APPROVED') return [open, { text: ' ' }, { text: OCT.check, color: 'success' }]
43  if (s.reviewDecision === 'CHANGES_REQUESTED') return [open, { text: ' ' }, { text: OCT.diff, color: 'error' }]
44  return [open]
45}
46
47function title(s: PrStatus): string {
48  return s.title.length > TITLE_MAX ? `${s.title.slice(0, TITLE_MAX - 1).trimEnd()}…` : s.title
49}
50
51const SEP = String.raw`\s*[,:;\u2013\u2014-]?\s*`
52const escape = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
53
54// The badge already shows number and title, so drop a copy written right beside the link.
55function echoes(number: string, s: PrStatus): { before: RegExp; after: RegExp; whole: RegExp } {
56  const num = String.raw`\(?(?:PR\s*)?#${number}\b\)?`
57  const ttl = s.title ? String.raw`["\u201c'*_]*${escape(s.title)}["\u201d'*_]*` : '(?!)'
58  const any = String.raw`(?:${num}(?:${SEP}${ttl})?|${ttl}(?:${SEP}${num})?)`
59  return {
60    before: new RegExp(String.raw`(^|\s)${any}${SEP}$`, 'i'),
61    after: new RegExp(String.raw`^\s*(?:\(${any}\)|${SEP}${num})(?=\W|$)`, 'i'),
62    whole: new RegExp(String.raw`^\s*${any}\s*$`, 'i'),
63  }
64}
65
66const ONLY_SEP = new RegExp(String.raw`^${SEP}$`)
67
68function trimBefore(runs: Run[], e: ReturnType<typeof echoes>) {
69  let end = runs.length
70  for (let i = runs.length - 1; i >= 0 && i >= runs.length - 4; i--) {
71    const r = runs[i] as Run
72    if (r.href) return
73    if (ONLY_SEP.test(r.text)) continue
74    if (e.whole.test(r.text)) {
75      runs.splice(i, end - i)
76      end = i
77      continue
78    }
79    const text = r.text.replace(e.before, '$1')
80    if (text !== r.text) runs.splice(i, end - i, { ...r, text })
81    return
82  }
83}
84
85export function inlineRuns(line: string, prs: Record<string, PrStatus>): Run[] {
86  const runs: Run[] = []
87  let at = 0
88  let echo: ReturnType<typeof echoes> | null = null
89  const plain = (text: string) => {
90    if (echo) text = text.replace(echo.after, '')
91    echo = null
92    if (text) runs.push({ text })
93  }
94  for (const m of line.matchAll(INLINE)) {
95    const g = m.groups ?? {}
96    if (m.index > at) plain(line.slice(at, m.index))
97    else echo = null
98    at = m.index + m[0].length
99    const owner = g.o1 ?? g.o2
100    const repo = g.r1 ?? g.r2
101    const number = g.n1 ?? g.n2
102    if (owner && repo && number) {
103      const href = `https://github.com/${owner}/${repo}/pull/${number}`
104      const s = prs[prKey(owner, repo, number)]
105      if (s) {
106        echo = echoes(number, s)
107        trimBefore(runs, echo)
108        const icons = iconRuns(s)
109        runs.push(...icons, { text: ' ' }, { text: `#${number}`, color: icons[0]?.color, bold: true, href })
110        runs.push({ text: ' ' }, { text: title(s), underline: true, href })
111        continue
112      }
113      else runs.push({ text: href, href })
114    } else if (g.code) runs.push({ text: g.code.slice(1, -1), color: 'permission' })
115    else if (g.md) runs.push(isWeb(g.mdu) ? { text: g.mdt || (g.mdu as string), href: g.mdu } : { text: g.md })
116    else if (g.bold) runs.push({ text: g.bold.slice(2, -2), bold: true })
117    else if (g.it) runs.push({ text: g.it.slice(1, -1), italic: true })
118    else if (g.url) runs.push({ text: g.url, href: g.url })
119  }
120  if (at < line.length) plain(line.slice(at))
121  return runs
122}
123
124function isWeb(url: string | undefined): boolean {
125  return /^https?:\/\//i.test(url ?? '')
126}
127
128function hasKnownPr(line: string, prs: Record<string, PrStatus>): boolean {
129  for (const m of line.matchAll(PR_ANY)) {
130    if (prs[prKey(m[1] as string, m[2] as string, m[3] as string)]) return true
131  }
132  return false
133}
134
135// Lines holding a known PR (outside fences and tables) are drawn by the mod so
136// their icons can be coloured; everything else stays engine-drawn markdown.
137export function layout(text: string, prs: Record<string, PrStatus>): Block[] | null {
138  const blocks: Block[] = []
139  let md: string[] = []
140  let inFence = false
141  let owned = false
142
143  const flush = () => {
144    while (md.length && !md[0]?.trim()) md.shift()
145    let trailingBlank = false
146    while (md.length && !md[md.length - 1]?.trim()) {
147      md.pop()
148      trailingBlank = true
149    }
150    if (md.length) blocks.push({ kind: 'md', text: decorate(md.join('\n'), prs) })
151    if (trailingBlank) blocks.push({ kind: 'blank' })
152    md = []
153  }
154
155  for (const line of text.split('\n')) {
156    if (/^\s*(```|~~~)/.test(line)) inFence = !inFence
157    const isTable = /^\s*\|/.test(line)
158    if (inFence || isTable || !hasKnownPr(line, prs)) {
159      if (!md.length && !line.trim() && blocks.length && blocks[blocks.length - 1]?.kind !== 'blank') {
160        blocks.push({ kind: 'blank' })
161        continue
162      }
163      md.push(line)
164      continue
165    }
166    flush()
167    owned = true
168    const lead = /^(\s*)([-*+]|\d+\.)\s+/.exec(line)
169    const heading = /^#{1,6}\s+/.exec(line)
170    const quote = /^>\s?/.exec(line)
171    const runs: Run[] = []
172    let body = line
173    if (lead) {
174      const marker = /\d/.test(lead[2] as string) ? (lead[2] as string) : '-'
175      runs.push({ text: `${lead[1]}${marker} ` })
176      body = line.slice(lead[0].length)
177    } else if (quote) {
178      runs.push({ text: '▎ ', color: 'inactive' })
179      body = line.slice(quote[0].length)
180    } else if (heading) {
181      body = line.slice(heading[0].length)
182    }
183    const inline = inlineRuns(body, prs)
184    runs.push(...(heading ? inline.map(r => ({ ...r, bold: true })) : inline))
185    blocks.push({ kind: 'line', runs })
186  }
187  flush()
188  while (blocks[blocks.length - 1]?.kind === 'blank') blocks.pop()
189  return owned ? blocks : null
190}
191
hooks/lib.ts 112 lines
1import type { PrStatus } from '../types'
2
3const PR_URL = String.raw`https://github\.com/([\w.-]+)/([\w.-]+)/pull/(\d+)`
4
5// A markdown link wrapping a PR URL, or a bare (optionally <angled>) PR URL.
6const LINK = new RegExp(String.raw`\[[^\]\n]*\]\(${PR_URL}[^)\s]*\)|<?${PR_URL}(?![\w/])[^\s)>\]]*>?`, 'g')
7
8export type PrRef = { key: string; url: string }
9
10export function prKey(owner: string, repo: string, number: string): string {
11  return `${owner.toLowerCase()}/${repo.toLowerCase()}#${number}`
12}
13
14export function findPrs(text: string): PrRef[] {
15  const found = new Map<string, PrRef>()
16  for (const m of text.matchAll(LINK)) {
17    const [owner = '', repo = '', number = ''] = m[1] ? [m[1], m[2], m[3]] : [m[4], m[5], m[6]]
18    const key = prKey(owner, repo, number)
19    found.set(key, { key, url: `https://github.com/${owner}/${repo}/pull/${number}` })
20  }
21  return [...found.values()]
22}
23
24// Every string inside a row's content blocks, tool results included.
25export function textsOf(value: unknown): string[] {
26  if (typeof value === 'string') return [value]
27  if (Array.isArray(value)) return value.flatMap(textsOf)
28  if (value && typeof value === 'object') {
29    const v = value as { text?: unknown; content?: unknown }
30    return [...textsOf(v.text), ...textsOf(v.content)]
31  }
32  return []
33}
34
35// Nerd Font Octicons: git-pull-request, -draft, git-merge, -closed, check, file-diff.
36const ICON = { open: '\uf407', draft: '\uf4dd', merged: '\uf419', closed: '\uf4dc', approved: '\uf42e', changes: '\uf4d2' }
37const TITLE_MAX = 72
38
39export function stateWord(s: PrStatus): string {
40  if (s.state === 'MERGED') return 'merged'
41  if (s.state === 'CLOSED') return 'closed'
42  if (s.isDraft) return 'draft'
43  if (s.reviewDecision === 'APPROVED') return 'open, approved'
44  if (s.reviewDecision === 'CHANGES_REQUESTED') return 'open, changes requested'
45  return 'open'
46}
47
48export function icon(s: PrStatus): string {
49  if (s.state === 'MERGED') return ICON.merged
50  if (s.state === 'CLOSED') return ICON.closed
51  if (s.isDraft) return ICON.draft
52  if (s.reviewDecision === 'APPROVED') return `${ICON.open} ${ICON.approved}`
53  if (s.reviewDecision === 'CHANGES_REQUESTED') return `${ICON.open} ${ICON.changes}`
54  return ICON.open
55}
56
57function linkText(title: string, number: string): string {
58  const t = title.length > TITLE_MAX ? `${title.slice(0, TITLE_MAX - 1).trimEnd()}…` : title
59  return `#${number} ${t}`.replace(/[\\[\]]/g, m => `\\${m}`)
60}
61
62// Code spans and fences are left alone so a link inside code stays literal.
63export function decorate(text: string, prs: Record<string, PrStatus>): string {
64  return text
65    .split(/(```[\s\S]*?(?:```|$)|`[^`\n]*`)/)
66    .map((part, i) =>
67      i % 2 === 1
68        ? part
69        : part.replace(LINK, (match, o1, r1, n1, o2, r2, n2) => {
70            const [owner, repo, number] = o1 ? [o1, r1, n1] : [o2, r2, n2]
71            const s = prs[prKey(owner, repo, number)]
72            if (!s) return match
73            const url = `https://github.com/${owner}/${repo}/pull/${number}`
74            return `${icon(s)} [${linkText(s.title, number)}](${url})`
75          }),
76    )
77    .join('')
78}
79
80// Titles come from strangers on public repos: drop control and bidi-override characters.
81export function cleanTitle(value: unknown): string {
82  return String(value ?? '')
83    .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, ' ')
84    .replace(/\s+/g, ' ')
85    .trim()
86}
87
88export function parseGh(stdout: string): PrStatus | null {
89  try {
90    const j = JSON.parse(stdout)
91    if (j.state !== 'OPEN' && j.state !== 'MERGED' && j.state !== 'CLOSED') return null
92    return {
93      state: j.state,
94      isDraft: Boolean(j.isDraft),
95      title: cleanTitle(j.title),
96      mergedAt: j.mergedAt || null,
97      reviewDecision: j.reviewDecision || null,
98    }
99  } catch {
100    return null
101  }
102}
103
104export function describeChange(key: string, before: PrStatus, after: PrStatus): string | null {
105  const was = stateWord(before)
106  const now = stateWord(after)
107  if (was === now) return null
108  const when = after.state === 'MERGED' && after.mergedAt ? ` at ${after.mergedAt}` : ''
109  // No title here: this note reaches the model, and a title is untrusted text.
110  return `PR ${key} changed state: ${was} → ${now}${when}.`
111}
112
hooks/poll.ts 64 lines
1import type { PrStatus } from '../types'
2import { cleanTitle } from './lib'
3
4export const RECENT_ROWS = 20
5export const RECENT_MS = 30 * 60_000
6export const IDLE_MS = 30 * 60_000
7export const HOT_MS = 15_000
8export const WARM_MS = 5 * 60_000
9export const CLOSED_MS = 10 * 60_000
10export const BATCH_MAX = 50
11
12export type Seen = { row: number; at: number }
13export type PrRef = { key: string; owner: string; repo: string; number: number }
14
15export function isRecent(seen: Seen | undefined, row: number, now: number): boolean {
16  return !!seen && (row - seen.row <= RECENT_ROWS || now - seen.at <= RECENT_MS)
17}
18
19// Merged PRs never change again; a PR not yet fetched is checked at the fast rate.
20export function interval(status: PrStatus | undefined, recent: boolean): number {
21  if (status?.state === 'MERGED') return Infinity
22  if (status?.state === 'CLOSED') return CLOSED_MS
23  return recent ? HOT_MS : WARM_MS
24}
25
26export function refOf(key: string): PrRef | null {
27  const m = /^([\w.-]+)\/([\w.-]+)#(\d+)$/.exec(key)
28  return m ? { key, owner: m[1] as string, repo: m[2] as string, number: Number(m[3]) } : null
29}
30
31// One GraphQL request for every PR, values passed as variables, never spliced into the query.
32export function batchArgs(refs: PrRef[]): string[] {
33  const vars = refs.map((_, i) => `$o${i}:String!,$r${i}:String!,$n${i}:Int!`).join(',')
34  const fields = 'state isDraft title mergedAt reviewDecision'
35  const body = refs
36    .map((_, i) => `p${i}:repository(owner:$o${i},name:$r${i}){pullRequest(number:$n${i}){${fields}}}`)
37    .join(' ')
38  const args = ['api', 'graphql', '-f', `query=query(${vars}){${body}}`]
39  refs.forEach((r, i) => args.push('-f', `o${i}=${r.owner}`, '-f', `r${i}=${r.repo}`, '-F', `n${i}=${r.number}`))
40  return args
41}
42
43export function parseBatch(stdout: string, refs: PrRef[]): Map<string, PrStatus> {
44  const out = new Map<string, PrStatus>()
45  let data: Record<string, { pullRequest?: Record<string, unknown> | null } | null> = {}
46  try {
47    data = JSON.parse(stdout).data ?? {}
48  } catch {
49    return out
50  }
51  refs.forEach((r, i) => {
52    const pr = data[`p${i}`]?.pullRequest
53    if (!pr || (pr.state !== 'OPEN' && pr.state !== 'MERGED' && pr.state !== 'CLOSED')) return
54    out.set(r.key, {
55      state: pr.state,
56      isDraft: Boolean(pr.isDraft),
57      title: cleanTitle(pr.title),
58      mergedAt: typeof pr.mergedAt === 'string' ? pr.mergedAt : null,
59      reviewDecision: typeof pr.reviewDecision === 'string' && pr.reviewDecision ? pr.reviewDecision : null,
60    })
61  })
62  return out
63}
64
types/index.d.ts 16 lines
1export type PrState = 'OPEN' | 'MERGED' | 'CLOSED'
2
3export type PrStatus = {
4  state: PrState
5  isDraft: boolean
6  title: string
7  mergedAt: string | null
8  reviewDecision: string | null
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'pr-status': { prs: Record<string, PrStatus>; isGuided: boolean }
14  }
15}
16