Your open GitHub PRs across every repo: checks, reviews, unresolved threads and recent merges in the status line, /prs and a pane; /unreleased shows what is on…

Six small mods for Claude Code, written in TypeScript against its plugin hooks API. They add guard rails on tool calls, status line items, panes and slash commands: blocking risky git and shell commands, tracking your pull requests, gathering a standup, pacing session limits, showing which repo Claude is in, and keeping the Mac awake while work runs. Each mod is its own plugin, so you can install only the ones you want.
Add the marketplace from GitHub or from a local clone:
/plugin marketplace add AbdAlrahmanAlQaroot/claude-code-mods
/plugin marketplace add ~/path/to/claude-code-mods
Then install the mods you want:
/plugin install rules-guard@claude-code-mods
/plugin install pr-radar@claude-code-mods
The same works from a shell with claude plugin marketplace add <source> and claude plugin install <name>@claude-code-mods. Options are set with /plugin (pick the plugin, then configure) or claude plugin configure <name>. Restart the session after installing or changing options.
pr-radar and standup call the GitHub CLI, so gh must be installed and logged in. stay-awake and the pr-radar desktop notification are macOS only.
Checks Bash, Write and Edit calls before they run and holds the ones that break a rule. Each block names the rule, the problem, where it was found, the fix, and when to ask the user, so Claude can correct itself and retry.
Always on:
--all, --mirror and a bare git push from a protected branch. A bare push whose branch cannot be told is held until the branch is named.--force-with-lease is fine.Optional (off by default):
noAiAttribution: block AI attribution lines (co-author trailers, session links, "generated with" lines) in commits, PRs, comments and files.noTestPlan: block a "Test plan" section in PR descriptions.prBulletsOnly: PR descriptions must be 20 lines or fewer of short bullet points, with no prose paragraphs.noCodeComments: block new code comments in source files inside a git repo. Tool directives such as @ts- or eslint- lines and shebangs are allowed.noTestFiles: block a git commit that would include test files.noSlackSend: block Claude from sending or scheduling Slack messages. Drafts are allowed.liquibaseAuthor: the only author allowed on Liquibase changesets. Empty turns the check off.vpnProcess: process name that runs while your VPN is up, such as openvpn. When set, kubectl calls are held while it is not running.awsSsoCheck: hold kubectl and aws calls when no AWS SSO session in ~/.aws/sso/cache is fresh.Other options:
protectedBranches: comma-separated branch names. Default main,master.Keeps the state of your open GitHub pull requests across every repo: review decision, CI checks, unresolved threads, conflicts, and PRs merged in the last three days. It shows a summary in the status line, refreshes every five minutes and after gh pr or git push commands, and tells you when a PR is approved, gets changes requested, starts or stops failing, gets new comments, merges or closes.
/prs lists your PRs with links. /prs pane opens a live pane. /prs refresh fetches now./unreleased compares main (or master) with your integration branch in each repo and lists the PRs and commits not yet released.my_prs and my_unreleased, and prompts about PRs carry the live state as context.Options:
alerts: toast and macOS notification on PR changes. Default true.sound: play a sound with the notification. Default true.repos: comma-separated owner/repo list for /unreleased. Default empty, which uses only repos where you have a PR into the integration branch.integrationBranch: the branch work merges into before a release. Default staging.login: your GitHub login, used to mark your own commits in /unreleased. Default empty, which reads it from gh api user./standup opens a pane with what you did on a work day: PRs you opened, merged and reviewed, the commits you made, and the Claude Code sessions you worked in, grouped by repo. With no argument it shows the previous work day. It also accepts today, yesterday or a date like 2026-10-06. The Draft button asks Claude to write the update.
Options:
repos: comma-separated local repo paths to read commits from, including unpushed branches. Paths can be absolute, start with ~/, or be relative to your home folder. Default empty, which uses GitHub search only.utcOffset: your time zone as an offset from UTC, such as +03:00. Default +00:00.workDays: comma-separated work days. Default Mon,Tue,Wed,Thu,Fri.draftCommand: a slash command the Draft button runs with the day as its argument. Default empty, which sends Claude a prompt with the day's activity instead.Shows session-limit usage in the status line with the time until each window resets, and shows a toast once at 80% and once at 95% of each window.
Options:
sonnetSubagentsAbove: when usage of any window is above this percent, general-purpose subagents with no model of their own run on Sonnet. Default 0, which turns it off.handoffAt: when the 5-hour window reaches this percent, run your own /handoff command once in the session you are actively working in. Default 0, which turns it off.Shows the repo and branch Claude is working in on the status line, following cd and git -C in its commands. It also names a new session from its first real prompt, using a short Haiku call.
No options.
Keeps the Mac from idle-sleeping while a turn or a background agent runs, using caffeinate, and lets it sleep again once everything is done.
No options.
MIT. See LICENSE.
hooks/register.tsx 344 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Radar, Unreleased, UnreleasedRepo } from '../types'
5import {
6 DEFAULT_INTEGRATION,
7 DEFAULT_REPOS,
8 EMPTY,
9 LOGIN_ARGS,
10 PR_WORDS,
11 RELEASE_BRANCHES,
12 age,
13 alertText,
14 asIds,
15 asRadar,
16 asUnreleased,
17 byRepo,
18 compareArgs,
19 contextLine,
20 describeMerged,
21 describePr,
22 failedRepo,
23 ghArgs,
24 isNotFound,
25 listText,
26 parse,
27 parseCompare,
28 radarChanges,
29 reasonOf,
30 reposToCheck,
31 shortRepo,
32 summary,
33 unreleasedText,
34} from './radar'
35
36const PANE = 'pr-radar'
37const FRESH_MS = 2 * 60_000
38const EVERY_MS = 5 * 60_000
39const STORE_KEY = 'radar'
40const UNRELEASED_KEY = 'unreleased'
41const ALERTED_KEY = 'alerted'
42const RELEASES_KEY = 'releases'
43const RELEASES_TTL_MS = 24 * 60 * 60_000
44const PR_CHANGING = /\bgh\s+pr\s+(create|merge|close|ready|reopen|edit)\b|\bgit\s+push\b/
45
46const radarAtom = atom({ plugin: 'pr-radar', key: 'radar' } as const, null)
47
48const TOOL_DESCRIPTION =
49 "The user's open GitHub pull requests across every repo (review decision, CI checks, unresolved review threads, conflicts, base branch, URL) plus their PRs merged in the last 3 days, from GitHub itself. Call it before saying whether a PR is merged, released, approved, failing or still has comments, and when asked for PR links."
50
51const unreleasedDescription = (integration: string) =>
52 `What is merged to ${integration} but not yet released to production (main/master), per repo, from GitHub's compare of the release branch with ${integration}: the PRs and commits waiting for the next release, and how far the last release took ${integration}. Call it before saying whether a change, fix or PR is released, live in prod, or still only on ${integration}.`
53
54type Settings = { alerts: boolean; sound: boolean; repos: string; login: string; integration: string }
55
56let settings: Settings = { alerts: true, sound: true, repos: DEFAULT_REPOS, login: '', integration: DEFAULT_INTEGRATION }
57
58let login: Promise<string> | null = null
59
60type Releases = Record<string, { branch: string | null; at: number }>
61
62const noBranches = () => `skipped: no ${settings.integration} or main/master branch`
63
64const asReleases = (value: unknown): Releases =>
65 typeof value === 'object' && value !== null && !Array.isArray(value) ? { ...(value as Releases) } : {}
66
67let inFlight: Promise<Radar> | null = null
68
69let unreleasedInFlight: Promise<Unreleased> | null = null
70
71async function publish($: EngineInterface, radar: Radar) {
72 await update($, radarAtom, () => radar)
73 $.ui.status(summary(radar))
74}
75
76async function fetchRadar($: EngineInterface): Promise<Radar> {
77 const now = await $.clock.now()
78 const previous = (await read($, radarAtom)) ?? EMPTY
79 try {
80 const ran = await $.process.run(ghArgs(now), { timeoutMs: 30_000 })
81 if (ran.exitCode !== 0) return { ...previous, error: reasonOf(ran.stderr || ran.stdout) }
82 const parsed = parse(ran.stdout, now)
83 return typeof parsed === 'string' ? { ...previous, error: parsed } : parsed
84 } catch (error) {
85 return { ...previous, error: reasonOf(String(error)) }
86 }
87}
88
89async function freshest($: EngineInterface) {
90 const candidates = [asRadar(await $.store.get(STORE_KEY)), await read($, radarAtom)]
91 return candidates
92 .filter((radar): radar is Radar => radar !== null && radar.error === null)
93 .sort((a, b) => b.fetchedAt - a.fetchedAt)[0]
94}
95
96async function notify($: EngineInterface, text: string) {
97 const show = settings.sound
98 ? 'display notification (item 1 of argv) with title "PR radar" sound name "Glass"'
99 : 'display notification (item 1 of argv) with title "PR radar"'
100 try {
101 await $.process.run(['osascript', '-e', 'on run argv', '-e', show, '-e', 'end run', text], { timeoutMs: 5000 })
102 } catch {}
103}
104
105async function alert($: EngineInterface, previous: Radar, radar: Radar) {
106 const seen = asIds(await $.store.get(ALERTED_KEY))
107 const fresh = radarChanges(previous, radar).filter(change => !seen.includes(change.id))
108 if (fresh.length === 0) return
109 await $.store.set(ALERTED_KEY, [...seen, ...fresh.map(change => change.id)].slice(-200))
110 for (const change of fresh.slice(0, 5)) $.ui.toast(change.text, { timeoutMs: 10_000 })
111 await notify($, alertText(fresh))
112}
113
114async function load($: EngineInterface, force: boolean): Promise<Radar> {
115 if (!force) {
116 const cached = await freshest($)
117 if (cached !== undefined && (await $.clock.now()) - cached.fetchedAt < FRESH_MS) {
118 await publish($, cached)
119 return cached
120 }
121 }
122 const previous = asRadar(await $.store.get(STORE_KEY))
123 const radar = await fetchRadar($)
124 await publish($, radar)
125 if (radar.error === null) {
126 await $.store.set(STORE_KEY, radar)
127 if (settings.alerts && previous !== null && previous.error === null) await alert($, previous, radar)
128 }
129 return radar
130}
131
132function refresh($: EngineInterface, force: boolean) {
133 inFlight ??= load($, force).finally(() => {
134 inFlight = null
135 })
136 return inFlight
137}
138
139async function resolveLogin($: EngineInterface): Promise<string> {
140 if (settings.login !== '') return settings.login
141 login ??= $.process
142 .run(LOGIN_ARGS, { timeoutMs: 10_000 })
143 .then(ran => (ran.exitCode === 0 ? ran.stdout.trim() : ''))
144 .catch(() => '')
145 .then(found => {
146 if (found === '') login = null
147 return found
148 })
149 return login
150}
151
152async function compareRepo($: EngineInterface, repo: string, known: Releases, me: string): Promise<UnreleasedRepo> {
153 const cached = known[repo]
154 const now = await $.clock.now()
155 const isKnown = cached !== undefined && now - cached.at < RELEASES_TTL_MS
156 if (isKnown && cached.branch === null) return failedRepo(repo, null, noBranches())
157 const order = isKnown && cached.branch !== null ? [cached.branch, ...RELEASE_BRANCHES.filter(b => b !== cached.branch)] : [...RELEASE_BRANCHES]
158 for (const release of order) {
159 try {
160 const ran = await $.process.run(compareArgs(repo, release, settings.integration), { timeoutMs: 20_000 })
161 if (ran.exitCode === 0) {
162 known[repo] = { branch: release, at: now }
163 const parsed = parseCompare(repo, release, ran.stdout, me)
164 return typeof parsed === 'string' ? failedRepo(repo, release, parsed) : parsed
165 }
166 const text = ran.stderr || ran.stdout
167 if (!isNotFound(text)) return failedRepo(repo, release, reasonOf(text))
168 } catch (error) {
169 return failedRepo(repo, release, reasonOf(String(error)))
170 }
171 }
172 known[repo] = { branch: null, at: now }
173 return failedRepo(repo, null, noBranches())
174}
175
176async function loadUnreleased($: EngineInterface, force: boolean): Promise<Unreleased> {
177 const now = await $.clock.now()
178 if (!force) {
179 const cached = asUnreleased(await $.store.get(UNRELEASED_KEY))
180 if (cached !== null && now - cached.fetchedAt < FRESH_MS) return cached
181 }
182 const radar = (await read($, radarAtom)) ?? asRadar(await $.store.get(STORE_KEY)) ?? EMPTY
183 const known = asReleases(await $.store.get(RELEASES_KEY))
184 const me = await resolveLogin($)
185 const repos = await Promise.all(reposToCheck(settings.repos, radar, settings.integration).map(repo => compareRepo($, repo, known, me)))
186 await $.store.set(RELEASES_KEY, known)
187 const unreleased: Unreleased = { repos, fetchedAt: now }
188 if (repos.some(entry => entry.error === null)) await $.store.set(UNRELEASED_KEY, unreleased)
189 return unreleased
190}
191
192function unreleasedNow($: EngineInterface, force: boolean) {
193 unreleasedInFlight ??= loadUnreleased($, force).finally(() => {
194 unreleasedInFlight = null
195 })
196 return unreleasedInFlight
197}
198
199export const register: Register = (on, options) => {
200 settings = {
201 alerts: options.alerts !== false,
202 sound: options.sound !== false,
203 repos: typeof options.repos === 'string' && options.repos.trim() !== '' ? options.repos : DEFAULT_REPOS,
204 login: typeof options.login === 'string' ? options.login.trim() : '',
205 integration:
206 typeof options.integrationBranch === 'string' && options.integrationBranch.trim() !== ''
207 ? options.integrationBranch.trim()
208 : DEFAULT_INTEGRATION,
209 }
210
211 on('session.start', async ($, e, next) => {
212 await $.command.register({
213 name: 'prs',
214 description: 'Your open PRs across repos: checks, reviews, unresolved threads, links',
215 argumentHint: '[pane|refresh]',
216 })
217 await $.command.register({
218 name: 'unreleased',
219 description: `What is merged to ${settings.integration} but not yet released to prod, per repo`,
220 argumentHint: '[refresh]',
221 })
222 await $.tool.register({
223 name: 'my_prs',
224 description: TOOL_DESCRIPTION,
225 inputSchema: {
226 type: 'object',
227 properties: { refresh: { type: 'boolean', description: 'Fetch from GitHub now instead of the last 2 minutes' } },
228 },
229 })
230 await $.tool.register({
231 name: 'my_unreleased',
232 description: unreleasedDescription(settings.integration),
233 inputSchema: {
234 type: 'object',
235 properties: { refresh: { type: 'boolean', description: 'Compare on GitHub now instead of using the last 2 minutes' } },
236 },
237 })
238 const stored = asRadar(await $.store.get(STORE_KEY))
239 if (stored !== null) await publish($, stored)
240 void refresh($, false)
241 $.clock.every(EVERY_MS, () => void refresh($, false))
242
243 return next(e)
244 })
245
246 on('command.run', { command: 'prs' }, async ($, e) => {
247 const arg = e.args.trim().toLowerCase()
248 if (arg === 'pane') {
249 await $.ui.open({ id: PANE, title: 'Pull requests' })
250 const radar = await refresh($, false)
251 return { text: `PR pane opened · ${summary(radar)}` }
252 }
253 if (arg !== '' && arg !== 'refresh') return { text: 'Usage: /prs, /prs refresh, /prs pane' }
254 const radar = await refresh($, arg === 'refresh')
255
256 return { text: listText(radar, await $.clock.now()) }
257 })
258
259 on('command.run', { command: 'unreleased' }, async ($, e) => {
260 const arg = e.args.trim().toLowerCase()
261 if (arg !== '' && arg !== 'refresh') return { text: 'Usage: /unreleased, /unreleased refresh' }
262 const unreleased = await unreleasedNow($, arg === 'refresh')
263
264 return { text: unreleasedText(unreleased, await $.clock.now(), settings.integration) }
265 })
266
267 on('tool.call', { tool: 'mcp__pr-radar__my_unreleased' }, async ($, e) => {
268 const unreleased = await unreleasedNow($, e.refresh === true)
269
270 return { result: unreleasedText(unreleased, await $.clock.now(), settings.integration) }
271 }).catch(() => ({ deny: `pr-radar could not compare ${settings.integration} with the release branches` }))
272
273 on('tool.call', { tool: 'mcp__pr-radar__my_prs' }, async ($, e) => {
274 const radar = await refresh($, e.refresh === true)
275
276 return { result: listText(radar, await $.clock.now()) }
277 }).catch(() => ({ deny: 'pr-radar could not read PR state' }))
278
279 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
280 const ran = await next(e)
281 if (PR_CHANGING.test(e.command)) $.clock.after(5_000, () => void refresh($, true))
282
283 return ran
284 }).catch(($, e, next) => next(e))
285
286 on('prompt.submit', async ($, e, next) => {
287 const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
288 if (!isPerson || !PR_WORDS.test(e.text)) return next(e)
289 const radar = await read($, radarAtom)
290 if (radar === null || radar.fetchedAt === 0) return next(e)
291
292 return next({ ...e, context: [...(e.context ?? []), contextLine(radar, await $.clock.now(), settings.integration)] })
293 }).catch(($, e, next) => next(e))
294
295 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
296 const { Box, Button, Link, Text } = $.ui.resolve(e)
297 const radar = (await read($, radarAtom)) ?? EMPTY
298 const now = await $.clock.now()
299
300 return (
301 <Box flexDirection="column">
302 <Text bold>{summary(radar)}</Text>
303 <Text dimColor>fetched {age(now, radar.fetchedAt)}</Text>
304 {radar.open.length === 0 && <Text dimColor>No open PRs.</Text>}
305 {byRepo(radar.open).map(([repo, prs]) => (
306 <Box flexDirection="column" marginTop={1}>
307 <Text bold>{shortRepo(repo)}</Text>
308 {prs.map(pr => (
309 <Box flexDirection="column">
310 <Link href={pr.url} label={`#${pr.number} ${pr.title}`} />
311 <Text
312 dimColor={pr.checks !== 'failing' && pr.review !== 'changes'}
313 color={pr.checks === 'failing' || pr.review === 'changes' ? 'error' : undefined}
314 wrap="truncate-end"
315 >
316 {' '}
317 {describePr(pr)}
318 </Text>
319 </Box>
320 ))}
321 </Box>
322 ))}
323 {radar.merged.length > 0 && (
324 <Box flexDirection="column" marginTop={1}>
325 <Text bold>Merged, last 3 days</Text>
326 {radar.merged.map(pr => (
327 <Box flexDirection="column">
328 <Link href={pr.url} label={`${shortRepo(pr.repo)} #${pr.number} ${pr.title}`} />
329 <Text dimColor>
330 {' '}
331 {describeMerged(pr)}
332 </Text>
333 </Box>
334 ))}
335 </Box>
336 )}
337 <Box marginTop={1}>
338 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($, true)} />
339 </Box>
340 </Box>
341 )
342 })
343}
344hooks/radar.ts 410 lines1import type {
2 LooseCommit,
3 MergedPr,
4 OpenPr,
5 PrChecks,
6 PrReview,
7 Radar,
8 RadarChange,
9 Unreleased,
10 UnreleasedPr,
11 UnreleasedRepo,
12} from '../types'
13
14export const QUERY = `query($merged: String!) {
15 viewer {
16 pullRequests(first: 50, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
17 nodes {
18 number title url isDraft baseRefName headRefName reviewDecision mergeable updatedAt
19 repository { nameWithOwner isArchived }
20 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
21 reviewThreads(first: 100) { nodes { isResolved } }
22 }
23 }
24 }
25 merged: search(query: $merged, type: ISSUE, first: 30) {
26 nodes { ... on PullRequest { number title url mergedAt baseRefName repository { nameWithOwner } } }
27 }
28}`
29
30const DAY_MS = 86_400_000
31
32export const EMPTY: Radar = { open: [], merged: [], fetchedAt: 0, error: null }
33
34export const PR_WORDS =
35 /\b(prs?|pull requests?|merg(e|ed|es|ing)|releas(e|ed|es|ing)|approv(e|ed|al)|reviews?|reviewed)\b/i
36
37type RawOpen = {
38 number: number
39 title: string
40 url: string
41 isDraft: boolean
42 baseRefName: string
43 headRefName: string
44 reviewDecision: string | null
45 mergeable: string | null
46 updatedAt: string
47 repository: { nameWithOwner: string; isArchived?: boolean }
48 commits: { nodes: { commit: { statusCheckRollup: { state: string } | null } }[] }
49 reviewThreads: { nodes: { isResolved: boolean }[] }
50}
51
52type RawMerged = {
53 number?: number
54 title?: string
55 url?: string
56 mergedAt?: string
57 baseRefName?: string
58 repository?: { nameWithOwner: string }
59}
60
61type Raw = {
62 data?: {
63 viewer?: { pullRequests?: { nodes?: RawOpen[] } }
64 merged?: { nodes?: RawMerged[] }
65 }
66 errors?: { message?: string }[]
67}
68
69export const mergedQuery = (now: number) =>
70 `author:@me is:pr is:merged merged:>=${new Date(now - 3 * DAY_MS).toISOString().slice(0, 10)} sort:updated-desc`
71
72export const ghArgs = (now: number) => [
73 'gh',
74 'api',
75 'graphql',
76 '-f',
77 `query=${QUERY}`,
78 '-f',
79 `merged=${mergedQuery(now)}`,
80]
81
82const checksOf = (state: string | undefined): PrChecks => {
83 if (state === 'SUCCESS') return 'passing'
84 if (state === 'FAILURE' || state === 'ERROR') return 'failing'
85 if (state === 'PENDING' || state === 'EXPECTED') return 'pending'
86 return 'none'
87}
88
89const reviewOf = (decision: string | null): PrReview => {
90 if (decision === 'APPROVED') return 'approved'
91 if (decision === 'CHANGES_REQUESTED') return 'changes'
92 if (decision === 'REVIEW_REQUIRED') return 'required'
93 return 'none'
94}
95
96const toOpen = (node: RawOpen): OpenPr => ({
97 repo: node.repository.nameWithOwner,
98 number: node.number,
99 title: node.title,
100 url: node.url,
101 isDraft: node.isDraft,
102 base: node.baseRefName,
103 head: node.headRefName,
104 review: reviewOf(node.reviewDecision),
105 checks: checksOf(node.commits.nodes[0]?.commit.statusCheckRollup?.state),
106 hasConflicts: node.mergeable === 'CONFLICTING',
107 unresolved: node.reviewThreads.nodes.filter(thread => !thread.isResolved).length,
108 updatedAt: node.updatedAt,
109})
110
111const toMerged = (node: RawMerged): MergedPr[] =>
112 node.number === undefined || node.url === undefined
113 ? []
114 : [
115 {
116 repo: node.repository?.nameWithOwner ?? '',
117 number: node.number,
118 title: node.title ?? '',
119 url: node.url,
120 base: node.baseRefName ?? '',
121 mergedAt: node.mergedAt ?? '',
122 },
123 ]
124
125export const parse = (stdout: string, now: number): Radar | string => {
126 let raw: Raw
127 try {
128 raw = JSON.parse(stdout) as Raw
129 } catch {
130 return 'unreadable gh output'
131 }
132 const nodes = raw.data?.viewer?.pullRequests?.nodes
133 if (nodes === undefined) return raw.errors?.[0]?.message?.slice(0, 60) ?? 'no data from GitHub'
134 return {
135 open: nodes.filter(node => !node.repository.isArchived).map(toOpen),
136 merged: (raw.data?.merged?.nodes ?? []).flatMap(toMerged),
137 fetchedAt: now,
138 error: null,
139 }
140}
141
142export const reasonOf = (text: string) => {
143 if (/gh auth login|not logged in|authentication|HTTP 401|Bad credentials/i.test(text)) return 'gh not logged in'
144 if (/ENOENT|executable file not found|command not found|cannot start/i.test(text)) return 'gh not installed'
145 if (/could not resolve|no such host|dial tcp|network is unreachable|timed? ?out|connection refused/i.test(text))
146 return 'offline'
147 if (/rate limit/i.test(text)) return 'rate limited'
148 return text.trim().split('\n')[0]?.slice(0, 60) || 'gh failed'
149}
150
151export const asRadar = (value: unknown): Radar | null => {
152 if (typeof value !== 'object' || value === null) return null
153 const radar = value as Partial<Radar>
154 return Array.isArray(radar.open) && Array.isArray(radar.merged) && typeof radar.fetchedAt === 'number'
155 ? { open: radar.open, merged: radar.merged, fetchedAt: radar.fetchedAt, error: radar.error ?? null }
156 : null
157}
158
159export const shortRepo = (repo: string) => repo.split('/').pop() ?? repo
160
161export const summary = (radar: Radar) => {
162 const approved = radar.open.filter(pr => pr.review === 'approved').length
163 const failing = radar.open.filter(pr => pr.checks === 'failing').length
164 const unresolved = radar.open.reduce((sum, pr) => sum + pr.unresolved, 0)
165 const parts = [`PRs ${radar.open.length}`]
166 if (approved > 0) parts.push(`✓${approved} approved`)
167 if (failing > 0) parts.push(`✗${failing} failing`)
168 if (unresolved > 0) parts.push(`${unresolved} unresolved`)
169 if (radar.error !== null) parts.push(`⚠ ${radar.error}`)
170 return parts.join(' · ')
171}
172
173const REVIEW_WORDS: Record<PrReview, string> = {
174 approved: 'approved',
175 changes: 'changes requested',
176 required: 'review required',
177 none: '',
178}
179
180const CHECK_WORDS: Record<PrChecks, string> = {
181 passing: 'checks ✓',
182 failing: 'checks ✗',
183 pending: 'checks running',
184 none: '',
185}
186
187export const describePr = (pr: OpenPr) =>
188 [
189 pr.isDraft ? 'draft' : '',
190 REVIEW_WORDS[pr.review],
191 CHECK_WORDS[pr.checks],
192 pr.unresolved > 0 ? `${pr.unresolved} unresolved` : '',
193 pr.hasConflicts ? 'conflicts' : '',
194 `→ ${pr.base}`,
195 ]
196 .filter(Boolean)
197 .join(' · ')
198
199export const describeMerged = (pr: MergedPr) =>
200 `merged ${pr.mergedAt.slice(0, 10)} → ${pr.base}${/^(master|main)$/.test(pr.base) ? ' (release)' : ''}`
201
202export const byRepo = <T extends { repo: string }>(list: readonly T[]) => {
203 const groups = new Map<string, T[]>()
204 for (const item of list) groups.set(item.repo, [...(groups.get(item.repo) ?? []), item])
205 return [...groups.entries()]
206}
207
208export const age = (now: number, fetchedAt: number) => {
209 if (fetchedAt === 0) return 'never fetched'
210 const minutes = Math.floor((now - fetchedAt) / 60_000)
211 if (minutes < 1) return 'just now'
212 if (minutes < 60) return `${minutes} min ago`
213 return `${Math.floor(minutes / 60)} h ago`
214}
215
216export const listText = (radar: Radar, now: number) => {
217 const lines = [`Open PRs (${radar.open.length}), fetched ${age(now, radar.fetchedAt)}`]
218 if (radar.error !== null) lines.push(`⚠ last refresh failed: ${radar.error}`)
219 for (const [repo, prs] of byRepo(radar.open)) {
220 lines.push('', repo)
221 for (const pr of prs) lines.push(` #${pr.number} ${pr.title}`, ` ${describePr(pr)}`, ` ${pr.url}`)
222 }
223 if (radar.merged.length > 0) {
224 lines.push('', `Merged in the last 3 days (${radar.merged.length})`)
225 for (const pr of radar.merged)
226 lines.push(` ${shortRepo(pr.repo)} #${pr.number} ${pr.title} · ${describeMerged(pr)}`, ` ${pr.url}`)
227 }
228 return lines.join('\n')
229}
230
231export const contextLine = (radar: Radar, now: number, integration = 'staging') => {
232 const open = radar.open.map(pr => `${shortRepo(pr.repo)}#${pr.number} (${describePr(pr)})`).join('; ')
233 const merged = radar.merged
234 .map(pr => `${shortRepo(pr.repo)}#${pr.number} → ${pr.base} on ${pr.mergedAt.slice(0, 10)}`)
235 .join('; ')
236 return [
237 `Live GitHub PR state from pr-radar, fetched ${age(now, radar.fetchedAt)}; trust it over earlier turns.`,
238 `Open: ${open || 'none'}.`,
239 `Merged in the last 3 days: ${merged || 'none'}.`,
240 `Full list with URLs: the mcp__pr-radar__my_prs tool. What is on ${integration} but not yet released to prod: the mcp__pr-radar__my_unreleased tool.`,
241 ].join(' ')
242}
243
244export const DEFAULT_REPOS = ''
245
246export const DEFAULT_INTEGRATION = 'staging'
247
248export const LOGIN_ARGS = ['gh', 'api', 'user', '--jq', '.login']
249
250export const RELEASE_BRANCHES = ['main', 'master'] as const
251
252const COMPARE_JQ =
253 '{ahead: .ahead_by, base: .merge_base_commit.commit.committer.date, commits: [.commits[] | {sha: .sha[0:7], login: (.author.login // ""), message: .commit.message, isMerge: ((.parents | length) > 1)}]}'
254
255export const compareArgs = (repo: string, release: string, integration = DEFAULT_INTEGRATION) => [
256 'gh',
257 'api',
258 `repos/${repo}/compare/${release}...${integration}`,
259 '--jq',
260 COMPARE_JQ,
261]
262
263export const isNotFound = (text: string) => /HTTP 404|Not Found/i.test(text)
264
265export const reposToCheck = (configured: string, radar: Radar, integration = DEFAULT_INTEGRATION) => {
266 const listed = configured
267 .split(',')
268 .map(repo => repo.trim())
269 .filter(repo => /^[\w.-]+\/[\w.-]+$/.test(repo))
270 const seen = [...radar.open, ...radar.merged].filter(pr => pr.base === integration).map(pr => pr.repo)
271 return [...new Set([...listed, ...seen])]
272}
273
274type RawCompare = {
275 ahead?: number
276 base?: string | null
277 commits?: { sha: string; login: string; message: string; isMerge: boolean }[]
278}
279
280const MERGE_PR = /^Merge pull request #(\d+) from (\S+)/
281const SQUASH_PR = /\s*\(#(\d+)\)\s*$/
282
283export const parseCompare = (repo: string, release: string, stdout: string, me = ''): UnreleasedRepo | string => {
284 let raw: RawCompare
285 try {
286 raw = JSON.parse(stdout) as RawCompare
287 } catch {
288 return 'unreadable compare output'
289 }
290 const prs = new Map<number, UnreleasedPr>()
291 const mine: LooseCommit[] = []
292 let others = 0
293 for (const commit of raw.commits ?? []) {
294 const [head = '', ...rest] = commit.message.split('\n')
295 const isMine = me !== '' && commit.login.toLowerCase() === me.toLowerCase()
296 const merge = MERGE_PR.exec(head)
297 if (merge !== null) {
298 const title = rest.map(line => line.trim()).find(Boolean) ?? merge[2] ?? ''
299 prs.set(Number(merge[1]), { number: Number(merge[1]), title, login: commit.login, isMine })
300 continue
301 }
302 const squash = SQUASH_PR.exec(head)
303 if (squash !== null) {
304 prs.set(Number(squash[1]), { number: Number(squash[1]), title: head.replace(SQUASH_PR, ''), login: commit.login, isMine })
305 continue
306 }
307 if (commit.login.endsWith('[bot]')) continue
308 if (isMine) mine.push({ sha: commit.sha, title: head })
309 else others += 1
310 }
311 return {
312 repo,
313 release,
314 ahead: raw.ahead ?? 0,
315 prs: [...prs.values()],
316 mine,
317 others,
318 releasedUpTo: raw.base ?? null,
319 error: null,
320 }
321}
322
323export const failedRepo = (repo: string, release: string | null, error: string): UnreleasedRepo => ({
324 repo,
325 release,
326 ahead: 0,
327 prs: [],
328 mine: [],
329 others: 0,
330 releasedUpTo: null,
331 error,
332})
333
334export const asUnreleased = (value: unknown): Unreleased | null => {
335 if (typeof value !== 'object' || value === null) return null
336 const found = value as Partial<Unreleased>
337 return Array.isArray(found.repos) && typeof found.fetchedAt === 'number'
338 ? { repos: found.repos, fetchedAt: found.fetchedAt }
339 : null
340}
341
342const repoLines = (entry: UnreleasedRepo, integration: string) => {
343 const name = shortRepo(entry.repo)
344 if (entry.error !== null) return [`${name} · ⚠ ${entry.error}`]
345 if (entry.ahead === 0) return [`${name} · nothing unreleased (${entry.release})`]
346 const head = [
347 `${name} · ${entry.ahead} commit${entry.ahead === 1 ? '' : 's'} ahead of ${entry.release}`,
348 entry.releasedUpTo === null ? '' : `last release took ${integration} up to ${entry.releasedUpTo.slice(0, 10)}`,
349 ]
350 .filter(Boolean)
351 .join(' · ')
352 const lines = [head]
353 for (const pr of entry.prs) lines.push(` #${pr.number} ${pr.title} · ${pr.isMine ? 'you' : pr.login || 'unknown'}`)
354 if (entry.mine.length > 0) {
355 lines.push(' your commits outside a PR:')
356 for (const commit of entry.mine) lines.push(` ${commit.sha} ${commit.title}`)
357 }
358 if (entry.others > 0) lines.push(` + ${entry.others} more commit${entry.others === 1 ? '' : 's'} by others`)
359 return lines
360}
361
362export const unreleasedText = (unreleased: Unreleased, now: number, integration = DEFAULT_INTEGRATION) => {
363 const lines = [`On ${integration}, not yet released to prod · checked ${age(now, unreleased.fetchedAt)}`]
364 if (unreleased.repos.length === 0) lines.push('', `No repos to check: set the repos option, or open a PR into ${integration}.`)
365 for (const entry of unreleased.repos) lines.push('', ...repoLines(entry, integration))
366 return lines.join('\n')
367}
368
369const STALE_BASELINE_MS = 24 * 60 * 60_000
370
371export const radarChanges = (previous: Radar, next: Radar): RadarChange[] => {
372 if (next.fetchedAt - previous.fetchedAt > STALE_BASELINE_MS) return []
373 const before = new Map(previous.open.map(pr => [pr.url, pr]))
374 const still = new Set(next.open.map(pr => pr.url))
375 const merged = new Map(next.merged.map(pr => [pr.url, pr]))
376 const changes: RadarChange[] = []
377 const add = (kind: string, url: string, text: string) =>
378 changes.push({ id: `${kind}:${url}:${previous.fetchedAt}`, text })
379 for (const pr of next.open) {
380 const was = before.get(pr.url)
381 if (was === undefined) continue
382 const name = `${shortRepo(pr.repo)} #${pr.number}`
383 if (pr.review !== was.review && pr.review === 'approved') add('approved', pr.url, `${name} approved`)
384 if (pr.review !== was.review && pr.review === 'changes') add('changes', pr.url, `${name}: changes requested`)
385 if (pr.checks === 'failing' && was.checks !== 'failing') add('failing', pr.url, `${name}: checks failing`)
386 if (pr.checks === 'passing' && was.checks === 'failing') add('passing', pr.url, `${name}: checks passing again`)
387 if (pr.unresolved > was.unresolved) {
388 const count = pr.unresolved - was.unresolved
389 add('comments', pr.url, `${name}: ${count} new unresolved comment${count === 1 ? '' : 's'}`)
390 }
391 }
392 for (const was of previous.open) {
393 if (still.has(was.url)) continue
394 const name = `${shortRepo(was.repo)} #${was.number}`
395 const done = merged.get(was.url)
396 if (done !== undefined) add('merged', was.url, `${name} merged → ${done.base}`)
397 else add('closed', was.url, `${name} closed`)
398 }
399 return changes
400}
401
402export const alertText = (changes: readonly RadarChange[]) => {
403 const shown = changes.slice(0, 3).map(change => change.text)
404 const more = changes.length - shown.length
405 return more > 0 ? `${shown.join(' · ')} · +${more} more` : shown.join(' · ')
406}
407
408export const asIds = (value: unknown): string[] =>
409 Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string') : []
410types/index.d.ts 78 lines1export type PrChecks = 'passing' | 'failing' | 'pending' | 'none'
2
3export type PrReview = 'approved' | 'changes' | 'required' | 'none'
4
5export type OpenPr = {
6 repo: string
7 number: number
8 title: string
9 url: string
10 isDraft: boolean
11 base: string
12 head: string
13 review: PrReview
14 checks: PrChecks
15 hasConflicts: boolean
16 unresolved: number
17 updatedAt: string
18}
19
20export type MergedPr = {
21 repo: string
22 number: number
23 title: string
24 url: string
25 base: string
26 mergedAt: string
27}
28
29export type Radar = {
30 open: OpenPr[]
31 merged: MergedPr[]
32 fetchedAt: number
33 error: string | null
34}
35
36export type UnreleasedPr = {
37 number: number
38 title: string
39 login: string
40 isMine: boolean
41}
42
43export type LooseCommit = {
44 sha: string
45 title: string
46}
47
48export type UnreleasedRepo = {
49 repo: string
50 release: string | null
51 ahead: number
52 prs: UnreleasedPr[]
53 mine: LooseCommit[]
54 others: number
55 releasedUpTo: string | null
56 error: string | null
57}
58
59export type Unreleased = {
60 repos: UnreleasedRepo[]
61 fetchedAt: number
62}
63
64export type RadarChange = {
65 id: string
66 text: string
67}
68
69declare module 'claude-code' {
70 interface PluginState {
71 'pr-radar': { radar: Radar | null }
72 }
73 interface McpToolInputs {
74 'mcp__pr-radar__my_prs': { refresh?: boolean }
75 'mcp__pr-radar__my_unreleased': { refresh?: boolean }
76 }
77}
78