SLOPSHOPPER

pr-watch

Above the prompt: the branch PR with its checks and failing log lines, uncommitted work with a commit-and-push key, and how far the branch is behind the…

newpanebandspinnerguardcommand
★ 1v0.5.1MITupdated 2026-10-06cephalofoil/kitt/pr-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-watch
│ ┃ PR #undefined ✕ › fix the failing auth test and add an audit log call │ ┃ ✓ PR # · │ ┃ Status all checks green ⏺ Read(src/auth.ts) │ ┃ Branch ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No checks reported yet. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ 3: Open in browser r: Refresh ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pr-watch │ ⎿ pr-watch: PR #undefined ✓ all checks green │ │ ● 2 uncommitted · 1 modified, 1 new c: Commit ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● 2 uncommitted · 1 modified, 1 new c: Commit ⟨Claude Code's own drawing⟩
Pane · PR #undefined
✓ PR # · Status all checks green Branch No checks reported yet. 3: Open in browser r: Refresh
README

kitt

<img width="2172" height="724" alt="Kitt-Logo mit kobaltblauen Ts" src="https://github.com/user-attachments/assets/0bbde4f9-62bc-4e15-ac01-c3796f27b836" />

Parallel work on a repo as lanes: one worktree per ticket, each with its own ports, its own agent in herdr, and a proof that the change works in the app.

Three parts:

  • kitt, a Go CLI: lanes, dev servers, the emulator, checks, proofs, and a dashboard.
  • lanes, a Claude Code skill: tells an agent how to work in a lane.
  • pr-watch, a Claude Code mod: PR checks, uncommitted work and rebase state above the prompt.

Install

go build -o ~/.local/bin/kitt.exe ./cmd/kitt     # any directory on PATH

/plugin marketplace add cephalofoil/kitt
/plugin install pr-watch@kitt
/plugin install lanes@kitt

kitt doctor says what is missing (git, gh, herdr, adb, symlinks).

A day with it

kitt repo add                 # once, inside the repo: registers it and shows what kitt detects
kitt init                     # optional: write that as kitt.toml to edit (--home for a repo that is not yours)
kitt dash --workspace         # the dashboard as its own herdr workspace

kitt new 338                  # lane for issue 338: worktree, branch, env links, install, and an agent started on the issue
kitt new 412 --apps web       # a lane about the website: starts the web app, leaves the emulator alone
kitt new settings-proto --prompt "/wayfinder settings across several screens"

In the dashboard, one row per lane: the apps it has changed files of, the agent's state, uncommitted and behind counts, the PR and its checks, the last proof, which dev servers answer, and which lane the emulator shows.

Key
entergo into the lane: its herdr workspace and agent, its dev servers started, its app in the emulator
gstart the lane's agent (on its issue, if it has one)
eonly point the emulator at the lane
oopen the lane's web app in the browser
iinstall what the lane runs, when a lane shows no install (installs run one lane at a time)
u / dstart / stop the lane's dev servers
crun the repo's checks for the apps the lane touched
popen the lane's proof
nnew lane: an issue number starts an agent on it, a name only makes the lane
aadopt a worktree kitt did not create (Claude's, herdr's, a hand-made one)
xremove a lane that holds nothing unsaved
tshow the worktrees that are not lanes

Under the lanes, the open pull requests that have no lane yet (a bot's are left out). enter on one fetches its branch and checks it out as a lane about the apps it touches; o shows it on GitHub. The same from the shell: kitt pr 380.

Every key is also a command: kitt focus, kitt agent, kitt emu, kitt up, kitt check, kitt proof open, kitt adopt, kitt rm. See kitt help.

The header

The dashboard opens with the kitt wordmark and, beside it, three facts: how many lanes there are, whether an agent is waiting for you, and which lane the emulator shows. Below 40 columns the wordmark gives way to the plain name.

KITT_LOGO picks another mark; kitt logo prints them all:

Value
not setthe wordmark, four lines
lockupthe tube beside a narrower wordmark, four lines
smalltube and wordmark in three lines
tube, beadthe tube alone, without or with a bead of putty
medium, large, blocksother sizes; blocks draws the tube with half blocks only
textno logo

The tube is drawn with Unicode sextants (U+1FB00 and up). A font without them shows boxes there; the wordmark uses only block elements and draws everywhere.

How lanes stay apart

  • Ports. Each lane holds a slot; an app listens on its base port plus ten per slot (Metro 8081 in the main checkout, 8091 in the first lane). kitt env prints a lane's ports.
  • Env files. The gitignored files named under link are symlinks into the main checkout, so every lane sees the same secrets and an edit reaches all of them.
  • The emulator. One device, one installed dev client. kitt emu reverses the lane's ports into it and sends the client to the lane's Metro; no native build. A proof holds a lock, so two agents never load over each other.
  • Shared apps. An app marked shared runs once, from the main checkout, for every lane (a database, a backend whose port is fixed).

kitt.toml

Everything project-specific lives in the repo, in kitt.toml. kitt init writes it:

kitt detect <path>      # look first: what kitt sees in a repo; registers and writes nothing
kitt init               # the wizard: each detected piece shown to keep, edit or drop, then written and registered
kitt init --agent       # an agent reads the repo first (justfile, Makefile, CI, AGENTS.md) and fills in dev commands, checks, ports
kitt init --home        # write into kitt's own folder, for a repo that is not yours to add a file to

Detection alone finds the apps (Expo, Next, Vite, anything with a dev script, a Python backend), their checks from package.json scripts, the package manager from the nearest lockfile, and the gitignored .env files to link. What it cannot see, the agent or you add: which env file means local, how the apps find each other, what CI really runs.

Which apps a lane touched is never stored. kitt reads it from git each time: the files that differ from the base branch, matched to the app whose dir they lie in.

name = "example-app"
base = "master"
link = ["apps/mobile/.env", "apps/mobile/.env.supabase.local"]

[emulator]
reverse = [54321]            # ports besides the apps' own the device must reach

[proof]
guide = "docs/proof.md"      # or the text itself: test accounts, how to reach a screen

[[app]]
name = "mobile"
kind = "expo"                # expo | web | backend
dir = "apps/mobile"
port = 8081
dev = "bunx expo start --dev-client --clear --port {port}"
env_files = [".env.supabase.local"]
setup = ["bun install --frozen-lockfile"]
checks = ["bun run typecheck", "bun run lint", "bun run test"]

[app.env]
EXPO_PUBLIC_API_URL = "http://localhost:{port:api}"

{port}, {port:<app>}, {lane}, {slot}, {root} and {main} are filled per lane. lazy = true defers an app's setup and start until it is named; shared = true runs it once for all lanes.

dev runs in a herdr pane, in your shell; setup and checks run through cmd /C (sh -c elsewhere).

Until a repo has its own kitt.toml on its base branch, a copy at <kitt home>/repos/<repo folder>.toml stands in; the repo's file wins once it exists.

Proof

kitt proof begin             # takes the emulator, loads the lane's app, prints the repo's guide
kitt proof shot "one section, no header"
kitt proof end --pass        # or --fail --note "..."
kitt proof open              # the page: verdict, note, shots in order

Proofs are kept under kitt's own folder (%APPDATA%\kitt\proofs), outside the repo. A proof goes stale when the lane gets a new commit.

pr-watch

Shown above the prompt, only when there is something to act on.

A key never runs git itself. It sends Claude a few words, with the how-to attached where only Claude reads it. Claude decides whether and how to act and calls one of the mod's tools; the tool does the fixed steps and answers with what happened. So every action is a real tool call in the transcript that Claude can decline, redo with other arguments, or react to.

ShownKeyWhat happens
the open PR: status, issue, one chip per check, the failing step and log lines1 2 3 xretry failed jobs · hand the log to Claude · open in browser · hide
uncommitted workcClaude writes the message the repo asks for and calls commit; no push
commits not pushed, no PR yetpClaude runs the repo's checks, calls push, opens the PR
commits not pushed, PR openpClaude calls push
behind the default branchbClaude calls rebase_and_push: the branch is rebased onto the default branch and then pushed to its own remote branch, ready for a PR; on conflicts Claude rebases by hand and asks where a conflict is not clear
the PR is mergedmswitches to the default branch, brought up to date (done by the mod; Claude is told)

Merging is yours. No key and no tool merges. When Claude runs gh pr merge anyway, the call is held and you are asked; anything but "Merge" refuses it.

Auto-rebase is an option of the plugin, off by default (/plugin, pr-watch, configure). On, a branch with an open PR that falls behind is rebased and pushed without a key press, as long as the tree is clean and the rebase has no conflicts.

The tools, as Claude sees them:

  • mcp__pr-watch__commit { subject, body?, paths? }: stages and commits. Refuses on the default branch. Never pushes.
  • mcp__pr-watch__push { force_with_lease? }: pushes. A refused push answers with the commits the remote holds; forcing then overwrites exactly those.
  • mcp__pr-watch__rebase_and_push: rebases onto the default branch and pushes with lease. A conflicting rebase is undone and answered with the files.

The branch name sits at the end of the hint line. /pr-watch opens the full view of the PR. Keys work once the band has focus (click it, or ctrl+x tab).

License

MIT

Source 3 files
hooks/register.tsx 971 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PrWatchCheck, PrWatchGit, PrWatchIssue, PrWatchLog, PrWatchSnapshot } from '../types'
5import { countBoxes, duration, headline, issueNumber, overall, parseChecks, parseLog } from './parse'
6import type { Overall } from './parse'
7
8const PANE = 'pr-watch'
9const FIELDS =
10  'number,title,body,state,isDraft,headRefOid,headRefName,closingIssuesReferences,statusCheckRollup'
11const TICK_MS = 15_000
12const IDLE_TICKS = 4
13const FETCH_TICKS = 20
14
15// The tools Claude calls to act on the branch. A key of the band never runs git
16// itself: it asks Claude in a few words, Claude decides whether and how to call
17// the tool, the tool does the fixed steps and answers with what happened.
18const TOOL = {
19  commit: 'mcp__pr-watch__commit',
20  push: 'mcp__pr-watch__push',
21  rebase: 'mcp__pr-watch__rebase_and_push',
22} as const
23
24// What a key sends is a few words the person reads in the transcript. How to do
25// it rides along as context only the model reads.
26const ASK = {
27  commit: 'Commit my changes.',
28  ship: 'Push this branch and open a PR.',
29  push: 'Push this branch.',
30  rebase: 'Rebase this branch onto ',
31} as const
32
33const HOW = {
34  commit:
35    `Sent by the Commit key of the pr-watch band. Look at what is uncommitted, write the message this repo asks for (AGENTS.md, ` +
36    `CLAUDE.md, the subjects in \`git log\`, the issue the branch works on), and call the tool ${TOOL.commit} with it. For ` +
37    `clearly unrelated changes, call it once per group with \`paths\`. Do not run \`git commit\` yourself, do not push, do not open a PR. ` +
38    `If something should not be committed (a secret, generated junk, work that is visibly broken), do not call the tool: say what and why. ` +
39    `Answer with the commit subject and nothing else.`,
40  ship:
41    `Sent by the Push & open PR key of the pr-watch band. First run the repo's own checks for what changed: \`kitt check\` when the ` +
42    `repo has a kitt.toml, otherwise the checks AGENTS.md or CLAUDE.md name. If a check fails, stop and report it. If the branch is ` +
43    `behind the default branch, call ${TOOL.rebase} first. Then call ${TOOL.push}, and open a PR against the default branch with ` +
44    `\`gh pr create\`, following the repo's PR conventions: title style, the sections its instructions ask for, the issue it closes. ` +
45    `If \`kitt proof status\` shows a proof for this lane, say in the PR body what it shows. Do not merge the PR: merging is the person's decision. Answer with the PR link and one line on the checks.`,
46  push:
47    `Sent by the Push key of the pr-watch band. Call the tool ${TOOL.push}. If it answers that the remote holds other commits, ` +
48    `read them: call again with force_with_lease only when they are this branch's own commits from before a rebase; if they are ` +
49    `someone else's work, stop and say so. Answer in one line.`,
50  rebase:
51    `Sent by the Rebase key of the pr-watch band. It means: rebase this branch onto the default branch, then push this branch to ` +
52    `its own remote branch, so a PR can be opened or updated. Nothing is pushed to the default branch. Call the tool ${TOOL.rebase}. ` +
53    `If it answers that the rebase conflicts, rebase by hand: resolve each conflict only where the right result is clear from both ` +
54    `sides, and where it is not, stop with the rebase still open and ask, naming the file and the two versions. After a rebase by ` +
55    `hand, call ${TOOL.push} with force_with_lease. Answer in one or two lines.`,
56} as const
57
58/** What the Rebase key sends: the branch goes onto the base, then the branch itself is pushed. */
59const rebaseAsk = (base: string): string => `${ASK.rebase}${base.replace(/^origin\//, '')} and push the branch.`
60
61function howFor(asked: string): string | null {
62  if (asked === ASK.commit) return HOW.commit
63  if (asked === ASK.ship) return HOW.ship
64  if (asked === ASK.push) return HOW.push
65  if (asked.startsWith(ASK.rebase)) return HOW.rebase
66
67  return null
68}
69
70const snapshot = atom({ plugin: 'pr-watch', key: 'snapshot' } as const, null)
71const gitState = atom({ plugin: 'pr-watch', key: 'git' } as const, null)
72const hiddenPr = atom({ plugin: 'pr-watch', key: 'hiddenPr' } as const, null)
73const hiddenRebase = atom({ plugin: 'pr-watch', key: 'hiddenRebase' } as const, null)
74const isLoaded = atom({ plugin: 'pr-watch', key: 'isLoaded' } as const, false)
75
76const GLYPH = { pass: '✓', fail: '✗', running: '●', queued: '○', skipped: '–' } as const
77const COLOR = { pass: 'green', fail: 'red', running: 'yellow', queued: 'gray', skipped: 'gray' } as const
78const HEAD = {
79  failed: { color: 'red', mark: '✗', word: 'checks failing' },
80  running: { color: 'yellow', mark: '●', word: 'checks running' },
81  green: { color: 'green', mark: '✓', word: 'all checks green' },
82  merged: { color: 'magenta', mark: '✓', word: 'merged' },
83  closed: { color: 'gray', mark: '–', word: 'closed' },
84} as const
85
86// A command that merges a pull request: the CLI's own, or the API's merge endpoint.
87const MERGE = /\bgh\s+pr\s+merge\b|\bgh\s+api\b[^\n]*\/pulls\/\d+\/merge\b/
88const NO_MERGE =
89  'Merging a pull request is the person\'s decision, and they did not approve this one. Do not merge and do not try another way: ' +
90  'say that the PR is ready to merge, and stop.'
91
92// Set from the plugin's options: rebase a PR's branch by itself when it falls behind.
93let isAutoRebase = false
94let autoFor = ''
95
96let isPrBusy = false
97let isGitBusy = false
98let ticks = 0
99let last: { key: string; overall: Overall } | null = null
100let issue: (PrWatchIssue & { at: number }) | null = null
101let base: string | null = null
102let tried: { key: string; conflicts: string[] | null } | null = null
103const logs = new Map<string, PrWatchLog>()
104
105const keyOf = (pr: PrWatchSnapshot): string => `${pr.number}:${pr.headSha}`
106
107const plural = (count: number, one: string): string => `${count} ${one}${count === 1 ? '' : 's'}`
108
109async function run($: EngineInterface, argv: readonly string[], timeoutMs = 20_000) {
110  try {
111    return await $.process.run(argv, { timeoutMs })
112  } catch {
113    return null
114  }
115}
116
117async function text($: EngineInterface, argv: readonly string[]): Promise<string | null> {
118  const ran = await run($, argv)
119
120  return ran === null || ran.exitCode !== 0 ? null : ran.stdout.trim()
121}
122
123// --- The branch: what is uncommitted, unpushed, behind ----------------------
124
125/** The remote's default branch (`origin/main`), asked once: origin's HEAD, else main, else master. */
126async function findBase($: EngineInterface): Promise<string | null> {
127  if (base !== null) return base
128
129  const head = await text($, ['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
130
131  if (head !== null && head !== '') {
132    base = head
133
134    return base
135  }
136  for (const name of ['origin/main', 'origin/master']) {
137    if ((await text($, ['git', 'rev-parse', '--verify', '--quiet', name])) !== null) {
138      base = name
139
140      return base
141    }
142  }
143
144  return null
145}
146
147async function loadGit($: EngineInterface): Promise<PrWatchGit | null> {
148  const branch = await text($, ['git', 'branch', '--show-current'])
149
150  if (branch === null || branch === '') return null
151
152  const onto = await findBase($)
153  const status = (await run($, ['git', 'status', '--porcelain']))?.stdout.split(/\r?\n/).filter(Boolean) ?? []
154  const untracked = status.filter(line => line.startsWith('??')).length
155  const counts =
156    (onto === null ? null : await text($, ['git', 'rev-list', '--left-right', '--count', `HEAD...${onto}`])) ?? '0 0'
157  const [ahead = 0, behind = 0] = counts.split(/\s+/).map(Number)
158  const unpushed = await text($, ['git', 'rev-list', '--count', '@{upstream}..HEAD'])
159  const baseSha = (onto === null ? null : await text($, ['git', 'rev-parse', onto])) ?? ''
160  const head = (await text($, ['git', 'rev-parse', 'HEAD'])) ?? ''
161  const key = `${head}:${baseSha}`
162
163  if (behind === 0) tried = { key, conflicts: [] }
164  if (onto !== null && behind > 0 && tried?.key !== key) {
165    // Merges the two tips in memory: nothing in the work tree or the index moves.
166    const merged = await run($, ['git', 'merge-tree', '--write-tree', '--name-only', 'HEAD', onto])
167    const names = merged?.stdout.split(/\r?\n\r?\n/)[0]?.split(/\r?\n/).slice(1).filter(Boolean) ?? []
168
169    tried = { key, conflicts: merged === null || merged.exitCode > 1 ? null : merged.exitCode === 0 ? [] : names }
170  }
171
172  return {
173    branch,
174    head,
175    modified: status.length - untracked,
176    untracked,
177    behind,
178    ahead,
179    unpushed: unpushed === null ? null : Number(unpushed),
180    conflicts: tried?.conflicts ?? null,
181    base: onto ?? '',
182    baseSha,
183  }
184}
185
186async function refreshGit($: EngineInterface) {
187  if (isGitBusy) return
188
189  isGitBusy = true
190
191  try {
192    const now = await loadGit($)
193
194    await update($, gitState, () => now)
195
196    if (isAutoRebase && now !== null && now.behind > 0 && now.modified + now.untracked === 0 && now.conflicts?.length === 0) {
197      const pr = await read($, snapshot)
198      const key = `${now.head}:${now.baseSha}`
199
200      if (pr !== null && pr.state === 'OPEN' && pr.branch === now.branch && autoFor !== key) {
201        autoFor = key
202        $.ui.toast(`${now.behind} behind ${now.base.replace(/^origin\//, '')}: rebasing (auto-rebase is on)`, { timeoutMs: 8000 })
203        void $.prompt.submit({ text: rebaseAsk(now.base) })
204      }
205    }
206  } catch (error) {
207    $.ui.log(`pr-watch: ${error instanceof Error ? error.message : String(error)}`)
208  } finally {
209    isGitBusy = false
210  }
211}
212
213// --- The pull request: checks, failing log, issue ---------------------------
214
215async function loadIssue($: EngineInterface, number: number, now: number) {
216  if (issue !== null && issue.number === number && now - issue.at < 300_000) return issue
217
218  const ran = await text($, ['gh', 'issue', 'view', String(number), '--json', 'title,body'])
219
220  if (ran === null) return issue?.number === number ? issue : null
221
222  const data = JSON.parse(ran) as { title?: string; body?: string }
223  issue = { number, title: data.title ?? '', ...countBoxes(data.body ?? ''), at: now }
224
225  return issue
226}
227
228async function loadLog($: EngineInterface, check: PrWatchCheck) {
229  if (check.jobId === null) return null
230
231  const kept = logs.get(check.jobId)
232
233  if (kept !== undefined) return kept
234
235  const ran = await run($, ['gh', 'run', 'view', '--job', check.jobId, '--log-failed'], 45_000)
236
237  // gh has no log until the whole run is over: ask again at the next poll.
238  if (ran === null || ran.exitCode !== 0 || ran.stdout.trim() === '') return null
239
240  const log = { jobId: check.jobId, ...parseLog(ran.stdout) }
241  logs.set(check.jobId, log)
242
243  return log
244}
245
246async function loadPr($: EngineInterface): Promise<PrWatchSnapshot | null> {
247  const ran = await text($, ['gh', 'pr', 'view', '--json', FIELDS])
248
249  if (ran === null) return null
250
251  const data = JSON.parse(ran) as {
252    number: number
253    title: string
254    body?: string
255    state: PrWatchSnapshot['state']
256    isDraft?: boolean
257    headRefOid: string
258    headRefName: string
259    closingIssuesReferences?: { number?: number }[]
260    statusCheckRollup?: Parameters<typeof parseChecks>[0]
261  }
262  const now = await $.clock.now()
263  const checks = parseChecks(data.statusCheckRollup ?? [], now)
264  const found = issueNumber(data.closingIssuesReferences, data.title, data.body ?? '')
265  const failed: PrWatchLog[] = []
266
267  for (const check of checks.filter(one => one.state === 'fail')) {
268    const log = await loadLog($, check)
269
270    if (log !== null) failed.push(log)
271  }
272
273  return {
274    number: data.number,
275    title: data.title,
276    state: data.state,
277    isDraft: data.isDraft === true,
278    headSha: data.headRefOid,
279    branch: data.headRefName,
280    issue: found === null ? null : await loadIssue($, found, now),
281    checks,
282    logs: failed,
283  }
284}
285
286function summary(pr: PrWatchSnapshot): string {
287  const now = overall(pr)
288  const bad = pr.checks.filter(check => check.state === 'fail').map(check => check.name)
289
290  return now === 'failed'
291    ? `PR #${pr.number} ✗ ${bad[0]}${bad.length > 1 ? ` +${bad.length - 1}` : ''}`
292    : `PR #${pr.number} ${HEAD[now].mark} ${HEAD[now].word}`
293}
294
295async function refreshPr($: EngineInterface) {
296  if (isPrBusy) return
297
298  isPrBusy = true
299
300  try {
301    const pr = await loadPr($)
302    const now = pr === null ? null : overall(pr)
303    const before = pr !== null && last !== null && last.key === keyOf(pr) ? last.overall : null
304
305    await update($, snapshot, () => pr)
306    await update($, isLoaded, () => true)
307    last = pr === null || now === null ? null : { key: keyOf(pr), overall: now }
308
309    if (pr !== null && before !== null && before !== now && now !== 'running' && now !== 'closed') {
310      $.ui.toast(summary(pr), { timeoutMs: 8000 })
311    }
312  } catch (error) {
313    $.ui.log(`pr-watch: ${error instanceof Error ? error.message : String(error)}`)
314  } finally {
315    isPrBusy = false
316  }
317}
318
319async function retry($: EngineInterface, pr: PrWatchSnapshot) {
320  const failed = pr.checks.filter(check => check.state === 'fail')
321  const runs = [...new Set(failed.map(check => check.runId).filter((id): id is string => id !== null))]
322
323  for (const id of runs) {
324    const ran = await run($, ['gh', 'run', 'rerun', id, '--failed'], 30_000)
325
326    $.ui.toast(
327      ran?.exitCode === 0
328        ? `PR #${pr.number}: failed jobs restarted`
329        : `Retry refused: ${(ran?.stderr ?? 'gh did not answer').trim().split('\n')[0]}`,
330      { timeoutMs: 8000 },
331    )
332  }
333  for (const check of failed) if (check.jobId !== null) logs.delete(check.jobId)
334
335  await refreshPr($)
336}
337
338async function hand($: EngineInterface, pr: PrWatchSnapshot) {
339  const parts = pr.checks
340    .filter(check => check.state === 'fail')
341    .map(check => {
342      const log = pr.logs.find(one => one.jobId === check.jobId)
343
344      return log === undefined
345        ? `Job "${check.name}" failed; its log is not available yet.`
346        : `Job "${check.name}" failed in step "${log.step}". Log tail:\n\`\`\`\n${log.lines.join('\n')}\n\`\`\``
347    })
348
349  await $.prompt.submit({
350    text: `CI on PR #${pr.number} (${pr.branch}) is failing.\n\n${parts.join('\n\n')}\n\nFind the cause and fix it.`,
351    asUser: true,
352  })
353}
354
355async function browse($: EngineInterface, pr: PrWatchSnapshot) {
356  await run($, ['gh', 'pr', 'view', String(pr.number), '--web'])
357}
358
359const why = (ran: { stderr: string; stdout: string } | null): string =>
360  ((ran?.stderr || ran?.stdout) ?? 'git did not answer').trim().split('\n').filter(Boolean).pop() ?? ''
361
362const tail = (ran: { stderr: string; stdout: string } | null, most: number): string =>
363  `${ran?.stdout ?? ''}\n${ran?.stderr ?? ''}`.split(/\r?\n/).filter(line => line.trim() !== '').slice(-most).join('\n')
364
365/** Tells Claude what a key did to the branch, without starting a turn. */
366async function tell($: EngineInterface, what: string) {
367  try {
368    await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: `[pr-watch] ${what}` }] } })
369  } catch {
370    // A session that takes no notes still has the toast.
371  }
372}
373
374// --- The tools: fixed steps, an answer Claude reads ---------------------------
375
376type Answer = { result: string } | { deny: string }
377
378/** The default branch is never committed to, pushed or rebased by a tool. */
379async function branchFor($: EngineInterface): Promise<PrWatchGit | { deny: string }> {
380  const git = await loadGit($)
381
382  if (git === null) return { deny: 'This directory is not on a git branch.' }
383  if (`origin/${git.branch}` === git.base) {
384    return { deny: `${git.branch} is the default branch. Create a branch first (git switch -c <type>/<name>), then call the tool again.` }
385  }
386
387  return git
388}
389
390async function commitTool($: EngineInterface, input: { subject?: unknown; body?: unknown; paths?: unknown }): Promise<Answer> {
391  const git = await branchFor($)
392
393  if ('deny' in git) return git
394
395  const subject = typeof input.subject === 'string' ? input.subject.trim() : ''
396  const body = typeof input.body === 'string' ? input.body.trim() : ''
397  const paths = Array.isArray(input.paths) ? input.paths.filter((path): path is string => typeof path === 'string') : []
398
399  if (subject === '') return { deny: 'A commit needs a subject.' }
400
401  const added = await run($, paths.length > 0 ? ['git', 'add', '--', ...paths] : ['git', 'add', '-A'], 60_000)
402
403  if (added?.exitCode !== 0) return { result: `Nothing was committed: git add failed: ${why(added)}` }
404
405  const staged = await run($, ['git', 'diff', '--cached', '--quiet'])
406
407  if (staged?.exitCode === 0) return { result: 'Nothing was committed: there is nothing staged for these paths.' }
408
409  // A repo's commit hooks run here and may take their time.
410  const committed = await run($, ['git', 'commit', '-m', subject, ...(body === '' ? [] : ['-m', body])], 300_000)
411
412  if (committed?.exitCode !== 0) {
413    return { result: `The commit was refused; the changes stay staged. Output:\n${tail(committed, 30)}` }
414  }
415
416  const sha = (await text($, ['git', 'rev-parse', '--short', 'HEAD'])) ?? ''
417  const left = (await run($, ['git', 'status', '--porcelain']))?.stdout.split(/\r?\n/).filter(Boolean).length ?? 0
418
419  await refreshGit($)
420
421  return { result: `Committed ${sha} ${subject}. ${left === 0 ? 'Nothing is left uncommitted.' : `${left} files are still uncommitted.`} Not pushed.` }
422}
423
424// The remote head Claude was shown when a push was refused: a forced push is
425// leased to exactly that commit, so it cannot overwrite anything newer.
426let shown: { branch: string; sha: string } | null = null
427
428async function pushTool($: EngineInterface, input: { force_with_lease?: unknown }): Promise<Answer> {
429  const git = await branchFor($)
430
431  if ('deny' in git) return git
432
433  const isForced = input.force_with_lease === true
434  const upstream = await text($, ['git', 'rev-parse', '--abbrev-ref', '@{upstream}'])
435  const lease = shown !== null && shown.branch === git.branch ? `--force-with-lease=${git.branch}:${shown.sha}` : '--force-with-lease'
436  const argv = isForced
437    ? ['git', 'push', lease, 'origin', git.branch]
438    : upstream === null
439      ? ['git', 'push', '--set-upstream', 'origin', git.branch]
440      : ['git', 'push']
441  const pushed = await run($, argv, 180_000)
442
443  if (pushed?.exitCode === 0) {
444    shown = null
445    await refreshGit($)
446    await refreshPr($)
447
448    return { result: `Pushed ${git.branch} to origin${isForced ? ' with --force-with-lease' : ''}.` }
449  }
450  if (isForced || !/rejected|non-fast-forward|fetch first|stale info/.test(`${pushed?.stderr}`)) {
451    return { result: `The push failed: ${why(pushed)}` }
452  }
453
454  // Refused: show what the remote holds that the local branch lacks.
455  await run($, ['git', 'fetch', '--quiet', 'origin', git.branch], 30_000)
456
457  const sha = (await text($, ['git', 'rev-parse', `origin/${git.branch}`])) ?? ''
458  const theirs = (await text($, ['git', 'log', '--format=%h %an: %s', '-10', `HEAD..origin/${git.branch}`])) ?? ''
459
460  shown = { branch: git.branch, sha }
461
462  return {
463    result:
464      `The push was refused: origin/${git.branch} holds commits the local branch does not have:\n${theirs}\n` +
465      `If these are this branch's own commits from before a rebase, call the tool again with force_with_lease: true. ` +
466      `If any is someone else's work, do not force: stop and tell the person.`,
467  }
468}
469
470async function rebaseTool($: EngineInterface): Promise<Answer> {
471  const git = await branchFor($)
472
473  if ('deny' in git) return git
474  if (git.modified + git.untracked > 0) {
475    return { deny: `There are uncommitted changes. Commit them first (${TOOL.commit}), then call this tool again.` }
476  }
477
478  const onto = git.base.replace(/^origin\//, '')
479
480  await run($, ['git', 'fetch', '--quiet', 'origin', onto], 30_000)
481
482  const behind = await text($, ['git', 'rev-list', '--count', `HEAD..${git.base}`])
483
484  const isRebased = behind !== '0'
485
486  const rebased = isRebased ? await run($, ['git', 'rebase', git.base], 180_000) : null
487
488  if (isRebased && rebased?.exitCode !== 0) {
489    const files = (await text($, ['git', 'diff', '--name-only', '--diff-filter=U'])) ?? ''
490
491    // Back to exactly where the branch was: nothing half-done is left behind.
492    await run($, ['git', 'rebase', '--abort'])
493    await refreshGit($)
494
495    return {
496      result:
497        `A plain rebase onto ${git.base} does not go through; it was undone and the branch is as before.\n` +
498        (files === '' ? `git said: ${why(rebased)}` : `Conflicts in:\n${files}`),
499    }
500  }
501
502  // The branch itself is pushed, to its own remote branch: a first push sets the
503  // upstream, a later one is leased, so it refuses when the remote branch moved
504  // since it was last seen.
505  const upstream = await text($, ['git', 'rev-parse', '--abbrev-ref', '@{upstream}'])
506  const pushed = await run(
507    $,
508    upstream === null ? ['git', 'push', '--set-upstream', 'origin', git.branch] : ['git', 'push', '--force-with-lease'],
509    180_000,
510  )
511  const did = isRebased ? `Rebased ${git.branch} onto ${git.base}` : `${git.branch} was already on top of ${git.base}`
512  const outcome =
513    pushed?.exitCode === 0
514      ? `${did} and pushed it to origin/${git.branch}${isRebased && upstream !== null ? ' with --force-with-lease; its commits have new ids' : ''}. A PR can be opened from it now.`
515      : `${did}, but pushing the branch was refused: ${why(pushed)}`
516  await refreshGit($)
517  await refreshPr($)
518
519  return { result: outcome }
520}
521
522/** Leaves a merged branch for the default one, brought up to the remote's tip. */
523async function switchToBase($: EngineInterface, git: PrWatchGit) {
524  const name = git.base.replace(/^origin\//, '')
525  const why = (ran: { stderr: string } | null) => (ran?.stderr ?? 'git did not answer').trim().split('\n')[0]
526
527  await run($, ['git', 'fetch', '--quiet', 'origin', name], 30_000)
528
529  const moved = await run($, ['git', 'switch', name])
530
531  if (moved?.exitCode !== 0) {
532    $.ui.toast(`Switch refused: ${why(moved)}`, { timeoutMs: 8000 })
533
534    return
535  }
536
537  const pulled = await run($, ['git', 'merge', '--ff-only', git.base])
538
539  $.ui.toast(
540    pulled?.exitCode === 0 ? `On ${name}, level with ${git.base}` : `On ${name}, not fast-forwarded: ${why(pulled)}`,
541    { timeoutMs: 8000 },
542  )
543  await tell($, `The person pressed Switch: this checkout is now on ${name}, level with ${git.base}. The branch ${git.branch} is merged and done.`)
544  await refreshGit($)
545  await refreshPr($)
546}
547
548export const register: Register = (on, options) => {
549  isAutoRebase = options.autoRebase === true
550
551  on('session.start', async ($, e, next) => {
552    await $.command.register({
553      name: 'pr-watch',
554      description: "Open the full view of this branch's PR: every check, failing log lines, issue",
555    })
556    // An earlier version pinned the branch here; a pin outlives a reload until cleared.
557    $.ui.status(undefined)
558    try {
559      await $.tool.register({
560        name: 'commit',
561        description:
562          'Commit uncommitted changes of the current branch with the message you give. Stages everything, or only `paths` when given. ' +
563          'Refuses on the default branch. Never pushes. Use it when the person asks to commit.',
564        inputSchema: {
565          type: 'object',
566          properties: {
567            subject: { type: 'string', description: 'The commit subject, in the form this repo uses' },
568            body: { type: 'string', description: 'Optional commit body' },
569            paths: { type: 'array', items: { type: 'string' }, description: 'Only these paths; leave out to commit everything' },
570          },
571          required: ['subject'],
572        },
573      })
574      await $.tool.register({
575        name: 'push',
576        description:
577          'Push the current branch to origin, setting its upstream on the first push. A refused push answers with the commits the ' +
578          'remote holds; force_with_lease then overwrites exactly those, and only those. Refuses on the default branch.',
579        inputSchema: {
580          type: 'object',
581          properties: {
582            force_with_lease: {
583              type: 'boolean',
584              description: "Only after a refused push whose listed commits are this branch's own from before a rebase",
585            },
586          },
587        },
588      })
589      await $.tool.register({
590        name: 'rebase_and_push',
591        description:
592          'Rebase the current branch onto the latest default branch, then push that branch to its own remote branch (first push sets the ' +
593          'upstream, a later one uses --force-with-lease). Nothing is pushed to the default branch. A rebase that conflicts is ' +
594          'undone and answered with the conflicting files. Needs a clean work tree. Refuses on the default branch.',
595        inputSchema: { type: 'object', properties: {} },
596      })
597    } catch (error) {
598      $.ui.log(`pr-watch: tools not registered: ${error instanceof Error ? error.message : String(error)}`)
599    }
600    void refreshGit($)
601    void refreshPr($)
602
603    $.clock.every(TICK_MS, () => {
604      ticks += 1
605
606      if (ticks % FETCH_TICKS === 0) {
607        if (base !== null) void run($, ['git', 'fetch', '--quiet', 'origin', base.replace(/^origin\//, '')], 30_000)
608      }
609
610      const isLive = last !== null && (last.overall === 'failed' || last.overall === 'running')
611
612      void refreshGit($)
613      if (isLive || ticks % IDLE_TICKS === 0) void refreshPr($)
614    })
615
616    return next(e)
617  })
618
619  on('command.run', { command: 'pr-watch' }, async $ => {
620    await refreshPr($)
621
622    const pr = await read($, snapshot)
623
624    await $.ui.open({ id: PANE, title: pr === null ? 'PR' : `PR #${pr.number}`, rows: pr === null ? 4 : 20 })
625
626    return { text: pr === null ? 'pr-watch: this branch has no pull request.' : summary(pr) }
627  })
628
629  on('tool.call', async ($, e, next) => {
630    const command = String((e as { command?: unknown }).command ?? '')
631
632    if (/\bgh pr create\b/.test(command)) {
633      const now = await read($, gitState)
634
635      if (now !== null && now.behind > 0) {
636        $.ui.toast(`${now.behind} behind ${now.base}: this PR goes out unrebased`, { timeoutMs: 8000 })
637      }
638    }
639
640    const ran = await next(e)
641
642    void refreshGit($)
643    if (/\bgh (pr|run) |\bgit (push|checkout|switch)\b/.test(command)) void refreshPr($)
644
645    return ran
646  })
647
648  on('turn.complete', ($, e, next) => {
649    void refreshGit($)
650    void refreshPr($)
651
652    return next(e)
653  })
654
655  // The branch rides the engine's own hint line, dim, beside its PR pill.
656  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
657    const git = await read($, gitState)
658
659    if (git === null) return next(e)
660
661    const tail = [e.props.tail, `⎇ ${git.branch}`].filter(Boolean).join(' · ')
662
663    return next({ ...e, props: { ...e.props, tail } })
664  })
665
666  // Merging is held until the person says yes, whoever asked for it and however green the PR is.
667  on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
668    const command = String((e as { command?: unknown }).command ?? '')
669
670    if (!MERGE.test(command)) return next(e)
671
672    let answer = 'Do not merge'
673
674    try {
675      answer = await $.ui.ask(`Claude is about to merge a pull request. Merge it? ${command.slice(0, 140)}`, ['Merge', 'Do not merge'])
676    } catch {
677      // Dismissed, or nobody to ask: the answer stays no.
678    }
679
680    return answer === 'Merge' ? next(e) : { deny: NO_MERGE }
681  }).catch(() => ({ deny: NO_MERGE }))
682
683  on('tool.call', { tool: 'mcp__pr-watch__commit' }, ($, e) => commitTool($, e as { subject?: unknown; body?: unknown; paths?: unknown }))
684  on('tool.call', { tool: 'mcp__pr-watch__push' }, ($, e) => pushTool($, e as { force_with_lease?: unknown }))
685  on('tool.call', { tool: 'mcp__pr-watch__rebase_and_push' }, $ => rebaseTool($))
686
687  // A key's few words get their instructions here, unseen by the person.
688  on('prompt.submit', ($, e, next) => {
689    const how = (e.origin as { kind: string }).kind === 'plugin' ? howFor(e.text) : null
690
691    return how === null ? next(e) : next({ ...e, context: [...(e.context ?? []), how] })
692  })
693
694  // --- The band above the prompt: PR, uncommitted work, rebase --------------
695
696  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
697    if (e.props.hasSurvey) return next(e)
698
699    const pr = await read($, snapshot)
700    const git = await read($, gitState)
701    const now = pr === null ? null : overall(pr)
702    const hasPr =
703      pr !== null && git !== null && now !== null && pr.branch === git.branch &&
704      now !== 'merged' && now !== 'closed' && (await read($, hiddenPr)) !== keyOf(pr)
705    const dirty = git === null ? 0 : git.modified + git.untracked
706    // The branch's PR is merged and nothing was committed since: the branch is done.
707    const isDone =
708      pr !== null && git !== null && pr.state === 'MERGED' && pr.branch === git.branch && pr.headSha === git.head
709    const isOpen = pr !== null && git !== null && pr.state === 'OPEN' && pr.branch === git.branch
710    // A branch without an upstream has pushed nothing: everything on top of the base is outgoing.
711    const outgoing = git === null ? 0 : (git.unpushed ?? git.ahead)
712    const isBase = git !== null && `origin/${git.branch}` === git.base
713    const hasPush =
714      git !== null && dirty === 0 && !isDone && !isBase && (outgoing > 0 || (!isOpen && git.ahead > 0))
715    const hasRebase =
716      !isDone && git !== null && git.behind > 0 && `origin/${git.branch}` !== git.base && (await read($, hiddenRebase)) !== git.baseSha
717
718    if (!hasPr && !isDone && dirty === 0 && !hasPush && !hasRebase) return next(e)
719
720    const below = await next(e)
721
722    const { Box, Button, Text } = $.ui.resolve(e)
723    const width = Math.max(30, e.props.bodyColumns)
724    const failed = hasPr ? pr.checks.filter(check => check.state === 'fail') : []
725    const skipped = hasPr ? pr.checks.filter(check => check.state === 'skipped').length : 0
726    const files = git?.conflicts ?? []
727
728    return (
729      <Box flexDirection="column">
730        <Box flexDirection="column" width={width}>
731        {hasPr && (
732          <Box flexDirection="column">
733            <Box columnGap={1}>
734              <Text bold color={HEAD[now].color}>
735                {HEAD[now].mark} PR #{pr.number}
736              </Text>
737              <Text color={HEAD[now].color}>{HEAD[now].word}</Text>
738              <Text dimColor>·</Text>
739              <Box flexGrow={1} flexShrink={1}>
740                <Text wrap="truncate-end">
741                  {pr.issue === null ? pr.title : `#${pr.issue.number} ${pr.issue.title}`}
742                  {pr.issue !== null && pr.issue.total > 0 ? ` · ${pr.issue.done}/${pr.issue.total} done` : ''}
743                </Text>
744              </Box>
745            </Box>
746            <Box columnGap={2} flexWrap="wrap" marginLeft={2}>
747              {pr.checks
748                .filter(check => check.state !== 'skipped')
749                .map(check => (
750                  <Box>
751                    <Text color={COLOR[check.state]}>{GLYPH[check.state]} </Text>
752                    <Text bold={check.state === 'fail'} dimColor={check.state === 'pass'}>
753                      {check.name.split(' — ')[0]}
754                    </Text>
755                    <Text dimColor>
756                      {check.state === 'queued' ? ' queued' : check.seconds === null ? '' : ` ${duration(check.seconds)}`}
757                    </Text>
758                  </Box>
759                ))}
760              {skipped > 0 && <Text dimColor>– {skipped} skipped</Text>}
761              {pr.checks.length === 0 && <Text dimColor>no checks reported yet</Text>}
762            </Box>
763            {failed.map(check => {
764              const log = pr.logs.find(one => one.jobId === check.jobId)
765
766              return (
767                <Box flexDirection="column" marginLeft={2}>
768                  <Text color="red" wrap="truncate-end">
769                    ✗ {check.name}
770                    {log === undefined ? ' · log arrives when the run finishes' : log.step === '' ? '' : ` › ${log.step}`}
771                  </Text>
772                  {log !== undefined &&
773                    headline(log.lines, 2).map(line => (
774                      <Text dimColor wrap="truncate-end">    {line}</Text>
775                    ))}
776                </Box>
777              )
778            })}
779            <Box columnGap={2} marginLeft={2}>
780              {failed.length > 0 && <Button key="retry" plain hotkey="1" label="Retry failed" onPress={() => retry($, pr)} />}
781              {failed.length > 0 && <Button key="hand" plain hotkey="2" label="Hand log to Claude" onPress={() => hand($, pr)} />}
782              <Button key="web" plain dimColor hotkey="3" label="Open in browser" onPress={() => browse($, pr)} />
783              <Button key="hide-pr" plain dimColor hotkey="x" label="Hide" onPress={() => update($, hiddenPr, () => keyOf(pr))} />
784            </Box>
785          </Box>
786        )}
787        {git !== null && dirty > 0 && (
788          <Box columnGap={1}>
789            <Text bold color="cyan">● {dirty} uncommitted</Text>
790            <Text dimColor>
791              · {[git.modified > 0 ? `${git.modified} modified` : '', git.untracked > 0 ? `${git.untracked} new` : '']
792                .filter(Boolean)
793                .join(', ')}
794              {outgoing > 0 ? ` · ↑ ${outgoing} not pushed` : ''}
795            </Text>
796            <Text> </Text>
797            <Button
798              key="commit"
799              plain
800              hotkey="c"
801              label="Commit"
802              onPress={() => void $.prompt.submit({ text: ASK.commit, asUser: true })}
803            />
804          </Box>
805        )}
806        {hasPush && (
807          <Box columnGap={1}>
808            <Text bold color="cyan">
809              {outgoing > 0 ? `↑ ${plural(outgoing, 'commit')} not pushed` : '↑ pushed'}
810            </Text>
811            {!isOpen && <Text dimColor>· no PR yet</Text>}
812            <Text> </Text>
813            <Button
814              key="push"
815              plain
816              hotkey="p"
817              label={isOpen ? 'Push' : outgoing > 0 ? 'Push & open PR' : 'Open PR'}
818              onPress={() => void $.prompt.submit({ text: isOpen ? ASK.push : ASK.ship, asUser: true })}
819            />
820          </Box>
821        )}
822        {isDone && (
823          <Box columnGap={1}>
824            <Text bold color="magenta">✓ PR #{pr.number} merged</Text>
825            <Text dimColor>· this branch is done</Text>
826            <Text> </Text>
827            {dirty === 0 ? (
828              <Button
829                key="switch"
830                plain
831                hotkey="m"
832                label={`Switch to ${git.base.replace(/^origin\//, '')}`}
833                onPress={() => switchToBase($, git)}
834              />
835            ) : (
836              <Text dimColor>commit or stash first to switch</Text>
837            )}
838          </Box>
839        )}
840        {hasRebase && (
841          <Box columnGap={1}>
842            <Text bold color="yellow">↓ {git.behind} behind {git.base.replace(/^origin\//, '')}</Text>
843            <Text dimColor>·</Text>
844            {git.conflicts === null && <Text dimColor>conflict check unavailable</Text>}
845            {git.conflicts !== null && files.length === 0 && <Text color="green">rebase looks clean</Text>}
846            {files.length > 0 && (
847              <Text color="red" wrap="truncate-end">
848                conflicts likely in {plural(files.length, 'file')}:{' '}
849                {files.slice(0, 2).map(path => path.split('/').pop()).join(', ')}
850                {files.length > 2 ? ', …' : ''}
851              </Text>
852            )}
853            <Text> </Text>
854            <Button
855              key="rebase"
856              plain
857              hotkey="b"
858              label="Rebase & push"
859              onPress={() => void $.prompt.submit({ text: rebaseAsk(git.base), asUser: true })}
860            />
861            <Button key="hide-rebase" plain dimColor hotkey="h" label="Hide" onPress={() => update($, hiddenRebase, () => git.baseSha)} />
862          </Box>
863        )}
864        </Box>
865        {below}
866      </Box>
867    )
868  })
869
870  // --- The full view, opened with /pr-watch --------------------------------
871
872  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
873    const { Box, Button, Text } = $.ui.resolve(e)
874    const pr = await read($, snapshot)
875    const git = await read($, gitState)
876
877    if (pr === null) {
878      return (
879        <Box flexDirection="column">
880          <Text dimColor>
881            {(await read($, isLoaded)) ? 'This branch has no pull request.' : 'Looking for the pull request…'}
882          </Text>
883        </Box>
884      )
885    }
886
887    const now = overall(pr)
888    const head = HEAD[now]
889    const width = Math.max(30, Math.min(e.props.bodyColumns, 76))
890    const shown = pr.checks.filter(check => check.state !== 'skipped')
891    const skipped = pr.checks.filter(check => check.state === 'skipped')
892    const failed = pr.checks.filter(check => check.state === 'fail')
893    const room = e.props.placement === 'dock' ? 6 : 3
894    const behind = git !== null && git.branch === pr.branch ? git.behind : null
895
896    return (
897      <Box flexDirection="column" width={width}>
898        <Text bold color={head.color} wrap="truncate-end">
899          {head.mark} PR #{pr.number} · {pr.title}
900        </Text>
901        <Box>
902          <Text dimColor>Status  </Text>
903          <Text bold color={head.color}>{head.word}</Text>
904          {pr.isDraft && <Text dimColor> · draft</Text>}
905        </Box>
906        {pr.issue !== null && (
907          <Box>
908            <Text dimColor>Issue   </Text>
909            <Text wrap="truncate-end">
910              #{pr.issue.number} {pr.issue.title}
911              {pr.issue.total > 0 ? ` · ${pr.issue.done}/${pr.issue.total} done` : ''}
912            </Text>
913          </Box>
914        )}
915        <Box>
916          <Text dimColor>Branch  </Text>
917          <Text wrap="truncate-end">{pr.branch}</Text>
918          {behind !== null && pr.state === 'OPEN' && (
919            <Text color={behind > 0 ? 'yellow' : undefined} dimColor={behind === 0}>
920              {behind > 0 ? ` · ${behind} behind ${git?.base.replace(/^origin\//, '') ?? 'base'}` : ' · up to date'}
921            </Text>
922          )}
923        </Box>
924        <Text> </Text>
925        {pr.checks.length === 0 && <Text dimColor>  No checks reported yet.</Text>}
926        {shown.map(check => {
927          const log = pr.logs.find(one => one.jobId === check.jobId)
928
929          return (
930            <Box flexDirection="column">
931              <Box>
932                <Text color={COLOR[check.state]}>  {GLYPH[check.state]} </Text>
933                <Box flexGrow={1}>
934                  <Text bold={check.state === 'fail'} wrap="truncate-end">{check.name}</Text>
935                </Box>
936                <Text dimColor>
937                  {check.state === 'running' ? ' running ' : check.state === 'queued' ? ' queued' : ' '}
938                  {duration(check.seconds)}
939                </Text>
940              </Box>
941              {check.state === 'fail' && log === undefined && (
942                <Text dimColor italic>      log arrives when the run finishes</Text>
943              )}
944              {log !== undefined && (
945                <Box flexDirection="column" marginLeft={6}>
946                  {log.step !== '' && <Text color="red" wrap="truncate-end">{log.step}</Text>}
947                  {headline(log.lines, room).map(line => (
948                    <Text dimColor wrap="truncate-end">{line}</Text>
949                  ))}
950                </Box>
951              )}
952            </Box>
953          )
954        })}
955        {skipped.length > 0 && (
956          <Text dimColor italic wrap="truncate-end">
957            {'  '}– {skipped.length} skipped by path filter: {skipped.map(check => check.name.split(' — ')[0]).join(', ')}
958          </Text>
959        )}
960        <Text> </Text>
961        <Box columnGap={2} flexWrap="wrap">
962          {failed.length > 0 && <Button key="retry" plain hotkey="1" label="Retry failed" onPress={() => retry($, pr)} />}
963          {failed.length > 0 && <Button key="hand" plain hotkey="2" label="Hand log to Claude" onPress={() => hand($, pr)} />}
964          <Button key="web" plain hotkey="3" label="Open in browser" onPress={() => browse($, pr)} />
965          <Button key="refresh" plain hotkey="r" label="Refresh" onPress={() => refreshPr($)} />
966        </Box>
967      </Box>
968    )
969  })
970}
971
hooks/parse.ts 142 lines
1import type { PrWatchCheck, PrWatchCheckState, PrWatchSnapshot } from '../types'
2
3type RollupItem = {
4  __typename?: string
5  name?: string
6  context?: string
7  status?: string
8  state?: string
9  conclusion?: string
10  startedAt?: string
11  completedAt?: string
12  detailsUrl?: string
13}
14
15export type Overall = 'failed' | 'running' | 'green' | 'merged' | 'closed'
16
17const FAILED = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'ERROR'])
18const SKIPPED = new Set(['SKIPPED', 'NEUTRAL'])
19
20const stateOf = (item: RollupItem): PrWatchCheckState => {
21  if (item.__typename === 'StatusContext') {
22    if (item.state === 'SUCCESS') return 'pass'
23    if (item.state === 'PENDING' || item.state === 'EXPECTED') return 'running'
24
25    return 'fail'
26  }
27  if (item.status !== 'COMPLETED') {
28    return item.status === 'IN_PROGRESS' ? 'running' : 'queued'
29  }
30  if (FAILED.has(item.conclusion ?? '')) return 'fail'
31  if (SKIPPED.has(item.conclusion ?? '')) return 'skipped'
32
33  return 'pass'
34}
35
36const time = (text: string | undefined): number | null => {
37  const ms = text === undefined ? NaN : Date.parse(text)
38
39  return Number.isNaN(ms) || ms <= 0 ? null : ms
40}
41
42export const parseChecks = (rollup: readonly RollupItem[], now: number): PrWatchCheck[] => {
43  const latest = new Map<string, { check: PrWatchCheck; startedAt: number }>()
44
45  for (const item of rollup) {
46    const name = item.name ?? item.context ?? 'check'
47    const state = stateOf(item)
48    const startedAt = time(item.startedAt)
49    const completedAt = time(item.completedAt)
50    const end = state === 'running' ? now : completedAt
51    const ids = /\/actions\/runs\/(\d+)\/job\/(\d+)/.exec(item.detailsUrl ?? '')
52    const check: PrWatchCheck = {
53      name,
54      state,
55      seconds:
56        startedAt === null || end === null || state === 'skipped' || state === 'queued'
57          ? null
58          : Math.max(0, Math.round((end - startedAt) / 1000)),
59      runId: ids?.[1] ?? null,
60      jobId: ids?.[2] ?? null,
61    }
62    const seen = latest.get(name)
63
64    if (seen === undefined || (startedAt ?? 0) >= seen.startedAt) {
65      latest.set(name, { check, startedAt: startedAt ?? 0 })
66    }
67  }
68
69  return [...latest.values()].map(one => one.check)
70}
71
72const ANSI = /\u001b\[[0-9;]*[A-Za-z]/g
73const STAMP = /^?\d{4}-\d{2}-\d{2}T[\d:.]+Z\s?/
74
75/** `gh run view --log-failed` prints `job<TAB>step<TAB>timestamp text` per line. */
76export const parseLog = (text: string): { step: string; lines: string[] } => {
77  let step = ''
78  const lines: string[] = []
79
80  for (const raw of text.split(/\r?\n/)) {
81    const parts = raw.split('\t')
82    const body = (parts.length >= 3 ? parts.slice(2).join('\t') : raw)
83      .replace(ANSI, '')
84      .replace(STAMP, '')
85      .trimEnd()
86
87    if (body.trim() === '' || /^##\[(group|endgroup|command|debug)\]/.test(body)) continue
88    if (parts.length >= 3) step = parts[1] ?? step
89    lines.push(body.replace(/^##\[error\]/, '').trim())
90  }
91
92  return { step, lines: lines.slice(-40) }
93}
94
95/** The lines worth a row in the pane: the ones that name the failure, else the tail. */
96export const headline = (lines: readonly string[], most: number): string[] => {
97  const telling = lines.filter(line =>
98    /\b(error|failed|failure|FAIL|AssertionError|Exception|not ok)\b/.test(line) &&
99    !/Process completed with exit code/.test(line),
100  )
101
102  return (telling.length > 0 ? telling : lines).slice(-most)
103}
104
105export const countBoxes = (body: string): { done: number; total: number } => {
106  const done = (body.match(/^\s*[-*] \[[xX]\]/gm) ?? []).length
107  const open = (body.match(/^\s*[-*] \[ \]/gm) ?? []).length
108
109  return { done, total: done + open }
110}
111
112export const duration = (seconds: number | null): string => {
113  if (seconds === null) return ''
114  if (seconds < 60) return `${seconds}s`
115
116  return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`
117}
118
119export const overall = (snapshot: PrWatchSnapshot): Overall => {
120  if (snapshot.state === 'MERGED') return 'merged'
121  if (snapshot.state === 'CLOSED') return 'closed'
122  if (snapshot.checks.some(check => check.state === 'fail')) return 'failed'
123  if (snapshot.checks.some(check => check.state === 'running' || check.state === 'queued')) return 'running'
124
125  return 'green'
126}
127
128export const issueNumber = (
129  closing: readonly { number?: number }[] | undefined,
130  title: string,
131  body: string,
132): number | null => {
133  const first = closing?.[0]?.number
134
135  if (typeof first === 'number') return first
136
137  const found =
138    /\(#(\d+)\)\s*$/.exec(title) ?? /\b(?:clos\w*|fix\w*|resolv\w*)\s+#(\d+)/i.exec(body)
139
140  return found === null ? null : Number(found[1])
141}
142
types/index.d.ts 55 lines
1export type PrWatchCheckState = 'pass' | 'fail' | 'running' | 'queued' | 'skipped'
2
3export type PrWatchCheck = {
4  name: string
5  state: PrWatchCheckState
6  seconds: number | null
7  runId: string | null
8  jobId: string | null
9}
10
11export type PrWatchLog = { jobId: string; step: string; lines: string[] }
12
13export type PrWatchIssue = { number: number; title: string; done: number; total: number }
14
15export type PrWatchSnapshot = {
16  number: number
17  title: string
18  state: 'OPEN' | 'MERGED' | 'CLOSED'
19  isDraft: boolean
20  headSha: string
21  branch: string
22  issue: PrWatchIssue | null
23  checks: PrWatchCheck[]
24  logs: PrWatchLog[]
25}
26
27export type PrWatchGit = {
28  branch: string
29  head: string
30  modified: number
31  untracked: number
32  /** Commits the branch lacks from the remote's default branch, and has on top of it. */
33  behind: number
34  ahead: number
35  /** Commits not on the branch's upstream yet; null when it has none. */
36  unpushed: number | null
37  /** Files a rebase would likely conflict in; null when it could not be tried. */
38  conflicts: string[] | null
39  /** The remote's default branch, as `origin/main`; empty when none was found. */
40  base: string
41  baseSha: string
42}
43
44declare module 'claude-code' {
45  interface PluginState {
46    'pr-watch': {
47      snapshot: PrWatchSnapshot | null
48      git: PrWatchGit | null
49      hiddenPr: string | null
50      hiddenRebase: string | null
51      isLoaded: boolean
52    }
53  }
54}
55