SLOPSHOPPER

pr-status

A status line for the current branch's pull request: checks, review decision and merge state, refreshed after pushes and PR commands

newguardcommandstatusprocess
v0.1.0MITupdated 2026-10-05vincentlauriat/ModsTools/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 pull request for this branch (or gh is unavailable). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

pr-status

A status line for the pull request of the current branch: checks, review decision and merge state.

What it does

  • Runs gh pr view --json number,state,isDraft,reviewDecision,statusCheckRollup,mergeable,title,url in the session folder (10 s timeout) and shows one status entry:
  • PR #12 ✓ checks · approved
  • PR #12 ✗ 2 checks · changes requested
  • PR #12 ⏳ checks
  • PR #12 draft, PR #12 merged, PR #12 closed
  • review required and conflicts are appended when they apply.
  • No PR for the branch, not a git repository, gh missing or not logged in, timeout: the status is cleared.
  • Refreshes at session start, after each main-conversation turn at most every 5 minutes, and immediately after a successful Bash gh pr create|merge|ready|review or git push.

Commands

CommandEffect
/pr-statusRefresh and print title, URL, status line, each failing check name and the pending ones.

Install

claude --plugin-dir /path/to/ModsTools/mods/pr-status

Limits

  • Needs the GitHub CLI (gh), authenticated for the repository.
  • Pushes made outside Claude are only picked up at the next turn once 5 minutes have passed.
  • A check counts as failing on FAILURE, TIMED_OUT, CANCELLED, STARTUP_FAILURE, ACTION_REQUIRED, STALE or ERROR; SUCCESS, NEUTRAL and SKIPPED count as passed.
  • Merged and closed PRs show no check detail.

Develop

claude plugin validate mods/pr-status
claude plugin test mods/pr-status   # 16 tests
Source 2 files
hooks/register.ts 55 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { FIELDS, isStale, parse, statusLine, summary, touchesPr } from './pr'
4import type { Pr } from './pr'
5
6const TIMEOUT_MS = 10_000
7
8async function fetchPr($: EngineInterface): Promise<Pr | null> {
9  try {
10    const ran = await $.process.run(['gh', 'pr', 'view', '--json', FIELDS], { cwd: await $.session.cwd(), timeoutMs: TIMEOUT_MS })
11
12    return ran.exitCode === 0 ? parse(ran.stdout) : null
13  } catch {
14    return null
15  }
16}
17
18async function refresh($: EngineInterface, last: { at?: number }): Promise<Pr | null> {
19  last.at = await $.clock.now()
20  const pr = await fetchPr($)
21  $.ui.status(pr === null ? undefined : statusLine(pr))
22
23  return pr
24}
25
26export const register: Register = on => {
27  const last: { at?: number } = {}
28
29  on('session.start', async ($, e, next) => {
30    await $.command.register({ name: 'pr-status', description: 'Show the current branch pull request: checks, review, failing check names' })
31    await refresh($, last)
32
33    return next(e)
34  })
35
36  on('turn.complete', async ($, e, next) => {
37    if (e.agentId === undefined && isStale(last.at, await $.clock.now())) await refresh($, last)
38
39    return next(e)
40  })
41
42  on('tool.call', async ($, e, next) => {
43    const ran = await next(e)
44    if (e.tool === 'Bash' && ran.deny === undefined && ran.isError !== true && touchesPr(e.command)) await refresh($, last)
45
46    return ran
47  })
48
49  on('command.run', { command: 'pr-status' }, async $ => {
50    const pr = await refresh($, last)
51
52    return { text: pr === null ? 'No pull request for this branch (or gh is unavailable).' : summary(pr) }
53  })
54}
55
hooks/pr.ts 120 lines
1export type Check = {
2  __typename?: string
3  name?: string
4  context?: string
5  status?: string
6  conclusion?: string
7  state?: string
8  detailsUrl?: string
9  targetUrl?: string
10}
11
12export type Pr = {
13  number: number
14  state: string
15  isDraft: boolean
16  reviewDecision: string
17  mergeable: string
18  title: string
19  url: string
20  statusCheckRollup: Check[]
21}
22
23export const FIELDS = 'number,state,isDraft,reviewDecision,statusCheckRollup,mergeable,title,url'
24
25const FAILED = ['FAILURE', 'TIMED_OUT', 'CANCELLED', 'STARTUP_FAILURE', 'ACTION_REQUIRED', 'STALE', 'ERROR']
26const PASSED = ['SUCCESS', 'NEUTRAL', 'SKIPPED']
27const PR_COMMAND = /(^|[\s;&|(])(gh\s+pr\s+(create|merge|ready|review)|git\s+push)(\s|$)/
28
29// Parses `gh pr view --json …` output; null when it is not a PR object.
30export function parse(stdout: string): Pr | null {
31  let raw: unknown
32  try {
33    raw = JSON.parse(stdout)
34  } catch {
35    return null
36  }
37  if (typeof raw !== 'object' || raw === null) return null
38  const v = raw as Record<string, unknown>
39  if (typeof v.number !== 'number') return null
40  const str = (x: unknown) => (typeof x === 'string' ? x : '')
41
42  return {
43    number: v.number,
44    state: str(v.state),
45    isDraft: v.isDraft === true,
46    reviewDecision: str(v.reviewDecision),
47    mergeable: str(v.mergeable),
48    title: str(v.title),
49    url: str(v.url),
50    statusCheckRollup: Array.isArray(v.statusCheckRollup) ? (v.statusCheckRollup.filter(c => typeof c === 'object' && c !== null) as Check[]) : [],
51  }
52}
53
54export type Verdict = 'failed' | 'pending' | 'passed'
55
56// CheckRun carries status + conclusion, StatusContext carries state.
57export function verdict(c: Check): Verdict {
58  if (c.__typename === 'StatusContext' || (c.status === undefined && c.state !== undefined)) {
59    if (c.state !== undefined && FAILED.includes(c.state)) return 'failed'
60    return c.state === 'SUCCESS' ? 'passed' : 'pending'
61  }
62  if (c.status !== 'COMPLETED') return 'pending'
63  if (c.conclusion !== undefined && PASSED.includes(c.conclusion)) return 'passed'
64
65  return 'failed'
66}
67
68export const checkName = (c: Check) => c.name ?? c.context ?? 'unnamed check'
69
70export function failing(pr: Pr): Check[] {
71  return pr.statusCheckRollup.filter(c => verdict(c) === 'failed')
72}
73
74const REVIEW: Record<string, string> = {
75  APPROVED: 'approved',
76  CHANGES_REQUESTED: 'changes requested',
77  REVIEW_REQUIRED: 'review required',
78}
79
80const plural = (n: number) => `${n} check${n === 1 ? '' : 's'}`
81
82function checksPart(pr: Pr): string | undefined {
83  const verdicts = pr.statusCheckRollup.map(verdict)
84  const failed = verdicts.filter(v => v === 'failed').length
85  if (failed > 0) return `✗ ${plural(failed)}`
86  if (verdicts.includes('pending')) return '⏳ checks'
87
88  return verdicts.length > 0 ? '✓ checks' : undefined
89}
90
91// "PR #12 ✓ checks · approved", "PR #12 draft", "PR #12 merged".
92export function statusLine(pr: Pr): string {
93  const head = `PR #${pr.number}`
94  if (pr.state === 'MERGED') return `${head} merged`
95  if (pr.state === 'CLOSED') return `${head} closed`
96  if (pr.isDraft) return `${head} draft`
97  const parts = [checksPart(pr), REVIEW[pr.reviewDecision], pr.mergeable === 'CONFLICTING' ? 'conflicts' : undefined]
98  const shown = parts.filter((p): p is string => p !== undefined)
99
100  return shown.length === 0 ? head : `${head} ${shown.join(' · ')}`
101}
102
103export function summary(pr: Pr): string {
104  const lines = [pr.title === '' ? statusLine(pr) : `${statusLine(pr)} — ${pr.title}`]
105  if (pr.url !== '') lines.push(pr.url)
106  const bad = failing(pr)
107  if (bad.length > 0) lines.push('Failing checks:', ...bad.map(c => `  ✗ ${checkName(c)}`))
108  const pending = pr.statusCheckRollup.filter(c => verdict(c) === 'pending')
109  if (pending.length > 0) lines.push(`Pending checks: ${pending.map(checkName).join(', ')}`)
110  if (pr.mergeable === 'CONFLICTING') lines.push('Merge conflicts with the base branch.')
111
112  return lines.join('\n')
113}
114
115// Commands after which the PR state has likely changed.
116export const touchesPr = (command: string): boolean => PR_COMMAND.test(command.replace(/\s+/g, ' '))
117
118export const STALE_MS = 5 * 60_000
119export const isStale = (last: number | undefined, now: number): boolean => last === undefined || now - last >= STALE_MS
120