SLOPSHOPPER

pr-radar

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…

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-radar
│ ┃ pr-radar ✕ › fix the failing auth test and add an audit log call │ ┃ PRs 0 · ⚠ unreadable gh output │ ┃ fetched never fetched ⏺ Read(src/auth.ts) │ ┃ No open PRs. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Refresh ] ⎿ 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 │ ⎿ pr-radar: Open PRs (0), fetched never fetched │ ⎿ pr-radar: ⚠ last refresh failed: unreadable gh output │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ pr-radar: PRs 0 · ⚠ unreadable gh output

Draws

Pane · pr-radar
PRs 0 · ⚠ unreadable gh output fetched never fetched No open PRs. [ Refresh ]
README

Claude Code mods

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.

Install

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.

rules-guard

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:

  • No direct push to a protected branch, including --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.
  • No plain force push. --force-with-lease is fine.
  • No kubectl or aws calls aimed at production (a context, cluster or profile with "prod" in its name).

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.

pr-radar

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.
  • Claude gets two tools, 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

/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.

usage-pacer

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.

repo-context

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.

stay-awake

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.

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 344 lines
1import { 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}
344
hooks/radar.ts 410 lines
1import 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') : []
410
types/index.d.ts 78 lines
1export 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