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…

<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.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).
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 | |
|---|---|
enter | go into the lane: its herdr workspace and agent, its dev servers started, its app in the emulator |
g | start the lane's agent (on its issue, if it has one) |
e | only point the emulator at the lane |
o | open the lane's web app in the browser |
i | install what the lane runs, when a lane shows no install (installs run one lane at a time) |
u / d | start / stop the lane's dev servers |
c | run the repo's checks for the apps the lane touched |
p | open the lane's proof |
n | new lane: an issue number starts an agent on it, a name only makes the lane |
a | adopt a worktree kitt did not create (Claude's, herdr's, a hand-made one) |
x | remove a lane that holds nothing unsaved |
t | show 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 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 set | the wordmark, four lines |
lockup | the tube beside a narrower wordmark, four lines |
small | tube and wordmark in three lines |
tube, bead | the tube alone, without or with a bead of putty |
medium, large, blocks | other sizes; blocks draws the tube with half blocks only |
text | no 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.
kitt env prints a lane's ports.link are symlinks into the main checkout, so every lane sees the same secrets and an edit reaches all of them.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 runs once, from the main checkout, for every lane (a database, a backend whose port is fixed).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.
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.
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.
| Shown | Key | What happens |
|---|---|---|
| the open PR: status, issue, one chip per check, the failing step and log lines | 1 2 3 x | retry failed jobs · hand the log to Claude · open in browser · hide |
| uncommitted work | c | Claude writes the message the repo asks for and calls commit; no push |
| commits not pushed, no PR yet | p | Claude runs the repo's checks, calls push, opens the PR |
| commits not pushed, PR open | p | Claude calls push |
| behind the default branch | b | Claude 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 merged | m | switches 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).
hooks/register.tsx 971 lines1import { 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}
971hooks/parse.ts 142 lines1import 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}
142types/index.d.ts 55 lines1export 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