SLOPSHOPPER

pr-watch

Watches the GitHub PRs of the repo you are working in: status line, toasts on change, and a /prs pane with CI, review and merge details

newpanecommandtoaststatusprocess
v0.1.0MITupdated 2026-10-15ngxuanbminh/pr-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-watch
│ ┃ Pull requests ✕ › fix the failing auth test and add an audit log call │ ┃ acme/app · not loaded yet · every 120s[ Refr │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⚠ GitHub answered something that is not JSON ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Your PRs (0) ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ None open. ⎿ 3 pass, 1 fail │ ┃ │ ┃ Review requested (0) ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Nothing waiting on you. ✻ Worked for 42s · done 4:20 PM │ │ › /prs │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ pr-watch: app · PRs ⚠ GitHub answered something that is not JSON

Draws

Pane · Pull requests
acme/app · not loaded yet · every 120s [ Refresh ] ⚠ GitHub answered something that is not JSON Your PRs (0) None open. Review requested (0) Nothing waiting on you.
README

pr-watch

A Claude Code mod that keeps an eye on the GitHub pull requests of the repo you're working in: your own PRs and the ones waiting on your review.

The /prs pane and the status line in the Claude Code desktop app

<sub>Illustration with sample data (acme/rocket): the /prs pane on the right and the status line under the prompt.</sub>

What it does

  • Status line. A summary that is always visible under the prompt: rocket · PRs ✓1 ✗1 ●1 · 1 conflict · 2 to review. ✓ means passing CI, ✗ failing, ● running.
  • Toasts when something changes. It notifies you when:
  • CI goes red, or goes green again
  • a PR is approved or has changes requested
  • a merge conflict appears
  • a new review request arrives
  • one of your PRs is merged or closed

The first check in a session only records a starting point, so opening a session doesn't produce a burst of toasts.

  • /prs pane. It lists your PRs and your review requests, each with:
  • CI: passed, failed, running and skipped counts, plus the names of the failing checks
  • Review: who approved, who requested changes, who is still requested (you when it's you), and unresolved threads
  • Merge: ready to merge, conflicts, behind the base branch, blocked by branch rules, or draft
  • Size: additions and deletions, files changed, and when the PR was last updated

Press Refresh (r) to check right away.

  • Ask Claude. Your own PRs get buttons that hand Claude a prompt about the problem. Claude is told not to push or change the PR without asking you first.
  • In the desktop app, the button opens a new Claude Code session in the same folder with the prompt already in its composer. Your current conversation is left alone, and nothing runs until you send the prompt there.
  • In a terminal, or if the desktop app can't be opened, the prompt is queued in the current conversation and starts once it's idle.
  • Failing CI: Ask Claude has Claude read the failing logs with gh and propose a fix.
  • Merge conflict or behind main: Ask Claude has Claude look into the problem and choose the fix itself. It picks between merging main in, rebasing onto main, or GitHub's update-branch (when the PR is only behind), and tells you which it chose and why. It weighs the repo's conventions, branch protection, and existing reviews that a force-push would disrupt. If a PR that's only behind doesn't need updating, Claude says so and stops. Claude works on the branch in a separate git worktree, so your current checkout is left alone, and it runs the tests and type-check. For conflicts, it shows you how it resolved each file.
  • Merge. Your approved PRs that GitHub would merge right now get a merge button, labeled like GitHub's own (Squash and merge, Merge, or Rebase and merge, whichever GitHub preselects for you in that repo).
  • "Right now" means CI is not failing or running, there's no conflict, the PR is not a draft, and branch rules don't block it.
  • Pressing it only asks: Squash and merge #405 into main? [Confirm] [Cancel]. Nothing merges until you press Confirm.
  • The merge is pinned to the head commit the pane showed, so GitHub refuses it if someone pushed after the pane last loaded.
  • It runs by repo name, so it doesn't touch your local checkout or branches. Deleting the branch follows the repo's own setting, as on GitHub.
  • A toast says it merged, or gives GitHub's reason if it refused.
  • Follows the session's repo. It reads the origin remote of the session's folder, so each session shows its own repo. Folders that aren't on github.com get no status line.

Requirements

  • Claude Code with mod (function hook) support. Tested with 2.1.293. Older builds such as 2.1.176 don't load it.
  • GitHub CLI. gh must be installed and logged in (gh auth login) with the repo scope.
  • A github.com remote. The repo needs an origin remote on github.com. GitHub Enterprise hosts aren't supported yet.

Install

Run this in a terminal Claude Code session:

/plugin install pr-watch --marketplace ngxuanbminh/pr-watch
  1. Answer y to add the marketplace.
  2. Choose the user scope, so the mod works in every project.
  3. Optionally set the poll interval.

The mod is active right away and loads in every session you start afterwards. That includes the desktop app's Code tab, which doesn't have the /plugin command itself.

Without the plugin manager

Clone the repo and point Claude Code at the folder in ~/.claude/settings.json:

git clone https://github.com/ngxuanbminh/pr-watch ~/.claude/mods/pr-watch
{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/pr-watch"
  }
}

New sessions pick it up. Restart any session that is already open.

Use only one of the two install methods; otherwise the mod loads twice.

Usage

WhatHow
SummaryAlways in the status line while the session is in a GitHub repo
DetailsType /prs to open the pane
Check nowRefresh in the pane, or r while the pane has focus
Fix failing CIAsk Claude under a failing PR of yours
Fix a conflict or catch up with mainAsk Claude under the Merge line of a PR of yours that conflicts or is behind
Merge an approved PRThe merge button under its Merge line, then Confirm

In the terminal, the pane opens docked beside the conversation when the window is wide enough, and inline when it isn't.

Configuration

OptionDefaultMeaning
pollSeconds120How often to check GitHub; at least 30

Left unset, the default applies. To change it, run /plugin configure pr-watch@pr-watch in Claude Code, or pass it when installing from the shell:

claude plugin install pr-watch@pr-watch --config pollSeconds=300

How it works

Each check runs one gh api graphql request with two searches, both limited to the session's repo:

  • is:open is:pr author:@me repo:<owner>/<name>
  • is:open is:pr review-requested:@me repo:<owner>/<name>

That costs about 6 points of GitHub's 5,000-points-per-hour API limit, so every two minutes is about 180 points an hour per open session. The repo comes from git remote get-url origin in the session's folder, and is checked again when the session moves to another folder.

The mod talks to GitHub only through your gh with your credentials, and sends nothing anywhere else.

Development

claude plugin validate .   # manifest, marketplace file and hooks module
claude plugin test .       # the tests in tests/

Once Claude Code has loaded the mod, it writes its type declarations into .claude-plugin/types/ (gitignored). After that, tsc -p . type-checks the mod.

FileRole
hooks/register.tsxHooks: polling, the /prs command, the pane, toasts, the status line
hooks/github.tsGraphQL query, response parsing, remote URL parsing
hooks/details.tsThe CI, Review, Merge and Size lines
hooks/prompts.tsThe prompts behind the Ask Claude buttons
hooks/merge.tsWhen the merge button shows, and the gh pr merge it runs
hooks/diff.tsChange detection for toasts, and the status line summary

License

MIT

Source 7 files
hooks/register.tsx 395 lines
1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { CiState, MergeMethod, Pr, Snapshot } from '../types'
5import { ciLine, mergeLine, reviewLine, sizeLine } from './details'
6import type { Line } from './details'
7import { CI_ICON, changes, ref, settle, summary, toastText } from './diff'
8import { GH_CANDIDATES, ghArgs, parsePrs, parseRemote, reasonOf } from './github'
9import type { FetchResult } from './github'
10import { canMerge, mergeArgs, mergeFailure, mergeLabel } from './merge'
11import { ciPrompt, newSessionUrl, stalenessOf, updatePrompt } from './prompts'
12
13const PANE = 'pr-watch'
14const snapshot = atom({ plugin: 'pr-watch', key: 'latest' } as const, null)
15const confirming = atom({ plugin: 'pr-watch', key: 'confirming' } as const, null)
16const merging = atom({ plugin: 'pr-watch', key: 'merging' } as const, null)
17
18const CI_COLOR: Record<CiState, Color> = {
19  success: 'success',
20  failure: 'error',
21  pending: 'warning',
22  none: 'subtle',
23}
24
25// CI, review and merge state have their own lines; the header keeps who and what kind.
26const tagsOf = (p: Pr): { text: string; color: Color }[] => [
27  ...(p.isDraft ? [{ text: 'draft', color: 'subtle' }] : []),
28  ...(p.role === 'review' ? [{ text: `by ${p.author}`, color: 'subtle' }] : []),
29]
30
31const hhmm = (ms: number): string => {
32  const at = new Date(ms)
33  const pad = (n: number) => String(n).padStart(2, '0')
34
35  return `${pad(at.getHours())}:${pad(at.getMinutes())}`
36}
37
38// Module state starts over on every reload; the snapshot itself lives in $.state.
39let pollSeconds = 120
40let isPolling = false
41let isPollWanted = false
42let ghPath: string | undefined
43let repoOf: { cwd: string; repo: string | null } | undefined
44// PRs merged from the pane: their "closed or merged" toast would only repeat "Merged".
45const mergedHere = new Set<string>()
46
47/** The session folder's github.com repo (`owner/name`), or null; asks git once per folder. */
48async function sessionRepo($: EngineInterface): Promise<string | null> {
49  const cwd = await $.session.cwd()
50  if (repoOf?.cwd === cwd) return repoOf.repo
51
52  let repo: string | null = null
53  try {
54    const ran = await $.process.run(['git', 'remote', 'get-url', 'origin'], {
55      cwd,
56      timeoutMs: 5_000,
57    })
58    repo = ran.exitCode === 0 ? parseRemote(ran.stdout) : null
59  } catch {
60    repo = null // no git here, or the folder is gone
61  }
62  repoOf = { cwd, repo }
63
64  return repo
65}
66
67type GhRun =
68  | { ok: true; exitCode: number; stdout: string; stderr: string }
69  | { ok: false; error: string }
70
71/** Runs the person's `gh` with `args`, finding it on first use. */
72async function runGh($: EngineInterface, args: readonly string[]): Promise<GhRun> {
73  for (const gh of ghPath === undefined ? GH_CANDIDATES : [ghPath]) {
74    let ran
75    try {
76      ran = await $.process.run([gh, ...args], { timeoutMs: 30_000 })
77    } catch (err) {
78      if (/timed? ?out|still running/i.test(String(err)))
79        return { ok: false, error: 'gh timed out' }
80      continue // not installed here; try the next place
81    }
82    ghPath = gh
83
84    return { ok: true, exitCode: ran.exitCode, stdout: ran.stdout, stderr: ran.stderr }
85  }
86  ghPath = undefined
87
88  return { ok: false, error: 'gh not found' }
89}
90
91/** Runs the repo's PR query. */
92async function fetchPrs($: EngineInterface, repo: string): Promise<FetchResult> {
93  const ran = await runGh($, ghArgs(repo))
94  if (!ran.ok) return ran
95  if (ran.exitCode !== 0) return { ok: false, error: reasonOf(ran.stderr) }
96
97  return parsePrs(ran.stdout)
98}
99
100/** One poll: find the repo, fetch, toast what changed since the last good poll, store, show. */
101async function pollOnce($: EngineInterface): Promise<void> {
102  const repo = await sessionRepo($)
103  if (repo === null) {
104    await update($, snapshot, () => ({
105      repo: null,
106      mergeMethod: null,
107      prs: [],
108      fetchedAt: null,
109      error: null,
110    }))
111    $.ui.status(undefined)
112    return
113  }
114
115  // Another repo's PRs are no baseline: a switch starts over without toasts.
116  const stored = await read($, snapshot)
117  const prev = stored?.repo === repo ? stored : null
118  const result = await fetchPrs($, repo)
119
120  if (!result.ok) {
121    const failed: Snapshot = {
122      repo,
123      mergeMethod: prev?.mergeMethod ?? null,
124      prs: prev?.prs ?? [],
125      fetchedAt: prev?.fetchedAt ?? null,
126      error: result.error,
127    }
128    await update($, snapshot, () => failed)
129    $.ui.status(summary(failed))
130    return
131  }
132
133  const prs = settle(prev?.prs, result.prs)
134  // The first good poll is the baseline: nothing is new to the person yet.
135  if (prev !== null && prev.fetchedAt !== null) {
136    const found = changes(prev.prs, prs).filter(
137      c => !(c.what === 'closed or merged' && mergedHere.has(c.pr.url)),
138    )
139    const text = toastText(found)
140    if (text !== undefined) $.ui.toast(text, { timeoutMs: 10_000 })
141  }
142
143  mergedHere.clear()
144  const fresh: Snapshot = {
145    repo,
146    mergeMethod: result.mergeMethod,
147    prs,
148    fetchedAt: await $.clock.now(),
149    error: null,
150  }
151  await update($, snapshot, () => fresh)
152  $.ui.status(summary(fresh))
153}
154
155/** Polls now; a call while one runs is folded into one more run after it. */
156async function poll($: EngineInterface): Promise<void> {
157  if (isPolling) {
158    isPollWanted = true
159    return
160  }
161  isPolling = true
162  try {
163    do {
164      isPollWanted = false
165      await pollOnce($)
166    } while (isPollWanted)
167  } catch (err) {
168    $.ui.status(`PRs ⚠ ${String(err).slice(0, 80)}`)
169  } finally {
170    isPolling = false
171  }
172}
173
174/**
175 * Hands `text` to Claude; `what` names the ask in the toast. In the desktop
176 * app it opens a new Claude Code session on this session's folder with the
177 * prompt in its composer, for the person to send; elsewhere, or if that
178 * fails, it queues the prompt in this conversation.
179 */
180async function askClaude(
181  $: EngineInterface,
182  p: Pr,
183  text: string,
184  what: string,
185  surface: RenderSurface,
186): Promise<void> {
187  if (surface === 'desktop') {
188    try {
189      const url = newSessionUrl(await $.session.cwd(), text)
190      const ran = await $.process.run(['/usr/bin/open', url], { timeoutMs: 10_000 })
191      if (ran.exitCode === 0) {
192        $.ui.toast(`Opened a new session to ${what} on ${ref(p)}; send the prompt there to start`)
193        return
194      }
195    } catch {
196      // No `open` on this machine: ask in this conversation instead.
197    }
198  }
199
200  void $.prompt
201    .submit({ text, asUser: true })
202    .catch(() => $.ui.toast(`pr-watch: could not queue the prompt for ${ref(p)}`))
203  $.ui.toast(`Asked Claude to ${what} on ${ref(p)}; it starts once the session is idle`)
204}
205
206/** Merges `p` as the pane showed it (Confirm was pressed), says how it went, and refreshes. */
207async function mergePr($: EngineInterface, p: Pr, method: MergeMethod): Promise<void> {
208  if ((await read($, merging)) !== null) return
209  await update($, confirming, () => null)
210  await update($, merging, () => p.url)
211
212  let said: string
213  try {
214    const ran = await runGh($, mergeArgs(p, method))
215    if (!ran.ok) said = `Could not merge ${ref(p)}: ${ran.error}`
216    else if (ran.exitCode !== 0) said = `Could not merge ${ref(p)}: ${mergeFailure(ran.stderr)}`
217    else {
218      mergedHere.add(p.url)
219      said = `Merged ${ref(p)} into ${p.baseBranch || 'main'}`
220    }
221  } finally {
222    await update($, merging, () => null)
223  }
224  $.ui.toast(said, { timeoutMs: 10_000 })
225  await poll($)
226}
227
228export const register: Register = (on, options) => {
229  pollSeconds = Math.max(30, Number(options.pollSeconds ?? 120) || 120)
230
231  on('session.start', async ($, e, next) => {
232    await $.command.register({
233      name: 'prs',
234      description: "Show your open PRs and review requests in this session's GitHub repo",
235    })
236
237    // After a hot reload the session's last snapshot is still there.
238    const prev = await read($, snapshot)
239    if (prev !== null) $.ui.status(summary(prev))
240
241    void poll($)
242    $.clock.every(pollSeconds * 1000, () => void poll($))
243
244    return next(e)
245  })
246
247  // The session moved to another folder, maybe another repo: look again now.
248  on('classic.CwdChanged', ($, e, next) => {
249    void poll($)
250
251    return next(e)
252  })
253
254  // Opens the pane and says nothing in the transcript.
255  on('command.run', { command: 'prs' }, async $ => {
256    const opened = await $.ui.open({ id: PANE, title: 'Pull requests' })
257    if (!opened.isPlaced) $.ui.toast('pr-watch: the PR pane opens once the window has room')
258    void poll($)
259
260    return {}
261  })
262
263  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
264    const { Box, Button, Link, Text } = $.ui.resolve(e)
265    const snap = await read($, snapshot)
266
267    if (snap === null) return <Text dimColor>Checking GitHub…</Text>
268    if (snap.repo === null) {
269      return (
270        <Text dimColor>
271          This session's folder is not a GitHub repo (no github.com origin remote), so there are no
272          PRs to show.
273        </Text>
274      )
275    }
276
277    const confirmingUrl = await read($, confirming)
278    const mergingUrl = await read($, merging)
279    const method = snap.mergeMethod ?? null
280    const mine = snap.prs.filter(p => p.role === 'mine')
281    const asked = snap.prs.filter(p => p.role === 'review')
282    const now = snap.fetchedAt ?? 0
283
284    const detail = (label: string, line: Line) => (
285      <Box flexDirection="row" paddingLeft={2}>
286        <Box width={8} flexShrink={0}>
287          <Text dimColor>{label}</Text>
288        </Box>
289        <Text color={line.color ?? 'text'}>{line.text}</Text>
290      </Box>
291    )
292
293    const row = (p: Pr) => {
294      const stale = p.role === 'mine' ? stalenessOf(p) : undefined
295      const mergeWith = method !== null && canMerge(p) ? method : null
296      const isConfirming = mergeWith !== null && confirmingUrl === p.url && mergingUrl !== p.url
297
298      return (
299        <Box flexDirection="column">
300          <Box flexDirection="row" gap={1}>
301            <Text color={CI_COLOR[p.ci]}>{CI_ICON[p.ci]}</Text>
302            <Link href={p.url} label={`#${p.number}`} />
303            {tagsOf(p).map(tag => (
304              <Text color={tag.color}>{tag.text}</Text>
305            ))}
306          </Box>
307          <Box paddingLeft={2}>
308            <Text dimColor wrap="truncate-end">
309              {p.title}
310            </Text>
311          </Box>
312          {detail('CI', ciLine(p))}
313          {p.role === 'mine' && p.ci === 'failure' && (
314            <Box paddingLeft={10}>
315              <Button
316                key={`ask-ci:${p.url}`}
317                label="Ask Claude"
318                onPress={() => askClaude($, p, ciPrompt(p), 'look into failing CI', e.surface)}
319              />
320            </Box>
321          )}
322          {detail('Review', reviewLine(p))}
323          {detail('Merge', mergeLine(p))}
324          {(stale !== undefined || mergeWith !== null) && (
325            <Box paddingLeft={10} flexDirection="row" gap={1} flexWrap="wrap">
326              {stale !== undefined && (
327                <Button
328                  key={`ask-update:${p.url}`}
329                  label="Ask Claude"
330                  onPress={() =>
331                    askClaude(
332                      $,
333                      p,
334                      updatePrompt(p, stale),
335                      stale === 'conflict'
336                        ? 'fix the merge conflicts'
337                        : `bring it up to date with ${p.baseBranch || 'main'}`,
338                      e.surface,
339                    )
340                  }
341                />
342              )}
343              {mergeWith !== null && mergingUrl === p.url && <Text color="warning">Merging…</Text>}
344              {mergeWith !== null && mergingUrl !== p.url && !isConfirming && (
345                <Button
346                  key={`merge:${p.url}`}
347                  label={mergeLabel(mergeWith)}
348                  onPress={() => update($, confirming, () => p.url)}
349                />
350              )}
351            </Box>
352          )}
353          {mergeWith !== null && isConfirming && (
354            <Box paddingLeft={10} flexDirection="row" gap={1} flexWrap="wrap">
355              <Text color="warning">
356                {`${mergeLabel(mergeWith)} #${p.number} into ${p.baseBranch || 'main'}?`}
357              </Text>
358              <Button
359                key={`merge-confirm:${p.url}`}
360                label="Confirm"
361                variant="primary"
362                onPress={() => mergePr($, p, mergeWith)}
363              />
364              <Button
365                key={`merge-cancel:${p.url}`}
366                label="Cancel"
367                onPress={() => update($, confirming, () => null)}
368              />
369            </Box>
370          )}
371          {detail('Size', sizeLine(p, now))}
372        </Box>
373      )
374    }
375
376    return (
377      <Box flexDirection="column" gap={1}>
378        <Box flexDirection="row" justifyContent="space-between">
379          <Text dimColor wrap="truncate-end">
380            {snap.repo} ·{' '}
381            {snap.fetchedAt === null ? 'not loaded yet' : `updated ${hhmm(snap.fetchedAt)}`} · every{' '}
382            {pollSeconds}s
383          </Text>
384          <Button key="refresh" label="Refresh" hotkey="r" onPress={() => poll($)} />
385        </Box>
386        {snap.error !== null && <Text color="error">⚠ {snap.error}</Text>}
387        <Text bold>Your PRs ({mine.length})</Text>
388        {mine.length === 0 ? <Text dimColor>None open.</Text> : mine.map(row)}
389        <Text bold>Review requested ({asked.length})</Text>
390        {asked.length === 0 ? <Text dimColor>Nothing waiting on you.</Text> : asked.map(row)}
391      </Box>
392    )
393  })
394}
395
hooks/details.ts 96 lines
1import type { Color } from 'claude-code'
2
3import type { Pr } from '../types'
4
5/** One detail line of a PR in the pane: its words and, when it signals something, a color. */
6export type Line = { text: string; color?: Color }
7
8/** `a, b, c +2`: at most three names, then how many more. */
9const names = (list: readonly string[]): string =>
10  list.length > 3 ? `${list.slice(0, 3).join(', ')} +${list.length - 3}` : list.join(', ')
11
12const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
13
14/** `✗ 2 failed (checks, test) · 10 passed`: the head commit's checks by outcome. */
15export const ciLine = (p: Pr): Line => {
16  const { passed, pending, skipped } = p.checks
17  // The named failures count even where GitHub's tally of them is missing.
18  const failed = Math.max(p.checks.failed, p.failedChecks.length)
19  if (passed + failed + pending + skipped === 0) return { text: 'no checks', color: 'subtle' }
20
21  const failedNames = p.failedChecks.length > 0 ? ` (${names(p.failedChecks)})` : ''
22  const parts = [
23    ...(failed > 0 ? [`${failed} failed${failedNames}`] : []),
24    ...(pending > 0 ? [`${pending} running`] : []),
25    ...(passed > 0 ? [`${passed} passed`] : []),
26    ...(skipped > 0 ? [`${skipped} skipped`] : []),
27  ].join(' · ')
28
29  if (failed > 0) return { text: `✗ ${parts}`, color: 'error' }
30  if (pending > 0) return { text: `● ${parts}`, color: 'warning' }
31
32  return { text: `✓ ${parts}`, color: 'success' }
33}
34
35/** `✓ approved by a · waiting on you · 2 unresolved threads`. */
36export const reviewLine = (p: Pr): Line => {
37  const { approvedBy, changesBy, waitingOn, unresolvedThreads } = p.reviews
38  const parts = [
39    ...(approvedBy.length > 0 ? [`✓ approved by ${names(approvedBy)}`] : []),
40    ...(changesBy.length > 0 ? [`✎ changes requested by ${names(changesBy)}`] : []),
41    ...(waitingOn.length > 0 ? [`waiting on ${names(waitingOn)}`] : []),
42  ]
43  if (parts.length === 0) parts.push(p.review === 'required' ? 'review required' : 'no reviews yet')
44  if (unresolvedThreads > 0) parts.push(`${plural(unresolvedThreads, 'unresolved thread')}`)
45
46  const text = parts.join(' · ')
47  if (changesBy.length > 0) return { text, color: 'error' }
48  if (waitingOn.includes('you')) return { text, color: 'warning' }
49  if (p.review === 'approved') return { text, color: 'success' }
50
51  return { text }
52}
53
54/** Whether GitHub would let it merge now, and if not, why. */
55export const mergeLine = (p: Pr): Line => {
56  const base = p.baseBranch || 'the base branch'
57  if (p.merge === 'conflict' || p.mergeStatus === 'conflict') {
58    return { text: `⚠ conflicts with ${base}`, color: 'warning' }
59  }
60
61  switch (p.mergeStatus) {
62    case 'ready':
63      return { text: '✓ ready to merge', color: 'success' }
64    case 'behind':
65      return { text: `behind ${base}`, color: 'warning' }
66    case 'blocked':
67      return { text: 'blocked by branch rules (reviews or required checks)' }
68    case 'unstable':
69      return { text: 'mergeable, but some checks fail', color: 'warning' }
70    case 'draft':
71      return { text: 'draft, not ready for review', color: 'subtle' }
72    case 'unknown':
73      return { text: 'GitHub is still checking', color: 'subtle' }
74  }
75}
76
77/** `3h ago`, from a span in milliseconds. */
78export const ago = (ms: number): string => {
79  const minutes = Math.floor(Math.max(0, ms) / 60_000)
80  if (minutes < 1) return 'just now'
81  if (minutes < 60) return `${minutes}m ago`
82  if (minutes < 60 * 24) return `${Math.floor(minutes / 60)}h ago`
83
84  return `${Math.floor(minutes / (60 * 24))}d ago`
85}
86
87/** `+120 −30 · 4 files · updated 2h ago`, the age as of `now`. */
88export const sizeLine = (p: Pr, now: number): Line => {
89  const updated = p.updatedAt > 0 ? [`updated ${ago(now - p.updatedAt)}`] : []
90  const text = [`+${p.additions} −${p.deletions}`, plural(p.changedFiles, 'file'), ...updated].join(
91    ' · ',
92  )
93
94  return { text, color: 'subtle' }
95}
96
hooks/diff.ts 124 lines
1import type { CiState, Pr, Snapshot } from '../types'
2
3/** One status change worth telling the person about. */
4export type Change = { icon: string; what: string; pr: Pr }
5
6/** `app#123`: the repo without its owner, then the number. */
7export const ref = (pr: Pr): string => `${pr.repo.split('/').pop()}#${pr.number}`
8
9export const CI_ICON: Record<CiState, string> = {
10  success: '✓',
11  failure: '✗',
12  pending: '●',
13  none: '·',
14}
15
16/**
17 * GitHub answers `UNKNOWN` for mergeability and merge state while it
18 * recomputes (after every push); keep the previous known values so a conflict
19 * is not reported twice and the Merge line and its buttons don't flicker.
20 */
21export const settle = (prev: readonly Pr[] | undefined, next: readonly Pr[]): Pr[] => {
22  const before = new Map((prev ?? []).map(p => [p.url, p]))
23
24  return next.map(p => {
25    const old = before.get(p.url)
26
27    if (old === undefined) return p
28
29    return {
30      ...p,
31      merge: p.merge === 'unknown' ? old.merge : p.merge,
32      mergeStatus: p.mergeStatus === 'unknown' ? old.mergeStatus : p.mergeStatus,
33    }
34  })
35}
36
37/** What changed between two polls, in the order of the new poll. */
38export const changes = (prev: readonly Pr[], next: readonly Pr[]): Change[] => {
39  const before = new Map(prev.map(p => [p.url, p]))
40  const after = new Set(next.map(p => p.url))
41  const found: Change[] = []
42
43  for (const p of next) {
44    const old = before.get(p.url)
45
46    if (old === undefined) {
47      if (p.role === 'review') found.push({ icon: '👀', what: 'review requested', pr: p })
48      continue
49    }
50    if (p.role !== 'mine') continue
51
52    if (p.ci === 'failure' && old.ci !== 'failure') {
53      found.push({ icon: '✗', what: 'CI failed', pr: p })
54    }
55    if (p.ci === 'success' && old.ci !== 'success') {
56      found.push({ icon: '✓', what: 'CI passed', pr: p })
57    }
58    if (p.review !== old.review && p.review === 'approved') {
59      found.push({ icon: '✓', what: 'approved', pr: p })
60    }
61    if (p.review !== old.review && p.review === 'changes') {
62      found.push({ icon: '✎', what: 'changes requested', pr: p })
63    }
64    if (p.merge === 'conflict' && old.merge !== 'conflict') {
65      found.push({ icon: '⚠', what: 'merge conflict', pr: p })
66    }
67  }
68
69  for (const p of prev) {
70    if (p.role === 'mine' && !after.has(p.url)) {
71      found.push({ icon: '⏏', what: 'closed or merged', pr: p })
72    }
73  }
74
75  return found
76}
77
78/** One toast line for a poll's changes, or undefined when there are none. */
79export const toastText = (found: readonly Change[]): string | undefined => {
80  const [only] = found
81  if (only === undefined) return undefined
82  if (found.length === 1) return `${only.icon} ${ref(only.pr)} ${only.what}: ${only.pr.title}`
83
84  const list = found.map(c => `${c.icon} ${ref(c.pr)} ${c.what}`).join(' · ')
85
86  return `${found.length} PR updates: ${list}`
87}
88
89/**
90 * The status line, `app · PRs ✓6 ✗1 ●2 · 2 conflicts · 2 to review`, or
91 * undefined (no line) when the session is not in a GitHub repo.
92 */
93export const summary = (snap: Snapshot): string | undefined => {
94  if (snap.repo === null) return undefined
95
96  return `${snap.repo.split('/').pop()} · ${countsOf(snap)}`
97}
98
99const countsOf = (snap: Snapshot): string => {
100  const error = snap.error === null ? [] : [`⚠ ${snap.error}`]
101  if (snap.fetchedAt === null) return `PRs ${error.join('') || '…'}`
102
103  const mine = snap.prs.filter(p => p.role === 'mine')
104  const toReview = snap.prs.length - mine.length
105  const count = (ci: CiState) => mine.filter(p => p.ci === ci).length
106  const conflicts = mine.filter(p => p.merge === 'conflict').length
107
108  const ci =
109    (['success', 'failure', 'pending'] as const)
110      .filter(state => count(state) > 0)
111      .map(state => `${CI_ICON[state]}${count(state)}`)
112      .join(' ') || (mine.length > 0 ? `${mine.length} open` : '')
113  const parts = [
114    ...(conflicts > 0 ? [`${conflicts} conflict${conflicts === 1 ? '' : 's'}`] : []),
115    ...(toReview > 0 ? [`${toReview} to review`] : []),
116    ...error,
117  ]
118
119  if (ci === '' && parts.length === 0) return 'PRs: none open'
120  if (ci === '') return `PRs ${parts.join(' · ')}`
121
122  return [`PRs ${ci}`, ...parts].join(' · ')
123}
124
hooks/github.ts 291 lines
1import type {
2  Checks,
3  CiState,
4  MergeMethod,
5  MergeState,
6  MergeStatus,
7  Pr,
8  ReviewState,
9  Reviews,
10} from '../types'
11
12// Two searches in one request: the PRs you opened in the repo, and the ones
13// there waiting on your review, plus the merge method GitHub preselects for
14// you there. About 6 rate-limit points per poll.
15export const QUERY = `
16query($mine: String!, $review: String!, $owner: String!, $name: String!) {
17  viewer { login }
18  repo: repository(owner: $owner, name: $name) { viewerDefaultMergeMethod }
19  mine: search(query: $mine, type: ISSUE, first: 50) { nodes { ...pr } }
20  review: search(query: $review, type: ISSUE, first: 50) { nodes { ...pr } }
21}
22fragment pr on PullRequest {
23  number title url isDraft mergeable mergeStateStatus reviewDecision
24  headRefName headRefOid baseRefName updatedAt additions deletions changedFiles
25  author { login }
26  repository { nameWithOwner }
27  latestOpinionatedReviews(first: 10) { nodes { state author { login } } }
28  reviewRequests(first: 10) { nodes { requestedReviewer {
29    __typename
30    ... on User { login }
31    ... on Team { name }
32    ... on Bot { login }
33    ... on Mannequin { login }
34  } } }
35  reviewThreads(first: 50) { nodes { isResolved } }
36  commits(last: 1) { nodes { commit { statusCheckRollup {
37    state
38    contexts(first: 50) {
39      checkRunCountsByState { state count }
40      statusContextCountsByState { state count }
41      nodes {
42        __typename
43        ... on CheckRun { name conclusion }
44        ... on StatusContext { context state }
45      }
46    }
47  } } } }
48}`
49
50// The session's PATH may not reach Homebrew's bin (an app-started session).
51export const GH_CANDIDATES = ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh']
52/** The `gh` arguments of one poll, both searches held to `repo` (`owner/name`). */
53export const ghArgs = (repo: string): string[] => {
54  const [owner = '', name = ''] = repo.split('/')
55
56  return [
57    'api',
58    'graphql',
59    '-f',
60    `query=${QUERY}`,
61    '-f',
62    `mine=is:open is:pr archived:false author:@me repo:${repo}`,
63    '-f',
64    `review=is:open is:pr archived:false review-requested:@me repo:${repo}`,
65    '-f',
66    `owner=${owner}`,
67    '-f',
68    `name=${name}`,
69  ]
70}
71
72/**
73 * `owner/name` of a github.com remote URL (https, ssh or scp form), or null
74 * for any other host or a URL that names no repo.
75 */
76export const parseRemote = (url: string): string | null => {
77  const found = /github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(url.trim())
78
79  return found === null ? null : `${found[1]}/${found[2]}`
80}
81
82// Check run conclusions and status context states, by how the pane counts them.
83const FAILED = new Set([
84  'FAILURE',
85  'ERROR',
86  'TIMED_OUT',
87  'ACTION_REQUIRED',
88  'STARTUP_FAILURE',
89  'CANCELLED',
90])
91const PASSED = new Set(['SUCCESS'])
92const PENDING = new Set(['PENDING', 'EXPECTED', 'IN_PROGRESS', 'QUEUED', 'WAITING'])
93const SKIPPED = new Set(['SKIPPED', 'NEUTRAL', 'STALE'])
94
95type RawCount = { state?: string; count?: number }
96type RawContext = {
97  __typename?: string
98  name?: string
99  conclusion?: string | null
100  context?: string
101  state?: string
102}
103type RawPr = {
104  number?: number
105  title?: string
106  url?: string
107  isDraft?: boolean
108  mergeable?: string
109  mergeStateStatus?: string
110  reviewDecision?: string | null
111  headRefName?: string
112  headRefOid?: string
113  baseRefName?: string
114  updatedAt?: string
115  additions?: number
116  deletions?: number
117  changedFiles?: number
118  author?: { login?: string } | null
119  repository?: { nameWithOwner?: string }
120  latestOpinionatedReviews?: { nodes?: { state?: string; author?: { login?: string } | null }[] }
121  reviewRequests?: {
122    nodes?: { requestedReviewer?: { login?: string; name?: string } | null }[]
123  }
124  reviewThreads?: { nodes?: { isResolved?: boolean }[] }
125  commits?: {
126    nodes?: {
127      commit?: {
128        statusCheckRollup?: {
129          state?: string
130          contexts?: {
131            checkRunCountsByState?: RawCount[]
132            statusContextCountsByState?: RawCount[]
133            nodes?: RawContext[]
134          }
135        } | null
136      }
137    }[]
138  }
139}
140type RawResponse = {
141  data?: {
142    viewer?: { login?: string }
143    repo?: { viewerDefaultMergeMethod?: string } | null
144    mine?: { nodes?: RawPr[] }
145    review?: { nodes?: RawPr[] }
146  }
147  errors?: { message?: string }[]
148}
149
150export type FetchResult =
151  | { ok: true; prs: Pr[]; mergeMethod: MergeMethod | null }
152  | { ok: false; error: string }
153
154const METHODS: Record<string, MergeMethod> = { MERGE: 'merge', SQUASH: 'squash', REBASE: 'rebase' }
155
156const ciOf = (state: string | undefined): CiState => {
157  if (state === 'SUCCESS') return 'success'
158  if (state === 'FAILURE' || state === 'ERROR') return 'failure'
159  if (state === 'PENDING' || state === 'EXPECTED') return 'pending'
160  return 'none'
161}
162
163const reviewOf = (decision: string | null | undefined): ReviewState => {
164  if (decision === 'APPROVED') return 'approved'
165  if (decision === 'CHANGES_REQUESTED') return 'changes'
166  if (decision === 'REVIEW_REQUIRED') return 'required'
167  return 'none'
168}
169
170const mergeOf = (mergeable: string | undefined): MergeState => {
171  if (mergeable === 'MERGEABLE') return 'clean'
172  if (mergeable === 'CONFLICTING') return 'conflict'
173  return 'unknown'
174}
175
176const MERGE_STATUS: Record<string, MergeStatus> = {
177  CLEAN: 'ready',
178  HAS_HOOKS: 'ready',
179  BEHIND: 'behind',
180  BLOCKED: 'blocked',
181  UNSTABLE: 'unstable',
182  DIRTY: 'conflict',
183  DRAFT: 'draft',
184}
185
186const checksOf = (counts: readonly RawCount[]): Checks => {
187  const checks: Checks = { passed: 0, failed: 0, pending: 0, skipped: 0 }
188  for (const { state = '', count = 0 } of counts) {
189    if (PASSED.has(state)) checks.passed += count
190    else if (FAILED.has(state)) checks.failed += count
191    else if (PENDING.has(state)) checks.pending += count
192    else if (SKIPPED.has(state)) checks.skipped += count
193  }
194
195  return checks
196}
197
198const reviewsOf = (raw: RawPr, viewer: string | undefined): Reviews => {
199  const by = (state: string) =>
200    (raw.latestOpinionatedReviews?.nodes ?? [])
201      .filter(r => r.state === state)
202      .map(r => r.author?.login ?? 'ghost')
203  const waitingOn = (raw.reviewRequests?.nodes ?? [])
204    .map(r => r.requestedReviewer?.login ?? r.requestedReviewer?.name)
205    .filter((name): name is string => name !== undefined)
206    .map(name => (name === viewer ? 'you' : name))
207
208  return {
209    approvedBy: by('APPROVED'),
210    changesBy: by('CHANGES_REQUESTED'),
211    waitingOn,
212    unresolvedThreads: (raw.reviewThreads?.nodes ?? []).filter(t => t.isResolved === false).length,
213  }
214}
215
216const toPr = (raw: RawPr, role: Pr['role'], viewer: string | undefined): Pr | undefined => {
217  if (raw.url === undefined || raw.number === undefined) return undefined
218
219  const rollup = raw.commits?.nodes?.[0]?.commit?.statusCheckRollup ?? null
220  const contexts = rollup?.contexts
221  const failedChecks = (contexts?.nodes ?? [])
222    .filter(c => FAILED.has(c.conclusion ?? c.state ?? ''))
223    .map(c => c.name ?? c.context ?? 'unnamed check')
224  const updatedAt = Date.parse(raw.updatedAt ?? '')
225
226  return {
227    url: raw.url,
228    repo: raw.repository?.nameWithOwner ?? '',
229    number: raw.number,
230    title: raw.title ?? '',
231    author: raw.author?.login ?? 'ghost',
232    branch: raw.headRefName ?? '',
233    baseBranch: raw.baseRefName ?? '',
234    headSha: raw.headRefOid ?? '',
235    isDraft: raw.isDraft === true,
236    ci: ciOf(rollup?.state),
237    checks: checksOf([
238      ...(contexts?.checkRunCountsByState ?? []),
239      ...(contexts?.statusContextCountsByState ?? []),
240    ]),
241    failedChecks,
242    review: reviewOf(raw.reviewDecision),
243    reviews: reviewsOf(raw, viewer),
244    merge: mergeOf(raw.mergeable),
245    mergeStatus: MERGE_STATUS[raw.mergeStateStatus ?? ''] ?? 'unknown',
246    additions: raw.additions ?? 0,
247    deletions: raw.deletions ?? 0,
248    changedFiles: raw.changedFiles ?? 0,
249    updatedAt: Number.isNaN(updatedAt) ? 0 : updatedAt,
250    role,
251  }
252}
253
254/** The PRs in a GraphQL answer: yours first, then review requests not already listed. */
255export const parsePrs = (body: string): FetchResult => {
256  let raw: RawResponse
257  try {
258    raw = JSON.parse(body) as RawResponse
259  } catch {
260    return { ok: false, error: 'GitHub answered something that is not JSON' }
261  }
262  const message = raw.errors?.[0]?.message
263  if (message !== undefined || raw.data === undefined) {
264    return { ok: false, error: message ?? 'GitHub answered no data' }
265  }
266
267  const viewer = raw.data.viewer?.login
268  const mine = (raw.data.mine?.nodes ?? []).map(p => toPr(p, 'mine', viewer))
269  const review = (raw.data.review?.nodes ?? []).map(p => toPr(p, 'review', viewer))
270  const prs: Pr[] = []
271  const seen = new Set<string>()
272  for (const p of [...mine, ...review]) {
273    if (p === undefined || seen.has(p.url)) continue
274    seen.add(p.url)
275    prs.push(p)
276  }
277
278  const mergeMethod = METHODS[raw.data.repo?.viewerDefaultMergeMethod ?? ''] ?? null
279
280  return { ok: true, prs, mergeMethod }
281}
282
283/** A short reason for a failed `gh` run, fit for the status line. */
284export const reasonOf = (stderr: string): string => {
285  if (/auth login|not logged in/i.test(stderr)) return 'gh not logged in'
286  if (/could not resolve|network|timeout|dial tcp/i.test(stderr)) return 'GitHub unreachable'
287  const line = stderr.trim().split('\n')[0] ?? ''
288
289  return line === '' ? 'gh failed' : line.slice(0, 80)
290}
291
hooks/merge.ts 53 lines
1import type { MergeMethod, Pr } from '../types'
2
3/** Approved: GitHub's review decision says so, or, where no review is required, someone approved and nobody asked for changes. */
4const isApproved = (p: Pr): boolean =>
5  p.review === 'approved' ||
6  (p.review === 'none' && p.reviews.approvedBy.length > 0 && p.reviews.changesBy.length === 0)
7
8/**
9 * Whether a PR of mine gets the merge button: approved, and nothing GitHub
10 * would refuse it for. Behind is allowed; a repo that requires up-to-date
11 * branches refuses it, and the toast says why.
12 */
13export const canMerge = (p: Pr): boolean =>
14  p.role === 'mine' &&
15  isApproved(p) &&
16  !p.isDraft &&
17  p.merge !== 'conflict' &&
18  (p.mergeStatus === 'ready' || p.mergeStatus === 'behind') &&
19  p.ci !== 'failure' &&
20  p.ci !== 'pending' &&
21  p.headSha !== ''
22
23/**
24 * The `gh` arguments that merge `p`: by `--repo` and without
25 * `--delete-branch`, so no local checkout or branch is touched, and pinned to
26 * the head commit the pane showed, so a later push is refused. Deleting the
27 * branch is left to the repo's own setting, as on GitHub.
28 */
29export const mergeArgs = (p: Pr, method: MergeMethod): string[] => [
30  'pr',
31  'merge',
32  String(p.number),
33  '--repo',
34  p.repo,
35  `--${method}`,
36  '--match-head-commit',
37  p.headSha,
38]
39
40/** The button label, as GitHub words its own. */
41export const mergeLabel = (method: MergeMethod): string =>
42  ({ merge: 'Merge', squash: 'Squash and merge', rebase: 'Rebase and merge' })[method]
43
44/** Why `gh pr merge` refused, from its standard error, fit for a toast. */
45export const mergeFailure = (stderr: string): string => {
46  if (/head branch was modified|head commit/i.test(stderr)) {
47    return 'the PR has new commits since the pane last loaded; refresh and look again'
48  }
49  const line = (stderr.trim().split('\n')[0] ?? '').replace(/^[X✗]\s+/, '')
50
51  return line === '' ? 'gh pr merge failed' : line.slice(0, 200)
52}
53
hooks/prompts.ts 61 lines
1import type { Pr } from '../types'
2
3/** Why a PR of mine is out of date with its base branch, if it is. */
4export type Staleness = 'conflict' | 'behind'
5
6export const stalenessOf = (p: Pr): Staleness | undefined => {
7  if (p.merge === 'conflict' || p.mergeStatus === 'conflict') return 'conflict'
8  if (p.mergeStatus === 'behind') return 'behind'
9  return undefined
10}
11
12const header = (p: Pr) => `My pull request ${p.repo}#${p.number} "${p.title}" (${p.url})`
13
14/** The prompt behind Ask Claude on a PR of mine whose CI fails. */
15export const ciPrompt = (p: Pr): string =>
16  [
17    `CI is failing on ${header(p)}, branch \`${p.branch}\`.`,
18    `Failed checks: ${p.failedChecks.length > 0 ? p.failedChecks.join(', ') : 'not named by GitHub'}.`,
19    `Use \`gh pr checks ${p.number} --repo ${p.repo}\` and \`gh run view <run-id> --repo ${p.repo} --log-failed\` to read the failing logs.`,
20    'Explain the root cause and propose a fix. Do not push or change the PR without asking me first.',
21  ].join('\n')
22
23/**
24 * The prompt behind Ask Claude on a PR of mine that conflicts with, or is
25 * behind, its base branch: Claude looks into it and picks the fix itself.
26 */
27export const updatePrompt = (p: Pr, why: Staleness): string => {
28  const base = p.baseBranch || 'main'
29  const isConflict = why === 'conflict'
30  const options = isConflict
31    ? `merging \`${base}\` into the branch or rebasing it onto \`${base}\``
32    : `merging \`${base}\` into the branch, rebasing it onto \`${base}\`, or GitHub's update-branch (\`gh pr update-branch ${p.number} --repo ${p.repo}\`, with or without \`--rebase\`)`
33
34  return [
35    `${header(p)} ${isConflict ? 'has merge conflicts with' : 'is behind'} \`${base}\`; its branch is \`${p.branch}\`.`,
36    'Look into it and decide the best way to fix it:',
37    `1. Run \`git fetch origin\` and see how far \`${p.branch}\` is behind \`origin/${base}\`${isConflict ? ' and which files conflict' : ''}.`,
38    ...(isConflict
39      ? []
40      : [
41          "   If updating isn't needed (nothing requires the branch to be up to date and it merges cleanly), tell me so and stop.",
42        ]),
43    `2. Choose between ${options}. Weigh the repo's conventions (its docs and recent history), whether branch protection requires the branch to be up to date, and any reviews a force-push would disrupt. Tell me what you chose and why.`,
44    `3. Do the work on \`${p.branch}\` in a separate git worktree so my current checkout and any uncommitted changes stay untouched${isConflict ? ', resolving each conflict so that the intent of both sides is kept' : ''}.`,
45    `4. Run the project's tests and type-check${isConflict ? ', then show me which files conflicted and how you resolved each one' : ''}.`,
46    'Do not push, force-push or update the PR on GitHub without asking me first.',
47  ].join('\n')
48}
49
50/** The most prompt text the desktop app takes from a link. */
51export const LINK_PROMPT_LIMIT = 14_336
52
53/**
54 * The desktop app's link for a new Claude Code session on `folder` with
55 * `prompt` in its composer; the person sends it from there.
56 */
57export const newSessionUrl = (folder: string, prompt: string): string =>
58  `claude://code/new?folder=${encodeURIComponent(folder)}&q=${encodeURIComponent(
59    prompt.slice(0, LINK_PROMPT_LIMIT),
60  )}&source=pr-watch`
61
types/index.d.ts 76 lines
1export type CiState = 'success' | 'failure' | 'pending' | 'none'
2export type ReviewState = 'approved' | 'changes' | 'required' | 'none'
3export type MergeState = 'clean' | 'conflict' | 'unknown'
4/** GitHub's merge state status, as the pane words it. */
5export type MergeStatus = 'ready' | 'behind' | 'blocked' | 'unstable' | 'conflict' | 'draft' | 'unknown'
6
7/** How GitHub merges a PR: what `gh pr merge --merge | --squash | --rebase` names. */
8export type MergeMethod = 'merge' | 'squash' | 'rebase'
9
10/** The head commit's checks, counted by outcome. */
11export type Checks = { passed: number; failed: number; pending: number; skipped: number }
12
13/** Who has reviewed, who still has to, and the open review threads. */
14export type Reviews = {
15  approvedBy: string[]
16  changesBy: string[]
17  /** Requested reviewers (users or teams); `you` stands for the viewer. */
18  waitingOn: string[]
19  unresolvedThreads: number
20}
21
22/** One open pull request, as the last poll saw it. */
23export type Pr = {
24  url: string
25  repo: string
26  number: number
27  title: string
28  author: string
29  branch: string
30  baseBranch: string
31  /** The head commit; a merge is refused if the PR has moved past it. */
32  headSha: string
33  isDraft: boolean
34  ci: CiState
35  checks: Checks
36  failedChecks: string[]
37  review: ReviewState
38  reviews: Reviews
39  merge: MergeState
40  mergeStatus: MergeStatus
41  additions: number
42  deletions: number
43  changedFiles: number
44  /** Last update on GitHub, epoch milliseconds (0 when GitHub did not say). */
45  updatedAt: number
46  /** `mine`: you opened it; `review`: your review is requested. */
47  role: 'mine' | 'review'
48}
49
50/**
51 * The session's repo (`owner/name`, null: not a GitHub repo), its PRs as of
52 * the last good poll, when that ran (null: none yet), and the last error.
53 */
54export type Snapshot = {
55  repo: string | null
56  /** The method GitHub preselects for you in this repo (null: unknown). */
57  mergeMethod: MergeMethod | null
58  prs: Pr[]
59  fetchedAt: number | null
60  error: string | null
61}
62
63declare module 'claude-code' {
64  interface PluginState {
65    // `latest` replaced `snapshot` when PRs gained their detail fields, so a
66    // reload never draws a PR stored in the older shape.
67    'pr-watch': {
68      latest: Snapshot | null
69      /** The PR (by URL) whose merge waits for Confirm, if any. */
70      confirming: string | null
71      /** The PR (by URL) being merged right now, if any. */
72      merging: string | null
73    }
74  }
75}
76