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

A status line for the pull request of the current branch: checks, review decision and merge state.
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 · approvedPR #12 ✗ 2 checks · changes requestedPR #12 ⏳ checksPR #12 draft, PR #12 merged, PR #12 closedreview required and conflicts are appended when they apply.gh missing or not logged in, timeout: the status is cleared.gh pr create|merge|ready|review or git push.| Command | Effect |
|---|---|
/pr-status | Refresh and print title, URL, status line, each failing check name and the pending ones. |
claude --plugin-dir /path/to/ModsTools/mods/pr-status
gh), authenticated for the repository.FAILURE, TIMED_OUT, CANCELLED, STARTUP_FAILURE, ACTION_REQUIRED, STALE or ERROR; SUCCESS, NEUTRAL and SKIPPED count as passed.claude plugin validate mods/pr-status
claude plugin test mods/pr-status # 16 testshooks/register.ts 55 lines1import 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}
55hooks/pr.ts 120 lines1export 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