A line above the prompt for each pull request Claude opens, merges by number, or is asked to watch, showing its GitHub workflows with a progress bar, then…

A line above the Claude Code prompt for each pull request Claude opens, merges by number, URL or branch, or is asked to watch, until the pull request closes, or until its merge has run its checks on the base branch. A branch Claude pushes gets a line too, until its checks pass.
#128 ● CI ████████████▏░░░░░░░░░░░ 1m11s / ~2m20s · CodeQL ✓ · Dependency Review ●
#128 ✓ ready to merge
#128 ✗ CI: Typecheck failed
#128 ⚠ conflicts
#128 merged into main · ● Release ████▏░░░░░░░░░░░░░░ 0m21s / ~1m40s · CI ●
#128 merged into main · ✓ checks passed
⟳ push feat/x · ● CI ███████▏░░░░░░░░░░░░░░░░░ 0m40s / ~2m20s
re-run, since GitHub keeps its first attempt's start time. The other workflows follow as marks: ✓ passed, ✗ failed, ● running.⚠ blocked).#128 merged into main, and follows the merge commit's runs there, every workflow counting, with the same bar and marks. Once they finish it reads ✓ checks passed or names the job that failed. It keeps reading for 90 seconds after the merge in case a later run starts. A passed merge then leaves the band a few seconds later. A failed one stays, read once a minute, until a re-run passes, and then leaves the same way; the × removes it sooner. A merge whose commit starts no runs within 90 seconds leaves the band.git push gets a line, ⟳ push <branch>, that follows the runs on the branch's newest commit the way a merged line does: it reads ✓ checks passed or names the job that failed, and leaves the same way. A branch that is the head of an open pull request hands its line to that pull request instead, upstream when the branch is on a fork. A push that moved no branch, such as a delete, a tag or Everything up-to-date, adds none, and neither does a git push -q, which prints nothing to read. A --dry-run prints what a push would, so it gets a line: a new branch leaves at its first read, and an existing one shows its current checks. A branch gone by the time it is read leaves at once, and one that cannot be read at all, such as on a host gh does not know, leaves once 90 seconds have passed.When a workflow has run more than once on the same commit, only its newest run counts.
Once nothing the merge waits on is running, a workflow it does not wait on still shows as a mark while it runs or after it fails.
A toast says when a pull request turns ready to merge, a job fails in a workflow the merge waits on, or a merge's runs on the base branch pass or fail, once per change. A line says why when gh fails, and · more checks not shown when a commit has more check suites than one read returns (100). Hover a line and press × to stop watching it. A pull request closed without merging leaves the band.
pr-watch draws above whatever other plugins draw in the same band, rather than replacing it.
pr-watch also tells Claude, so you do not have to prompt it. It submits a message to the session, which runs once Claude is idle, when:
Each is told once while it lasts: a later job failing in a run already told, such as a summary job, is not told again, while a new run or a re-run that fails, or a pull request ready again after new checks, is. A watch's first read hears the comments and reviews already there without telling them. The message says it is news, not a request to merge. If it cannot be submitted, a toast says so; a hook that refuses it shows its own reason.
After ten reads in a row whose only news was comments and reviews, those stop waking Claude until other news comes, so a chatty bot or thread cannot keep it busy. The tenth message says so.
In /config:
| Setting | Values | Default |
|---|---|---|
| Wake Claude | off, checks, checks and comments | checks and comments |
| Bots wake Claude | never, reviews, comments and reviews | never |
checks tells everything above except comments and reviews. off leaves the line and its toasts as they are. The bot setting applies when Wake Claude includes comments: reviews lets a review bot's reviews through, such as CodeRabbit or Copilot, but not bot comments such as plans, coverage reports or previews. Changing a setting reports no history: comments heard while they were not told stay quiet, and whatever still lasts when waking is turned back on is told once.
pr-watch gives Claude three tools, so it can follow a pull request it did not open, such as a teammate's, instead of polling gh pr checks or sleeping:
watch takes a pull request's number, URL or head branch, with a repository when it is not the current directory's. It reads the pull request at once and answers with what its line shows, along with any news that first read found; pr-watch then tells Claude when that changes.unwatch stops watching a pull request, by number, URL or head branch, or a pushed branch. A number two watched repositories share is refused until the repository is named.watches lists what pr-watch is watching and what each line shows.gh pr create that Claude runs through its Bash tool, and the branches in the output of a git push it runs there. Pull requests opened and branches pushed from your own terminal or a GitHub tool are not seen.gh pr merge Claude runs that names its pull request (a number, URL or branch) watches it too, if nothing did, and follows its merge onto the base branch; with --auto, the line follows the open pull request until it merges. A bare gh pr merge is not followed: after --delete-branch, or on a fork's branch, nothing names its pull request. Neither is a merge run after a cd in the same command or with a GH_HOST= prefix, nor a pull request merged more than two minutes before the command started (the leeway is for GitHub's clock), so a command that only mentions an old merge adds nothing.gh api graphql call: every 10 seconds while any workflow runs or GitHub is still settling, every 60 seconds while it waits on a person, and every 5 minutes once the line has said the same for half an hour, so a pull request left waiting on review, conflicts or failed checks, or a merge left failing, can wait up to 5 minutes for news of a comment or re-run. When Claude runs git push, gh pr merge, gh run rerun or gh workflow run, it reads every watched pull request at once and every 5 seconds for the next minute, while GitHub starts the new runs. Each workflow's length comes from one gh api call the first time pr-watch sees it, asked again a minute later if that call fails, and once each time a run of it finishes.gh calls share: an open pull request is read for its head commit's runs, a merged one for its merge commit's, and the read that first finds it merged reads again for those. While fewer than a tenth of the points are left, every watch on that host is read once a minute, after a push or merge too.rate limited until HH:MM: until the quota resets when the last read left none, otherwise a minute, doubling each time the limit is hit again up to 15 minutes. Each line on the host is read again as the pause ends, and a push not yet read stays through it. A read that fails for any other reason is retried after 10 seconds, doubling up to 5 minutes; after 8 in a row, some 15 minutes, pr-watch stops watching that pull request or push, says why in a toast, and tells Claude, as /config allows. A rate limit never counts toward the 8. A push that no read has answered yet leaves 90 seconds after it, rate limits aside, as one on a host gh cannot read would.gh, logged in to the pull request's host. GitHub Enterprise hosts are passed to gh as --hostname.claude plugin marketplace add oakoss/claude-plugins
claude plugin install pr-watch@oakoss
pr-watch is a mod: Claude Code runs its hooks module itself. It is built and tested against Claude Code 2.1.289.
hooks/register.tsx 754 lines1import { atom, read, update, type EngineInterface, type Register, type Timer } from 'claude-code';
2
3import type { Pull, Watch } from '../types';
4import {
5 estimateArgs,
6 parseEstimate,
7 parsePull,
8 parsePush,
9 parseQuota,
10 pullArgs,
11 pushArgs,
12 viewArgs,
13 type Quota,
14} from './github';
15import {
16 createdPull,
17 mergedPullOf,
18 mergingPull,
19 viewedPull,
20 pushedBranches,
21 pushedPull,
22 delayOf,
23 errorLine,
24 estimateKey,
25 lineOf,
26 movesPulls,
27 isGone,
28 isChecking,
29 isMergeFresh,
30 isQuotaLow,
31 isRateLimited,
32 GIVE_UP_AFTER,
33 LOW_QUOTA_MS,
34 pauseOf,
35 retryDelayOf,
36 untilText,
37 isSettled,
38 labelOf,
39 shownOf,
40 settingsOf,
41 tellsOf,
42 toastOf,
43 toldAfter,
44 verdictOf,
45 wakeOf,
46 type Merging,
47 type Memory,
48 type Pause,
49 type Segment,
50 type Target,
51 type Wake,
52} from './watch';
53
54type $ = EngineInterface;
55
56const watches = atom({ plugin: 'pr-watch', key: 'watches' } as const, []);
57const estimates = atom({ plugin: 'pr-watch', key: 'estimates' } as const, {});
58const clock = atom({ plugin: 'pr-watch', key: 'now' } as const, 0);
59
60const TICK_MS = 1000;
61const ESTIMATE_RETRY_MS = 60_000;
62const VIEW_TIMEOUT_MS = 10_000;
63const busy = new Set<string>();
64// When each watch was last read, kept here too so a read whose result could
65// not be saved still waits out its delay.
66const tried = new Map<string, number>();
67// When each unanswered length was last asked for, by estimate key.
68const asked = new Map<string, number>();
69// The GraphQL quota each host's latest read reported.
70const quota = new Map<string, Quota>();
71// Each rate-limited host's pause, kept after it ends until a read succeeds, so
72// a limit hit again waits longer.
73const paused = new Map<string, Pause>();
74// Finished runs a length was learned after, so each one re-learns it once.
75const learnedAfter = new Set<string>();
76// After Claude pushes, merges or starts a run, a watch last read before
77// pushedAt is read at once, and every watch every BURST_MS until burstUntil:
78// GitHub starts the new runs a few seconds later.
79const BURST_MS = 5000;
80const BURST_FOR_MS = 60_000;
81let pushedAt = 0;
82let burstUntil = 0;
83let poller: Timer | undefined;
84let isTickFailing = false;
85let isSaveFailing = false;
86// From /config; a change there reloads the module with the new values.
87let config = { isAwake: true, tells: tellsOf('checks and comments', 'never') };
88
89const idOf = (w: Pick<Watch, 'host' | 'repo' | 'number' | 'push'>) =>
90 `${w.host}/${w.repo}${w.push ? `@${w.push.branch}` : `#${w.number}`}`;
91
92// The same watch, not a later push of the same branch that replaced it.
93const isSame = (a: Watch, b: Watch) => idOf(a) === idOf(b) && a.push?.pushedAt === b.push?.pushedAt;
94
95function errorText(error: unknown): string {
96 return errorLine(error instanceof Error ? error.message : String(error));
97}
98
99// Not awaited: a plugin's prompt runs once the session is idle, so the news
100// waits for any turn in progress rather than holding up the poll. A hook that
101// drops it has its reason shown by the engine.
102function wake($: $, text: string, label: string): void {
103 Promise.resolve()
104 .then(() => $.prompt.submit({ text }))
105 .catch((error: unknown) => {
106 $.ui.toast(`pr-watch could not tell Claude about ${label}: ${errorText(error)}`);
107 })
108 // A toast that throws leaves nowhere to say so.
109 .catch(() => null);
110}
111
112// An unknown length that cannot be learned or kept is asked again after the
113// retry delay, the run drawing no bar meanwhile. A known one is asked again
114// once per finished run, and kept when that answer is missing or says none.
115async function learnEstimates($: $, w: Watch, now: number): Promise<void> {
116 let known: Record<string, number>;
117 try {
118 known = await read($, estimates);
119 } catch {
120 return;
121 }
122 for (const flow of [...(w.pull?.workflows ?? []), ...(w.pull?.mergeRuns?.workflows ?? [])]) {
123 const key = estimateKey(w.host, flow.id);
124 const run = flow.status === 'done' ? JSON.stringify([key, flow.url, flow.attempt]) : null;
125 const isKnown = known[key] !== undefined;
126 if (isKnown) {
127 if (run === null || learnedAfter.has(run)) continue;
128 learnedAfter.add(run);
129 } else if (now - (asked.get(key) ?? -Infinity) < ESTIMATE_RETRY_MS) {
130 continue;
131 }
132 asked.set(key, now);
133 try {
134 const r = await $.process.run(estimateArgs(w.host, w.repo, flow.id));
135 const ms = r.exitCode === 0 ? parseEstimate(r.stdout) : null;
136 if (ms === null || (ms === 0 && isKnown)) continue;
137 await update($, estimates, (all) => ({ ...all, [key]: ms }));
138 asked.delete(key);
139 if (run !== null) learnedAfter.add(run);
140 } catch {
141 continue;
142 }
143 }
144}
145
146class RateLimited extends Error {
147 constructor(readonly until: number) {
148 super(`rate limited until ${untilText(until)}`);
149 }
150}
151
152// gh exits non-zero when GraphQL reports any error, even beside usable data.
153async function readGh<T>(
154 $: $,
155 host: string,
156 argv: string[],
157 parse: (stdout: string) => T,
158): Promise<T> {
159 const before = paused.get(host);
160 if (before && (await $.clock.now()) < before.until) {
161 throw new RateLimited(before.until);
162 }
163 const r = await $.process.run(argv);
164 const left = parseQuota(r.stdout);
165 const was = quota.get(host);
166 // Reads finish out of order: within one reset window, the lowest count is the latest.
167 const isStale = was?.resetAt === left?.resetAt && (was?.remaining ?? 0) < (left?.remaining ?? 0);
168 if (left && !isStale) quota.set(host, left);
169 if (r.exitCode !== 0 && isRateLimited(r.stdout, r.stderr)) {
170 const now = await $.clock.now();
171 // Reads limited together pause once, rather than each doubling the wait.
172 const current = paused.get(host);
173 const pause = current && current !== before ? current : pauseOf(quota.get(host), current, now);
174 paused.set(host, pause);
175 throw new RateLimited(pause.until);
176 }
177 try {
178 return parse(r.stdout);
179 } catch (error) {
180 if (r.exitCode === 0) throw error;
181 throw new Error(r.stderr.trim() || `gh exited ${r.exitCode}`, { cause: error });
182 }
183}
184
185type Read = { kind: 'pull'; pull: Pull } | { kind: 'handoff'; to: Watch } | { kind: 'gone' };
186
187// A push whose branch heads an open pull request hands its line to that PR.
188async function readWatch($: $, w: Watch): Promise<Read> {
189 if (!w.push) {
190 const read = (isMerged: boolean) =>
191 readGh($, w.host, pullArgs(w.host, w.repo, w.number, isMerged), parsePull);
192 // Until a read says it merged, read the open variant; on the read that
193 // first finds it merged, read again for the merge commit's runs.
194 const wasMerged = w.pull?.state === 'MERGED';
195 const pull = await read(wasMerged);
196 if (wasMerged || pull.state !== 'MERGED') return { kind: 'pull', pull };
197 try {
198 return { kind: 'pull', pull: await read(true) };
199 } catch (error) {
200 // Just merged, it shows as waiting on checks while the next read asks for
201 // them. Older, it would read as closed and leave, so the failure is said.
202 if (error instanceof RateLimited || !isMergeFresh(pull, await $.clock.now())) throw error;
203 return { kind: 'pull', pull: { ...pull, workflows: [], mergeRuns: null } };
204 }
205 }
206 const { branch, pushedAt } = w.push;
207 const pushed = await readGh($, w.host, pushArgs(w.host, w.repo, branch), parsePush);
208 if (pushed.kind === 'gone') return pushed;
209 if (pushed.kind === 'pr') {
210 const { repo, number } = pushed;
211 const url = pushed.url ?? `https://${w.host}/${repo}/pull/${number}`;
212 return { kind: 'handoff', to: { host: w.host, repo, number, url, checkedAt: 0 } };
213 }
214 return { kind: 'pull', pull: pushedPull(branch, pushedAt, pushed.runs) };
215}
216
217// What a read leaves for a caller that tells it itself: its news, whether it
218// found the pull request closed, and why it could not finish.
219type Kept = { news: string | null; isClosed?: boolean; error?: string };
220
221async function refresh($: $, target: Watch, now: number, kept?: Kept): Promise<void> {
222 const id = idOf(target);
223 busy.add(id);
224 tried.set(id, now);
225 try {
226 let next: Watch;
227 let isLimited = false;
228 const pause = paused.get(target.host);
229 // A read that began before another read paused the host still waits it out.
230 const pausedUntil = async () => {
231 const until = paused.get(target.host)?.until;
232 return until !== undefined && (await $.clock.now()) < until ? until : undefined;
233 };
234 try {
235 const read = await readWatch($, target);
236 // Only a whole read that began after the pause was set shows the limit
237 // lifted: a merged pull request's second read may still be refused.
238 if (paused.get(target.host) === pause) paused.delete(target.host);
239 const limitedUntil = await pausedUntil();
240 if (read.kind === 'gone') {
241 await update($, watches, (all) => all.filter((w) => !isSame(w, target)));
242 return;
243 }
244 if (read.kind === 'handoff') {
245 // The PR's runs are the push's, so what Claude was told of carries over.
246 const told = target.told ?? [];
247 const to = { ...read.to, told };
248 await update($, watches, (all) => {
249 if (!all.some((w) => isSame(w, target))) return all;
250 const rest = all.filter((w) => !isSame(w, target));
251 if (!rest.some((w) => idOf(w) === idOf(to))) return [...rest, to];
252 return rest.map((w) =>
253 idOf(w) === idOf(to) ? { ...w, told: [...new Set([...(w.told ?? []), ...told])] } : w,
254 );
255 });
256 return;
257 }
258 next = {
259 ...target,
260 pull: read.pull,
261 error: undefined,
262 failures: undefined,
263 limitedUntil,
264 checkedAt: now,
265 };
266 } catch (error) {
267 // checkedAt stays the last good read's: settling is judged by it, and
268 // `tried` already spaces the retries. A rate limit is GitHub's to lift,
269 // so it does not count toward giving up.
270 if (error instanceof RateLimited) {
271 isLimited = true;
272 next = { ...target, limitedUntil: error.until };
273 } else {
274 next = {
275 ...target,
276 error: `gh failed: ${errorText(error)}`,
277 failures: (target.failures ?? 0) + 1,
278 limitedUntil: await pausedUntil(),
279 };
280 }
281 }
282 const isGivenUp = !isLimited && (next.failures ?? 0) >= GIVE_UP_AFTER;
283 // Only a read that answered is judged; a limited one keeps the last good state.
284 const isFresh = !isLimited && next.error === undefined;
285 let toast: string | null = null;
286 let news: string | null = null;
287 let wakeFor: ((last: Memory) => Wake) | null = null;
288 let isClosed = false;
289 if (next.pull && isFresh) {
290 const pull = next.pull;
291 const verdict = verdictOf(pull, await read($, estimates), next.host, now);
292 isClosed = verdict.kind === 'closed';
293 if (kept) kept.isClosed = isClosed;
294 if (!isClosed) {
295 // A passed merge is said only once no later run can start: until then
296 // it is not yet what the line has said.
297 const isEarly = verdict.kind === 'merged-passed' && !isSettled(verdict, pull, now);
298 const shown = target.shown;
299 wakeFor = (last) => wakeOf(next, pull, verdict, last, { isEarly, tells: config.tells });
300 // A ready pull request read as checking is not ready again afterwards.
301 if (!isEarly && !isChecking(verdict)) {
302 toast = toastOf(labelOf(next), verdict, shown, next.push ? '' : ' merged');
303 next.shown = shownOf(verdict);
304 if (next.shown !== shown || next.shownAt === undefined) next.shownAt = now;
305 }
306 }
307 }
308 let isSaved = false;
309 await update($, watches, (all) => {
310 isSaved = all.some((w) => isSame(w, target));
311 if (isClosed || isGivenUp) return all.filter((w) => !isSame(w, target));
312 // Every watch on the host waits out the pause, so each line says so.
313 if (isLimited) {
314 const { limitedUntil } = next;
315 return all.map((w) =>
316 isSame(w, target) ? next : w.host === next.host ? { ...w, limitedUntil } : w,
317 );
318 }
319 if (wakeFor) {
320 // Judged against every watch on the host as saved now: a push and its
321 // pull request read the same runs, and either may be read first.
322 const known = all.filter((w) => w.host === next.host).flatMap((w) => w.told ?? []);
323 const told = [...new Set([...(target.told ?? []), ...known])];
324 const woke = wakeFor({ told, heard: target.heard });
325 // With waking off, comments are still heard, so turning it on reports no
326 // history of them.
327 if (config.isAwake) news = woke.text;
328 next.told = toldAfter(woke.told, target.told, config.isAwake);
329 next.heard = woke.heard;
330 }
331 return all.map((w) => (isSame(w, target) ? next : w));
332 });
333 isSaveFailing = false;
334 // Only once the line says it, so a lost save does not toast or wake again.
335 if (toast && isSaved) $.ui.toast(toast);
336 if (isGivenUp && isSaved) {
337 $.ui.toast(`pr-watch stopped watching ${labelOf(next)}: ${next.error}`);
338 news = [
339 `pr-watch: ${GIVE_UP_AFTER} reads of ${nameOf(next)} in a row failed, so pr-watch stopped watching it. The last: ${next.error}`,
340 `Watch it again with pr-watch's watch tool once gh can read it. ${next.url}`,
341 ].join('\n');
342 // A tool's caller is told in its answer, whatever /config says.
343 if (!kept && !config.isAwake) news = null;
344 }
345 if (news && isSaved) {
346 if (kept) kept.news = news;
347 else wake($, news, labelOf(next));
348 }
349 if (!isClosed && isFresh) await learnEstimates($, next, now);
350 } catch (error) {
351 // Kept on the line so the band says why; said once when even that fails.
352 // The read itself succeeded or was caught above, so this is pr-watch's own,
353 // counted so that one failing every time is retried less often.
354 const why = errorText(error);
355 if (kept) kept.error = why;
356 // The read ran, so the line says a pause only while its host is still in one.
357 const until = paused.get(target.host)?.until;
358 const limitedUntil = until !== undefined && (await $.clock.now()) < until ? until : undefined;
359 try {
360 await update($, watches, (all) =>
361 all.map((w) =>
362 isSame(w, target)
363 ? {
364 ...w,
365 error: `pr-watch failed: ${why}`,
366 failures: (w.failures ?? 0) + 1,
367 limitedUntil,
368 }
369 : w,
370 ),
371 );
372 } catch {
373 if (!isSaveFailing) $.ui.toast(`pr-watch could not save ${labelOf(target)}: ${why}`);
374 isSaveFailing = true;
375 }
376 } finally {
377 busy.delete(id);
378 }
379}
380
381async function tick($: $): Promise<void> {
382 const saved = await read($, watches);
383 if (saved.length === 0) return;
384 const now = await $.clock.now();
385 const known = await read($, estimates);
386 // Judged on the list as saved, so a push that replaced a watch since stays.
387 let list: Watch[] = [];
388 await update($, watches, (all) => {
389 // A watch added while its host is paused waits it out like the rest, and
390 // says so; a push not yet read stays while it waits.
391 let isChanged = false;
392 list = all.flatMap((w) => {
393 const until = paused.get(w.host)?.until ?? 0;
394 const held = now < until && w.limitedUntil === undefined ? { ...w, limitedUntil: until } : w;
395 if (held !== w) isChanged = true;
396 const isKept = !isGone(held, known, now) || (!held.pull && held.limitedUntil !== undefined);
397 if (!isKept) isChanged = true;
398 return isKept ? [held] : [];
399 });
400 return isChanged ? list : all;
401 });
402 let isMoving = false;
403 for (const w of list) {
404 // Settling and closing are judged at the last read, so a run that started
405 // after it is not missed.
406 const verdict = w.pull ? verdictOf(w.pull, known, w.host, w.checkedAt) : null;
407 if (verdict?.kind === 'running' || verdict?.kind === 'merged-running') isMoving = true;
408 const pauseEnd = paused.get(w.host)?.until ?? 0;
409 if (now < pauseEnd) continue;
410 const usual = verdict && w.pull ? delayOf(verdict, w.pull, w.checkedAt, w.shownAt) : 10_000;
411 // A failing watch retries on its own schedule; low on quota, a watch waits
412 // its slow delay. Neither bursts.
413 const low = isQuotaLow(quota.get(w.host)) ? LOW_QUOTA_MS : 0;
414 const isSlowed = low > 0 || Boolean(w.failures);
415 const delay =
416 usual === null
417 ? null
418 : w.failures
419 ? Math.max(retryDelayOf(w.failures), low)
420 : low > 0
421 ? Math.max(usual, low)
422 : now < burstUntil
423 ? Math.min(usual, BURST_MS)
424 : usual;
425 if (delay === null || busy.has(idOf(w))) continue;
426 const last = Math.max(w.checkedAt, tried.get(idOf(w)) ?? 0);
427 // A line says the pause until a read of its own, so the end of one reads it at once.
428 const isDue =
429 w.limitedUntil !== undefined || (last <= pushedAt && !isSlowed) || now - last >= delay;
430 if (isDue) void refresh($, w, now);
431 }
432 if (isMoving) await update($, clock, () => now);
433}
434
435// A poll that fails is said once, until one succeeds.
436async function safeTick($: $): Promise<void> {
437 try {
438 await tick($);
439 isTickFailing = false;
440 } catch (error) {
441 if (!isTickFailing) $.ui.toast(`pr-watch stopped updating: ${errorText(error)}`);
442 isTickFailing = true;
443 }
444}
445
446// Watches the pull request a merge named, unless something already does.
447async function followMerge($: $, m: Merging, startedAt: number): Promise<void> {
448 try {
449 const r = await $.process.run(viewArgs(m.pull, m.repo), { timeoutMs: VIEW_TIMEOUT_MS });
450 if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `gh exited ${r.exitCode}`);
451 const target = mergedPullOf(r.stdout, startedAt);
452 if (target === 'stale') return;
453 if (target === null) {
454 throw new Error(
455 `gh pr view named no pull request: ${r.stdout.trim().slice(0, 80) || '(empty)'}`,
456 );
457 }
458 const id = idOf(target);
459 await update($, watches, (all) =>
460 all.some((w) => idOf(w) === id) ? all : [...all, { ...target, checkedAt: 0 }],
461 );
462 } catch (error) {
463 $.ui.toast(`pr-watch could not follow the merge of ${m.pull}: ${errorText(error)}`);
464 }
465}
466
467async function stop($: $, id: string): Promise<void> {
468 await update($, watches, (all) => all.filter((w) => idOf(w) !== id));
469}
470
471const TOOL_COLUMNS = 80;
472
473// A watch's line as text, for a tool's answer.
474function textOf(w: Watch, now: number, known: Record<string, number>): string {
475 return lineOf(w, now, known, TOOL_COLUMNS)
476 .map((s) => s.text)
477 .join('');
478}
479
480const nameOf = (w: Pick<Watch, 'repo' | 'number' | 'push'>) =>
481 w.push ? `the push to ${w.push.branch} on ${w.repo}` : `${w.repo}#${w.number}`;
482
483type ToolInput = { pull: string; repo: string | null };
484
485function toolInputOf(e: unknown): ToolInput | string {
486 const { pull, repo } = e as { pull?: unknown; repo?: unknown };
487 if (typeof pull !== 'string' || pull.trim() === '') {
488 return 'pr-watch: `pull` names a pull request: its number, URL or head branch.';
489 }
490 if (repo !== undefined && repo !== null && typeof repo !== 'string') {
491 return 'pr-watch: `repo` is the repository as owner/name.';
492 }
493 return { pull: pull.trim(), repo: typeof repo === 'string' && repo !== '' ? repo : null };
494}
495
496// The pull request gh resolves a tool's input to, or why it cannot.
497async function viewedTarget($: $, input: ToolInput): Promise<Target | string> {
498 const r = await $.process.run(viewArgs(input.pull, input.repo), {
499 timeoutMs: VIEW_TIMEOUT_MS,
500 });
501 if (r.exitCode !== 0) {
502 const why = r.stderr.trim() ? errorText(r.stderr) : `gh exited ${r.exitCode}`;
503 return `pr-watch could not read ${input.pull} with gh pr view: ${why}`;
504 }
505 const printed = r.stdout.trim().slice(0, 80) || '(empty)';
506 return (
507 viewedPull(r.stdout)?.target ??
508 `pr-watch: gh pr view named no pull request for ${input.pull}: ${printed}`
509 );
510}
511
512// Watches a pull request and answers with what it shows now. The news a first
513// read finds goes in the answer: the host refuses a prompt from a tool call's
514// hook, since it would wait on the turn the hook holds.
515async function onWatchTool($: $, e: unknown): Promise<{ result: string }> {
516 const input = toolInputOf(e);
517 if (typeof input === 'string') return { result: input };
518 let name = input.pull;
519 let isAdded = false;
520 try {
521 const target = await viewedTarget($, input);
522 if (typeof target === 'string') return { result: target };
523 const id = idOf(target);
524 name = nameOf(target);
525 let isNew = false;
526 await update($, watches, (all) => {
527 if (all.some((w) => idOf(w) === id)) return all;
528 isNew = true;
529 return [...all, { ...target, checkedAt: 0 }];
530 });
531 isAdded = true;
532 const now = await $.clock.now();
533 const kept: Kept = { news: null };
534 const before = await read($, watches);
535 const watch = before.find((w) => idOf(w) === id);
536 // A read already in flight tells its own news; a second would toast it again.
537 if (watch && !busy.has(id)) await refresh($, watch, now, kept);
538 const saved = await read($, watches);
539 const after = saved.find((w) => idOf(w) === id);
540 if (!after) {
541 if (kept.isClosed) return { result: `${name} is closed, so pr-watch is not watching it.` };
542 return {
543 result:
544 kept.news ?? `${name} is no longer watched: its line was removed while pr-watch read it.`,
545 };
546 }
547 const line = textOf(after, now, await read($, estimates));
548 const head = `pr-watch ${isNew ? 'is now watching' : 'was already watching'} ${name}: ${line}`;
549 if (kept.error !== undefined) {
550 return { result: `${head}\npr-watch could not finish its first read: ${kept.error}` };
551 }
552 const closing = config.isAwake
553 ? 'pr-watch tells you in this conversation when that changes, so there is no need to poll gh.'
554 : 'Waking Claude is off in /config: changes show on the line, and the watches tool reads them.';
555 return { result: [head, kept.news, closing].filter(Boolean).join('\n') };
556 } catch (error) {
557 const why = errorText(error);
558 return {
559 result: isAdded
560 ? `pr-watch is watching ${name} but could not read it yet: ${why}`
561 : `pr-watch could not watch ${input.pull}: ${why}`,
562 };
563 }
564}
565
566async function onUnwatchTool($: $, e: unknown): Promise<{ result: string }> {
567 const input = toolInputOf(e);
568 if (typeof input === 'string') return { result: input };
569 try {
570 const list = await read($, watches);
571 const number = /^#?(\d+)$/.exec(input.pull)?.[1];
572 const inRepo = (w: Watch) => input.repo === null || w.repo === input.repo;
573 let found = list.filter(
574 (w) =>
575 w.url === input.pull ||
576 (!w.push && number !== undefined && w.number === Number(number) && inRepo(w)) ||
577 (w.push?.branch === input.pull && inRepo(w)),
578 );
579 // A head branch names its pull request only through gh.
580 if (found.length === 0 && number === undefined && !input.pull.includes('://')) {
581 const target = await viewedTarget($, input);
582 if (typeof target === 'string') {
583 return { result: `pr-watch is not watching a push to ${input.pull}, and ${target}` };
584 }
585 found = list.filter((w) => idOf(w) === idOf(target));
586 }
587 if (found.length === 0) return { result: `pr-watch is not watching ${input.pull}.` };
588 if (found.length > 1) {
589 const names = found.map((w) => w.url).join(', ');
590 return { result: `${input.pull} matches ${names}; name one by its URL or repo.` };
591 }
592 await stop($, idOf(found[0]!));
593 return { result: `pr-watch stopped watching ${nameOf(found[0]!)}.` };
594 } catch (error) {
595 return { result: `pr-watch could not stop watching ${input.pull}: ${errorText(error)}` };
596 }
597}
598
599async function onWatchesTool($: $): Promise<{ result: string }> {
600 try {
601 const list = await read($, watches);
602 if (list.length === 0) return { result: 'pr-watch is watching nothing.' };
603 const now = await $.clock.now();
604 const known = await read($, estimates);
605 return { result: list.map((w) => `${w.url}: ${textOf(w, now, known)}`).join('\n') };
606 } catch (error) {
607 return { result: `pr-watch could not list its watches: ${errorText(error)}` };
608 }
609}
610
611const PULL_INPUT = {
612 type: 'object',
613 properties: {
614 pull: { type: 'string', description: 'The pull request: its number, URL or head branch.' },
615 repo: {
616 type: 'string',
617 description: "owner/name, when it is not the current directory's repository.",
618 },
619 },
620 required: ['pull'],
621};
622
623const TOOLS = [
624 {
625 name: 'watch',
626 description:
627 "Watches a GitHub pull request in pr-watch and returns what it shows now. Its line above the prompt follows its checks, and pr-watch tells you in this conversation, as /config allows, when it turns ready to merge, a check fails, it has conflicts or requested changes, someone comments or reviews, or its merge's checks finish. Pull requests you open with gh pr create, or merge with gh pr merge and a number, URL or branch, are watched already; use this for any other you are waiting on, instead of polling gh pr checks or sleeping.",
628 inputSchema: PULL_INPUT,
629 },
630 {
631 name: 'unwatch',
632 description:
633 'Stops pr-watch watching a pull request (its number, URL or head branch) or a pushed branch, once you no longer need its news.',
634 inputSchema: PULL_INPUT,
635 },
636 {
637 name: 'watches',
638 description: 'Lists what pr-watch is watching, each with what its line shows now. Read-only.',
639 inputSchema: { type: 'object', properties: {} },
640 },
641];
642
643// Each on its own, so one refused leaves the others; without them Claude falls
644// back to gh, and the band and its news stay.
645async function registerTools($: $): Promise<void> {
646 const failed: string[] = [];
647 for (const tool of TOOLS) {
648 try {
649 await $.tool.register(tool);
650 } catch (error) {
651 failed.push(`${tool.name} (${errorText(error)})`);
652 }
653 }
654 if (failed.length > 0) {
655 $.ui.toast(`pr-watch could not offer Claude its tools: ${failed.join(', ')}`);
656 }
657}
658
659export const register: Register = (on, options) => {
660 const { wake, bots } = settingsOf(options);
661 config = { isAwake: wake !== 'off', tells: tellsOf(wake, bots) };
662 on('session.start', async ($, e, next) => {
663 // Registered before next, so the tools are listed by the first turn.
664 await registerTools($);
665 const r = await next(e);
666 poller?.cancel();
667 // oxlint-disable-next-line unicorn/no-array-method-this-argument -- a timer, not Array#every
668 poller = $.clock.every(TICK_MS, () => void safeTick($));
669 return r;
670 });
671
672 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
673 const merging = mergingPull(e.command);
674 const startedAt = merging ? await $.clock.now() : 0;
675 const r = await next(e);
676 if ('deny' in r) return r;
677 // A failed push still bursts: the exit status is the whole shell line's, so
678 // `git push; false` pushed and failed, and `git push || true` the reverse.
679 if (movesPulls(e.command)) {
680 pushedAt = await $.clock.now();
681 burstUntil = pushedAt + BURST_FOR_MS;
682 }
683 const raw = (r.result as { stdout?: unknown } | undefined)?.stdout;
684 const stdout = typeof raw === 'string' ? raw : '';
685 // Read even when the line failed: a push that moved one branch and had
686 // another rejected exits non-zero.
687 const pushes = pushedBranches(e.command, stdout);
688 if (pushes.length > 0) {
689 // Pushing a branch again starts its line over.
690 const now = await $.clock.now();
691 const fresh: Watch[] = pushes.map(({ branch, ...p }) => ({
692 ...p,
693 number: 0,
694 push: { branch, pushedAt: now },
695 checkedAt: 0,
696 }));
697 const ids = new Set(fresh.map((p) => idOf(p)));
698 await update($, watches, (all) => [...all.filter((w) => !ids.has(idOf(w))), ...fresh]);
699 }
700 if (r.isError) return r;
701 // A merged pull request nothing watched yet is followed onto its base
702 // branch. Not awaited, so the merge's result waits on no gh call.
703 if (merging) void followMerge($, merging, startedAt).catch(() => null);
704 const target = createdPull(e.command, stdout);
705 if (target) {
706 const id = idOf(target);
707 await update($, watches, (all) =>
708 all.some((w) => idOf(w) === id) ? all : [...all, { ...target, checkedAt: 0 }],
709 );
710 }
711 return r;
712 });
713
714 on('tool.call', { tool: 'mcp__pr-watch__watch' }, ($, e) => onWatchTool($, e));
715 on('tool.call', { tool: 'mcp__pr-watch__unwatch' }, ($, e) => onUnwatchTool($, e));
716 on('tool.call', { tool: 'mcp__pr-watch__watches' }, ($) => onWatchesTool($));
717
718 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
719 const list = await read($, watches);
720 if (e.props.hasSurvey || list.length === 0) return next(e);
721 const now = (await read($, clock)) || (await $.clock.now());
722 const known = await read($, estimates);
723 const { Box, Text, Link, Button } = $.ui.resolve(e);
724 const beneath = await next(e);
725 // A segment with a URL is a link; the text between links truncates.
726 return (
727 <Box flexDirection="column" marginTop={1}>
728 {list.map((w) => (
729 <Box key={`row-${idOf(w)}`} flexDirection="row">
730 {lineOf(w, now, known, e.props.bodyColumns).map((s: Segment, i) =>
731 s.url ? (
732 <Box key={`l${i}`} flexShrink={0}>
733 {s.text.startsWith(' ') ? <Text> </Text> : <Box />}
734 <Link href={s.url} label={s.text.trim()} />
735 {s.text.endsWith(' ') ? <Text> </Text> : <Box />}
736 </Box>
737 ) : (
738 <Text key={`t${i}`} wrap="truncate-end" color={s.color} dimColor={s.isDim}>
739 {s.text}
740 </Text>
741 ),
742 )}
743 <Box display="none" hover={{ display: 'flex' }} flexShrink={0}>
744 <Text> </Text>
745 <Button key={`stop-${idOf(w)}`} label="×" plain onPress={() => stop($, idOf(w))} />
746 </Box>
747 </Box>
748 ))}
749 {beneath}
750 </Box>
751 );
752 });
753};
754hooks/github.ts 361 lines1// The gh calls pr-watch makes and what it reads from their output. Pure:
2// register.tsx runs the commands.
3import type { Activity, Job, Pull, RunStatus, Workflow } from '../types';
4
5// Whether a check is required is asked only of the head commit: the base
6// branch's runs after a merge gate nothing.
7const suites = (required: string) => `checkSuites(first: 100) {
8 pageInfo { hasNextPage }
9 nodes {
10 status conclusion
11 workflowRun { runAttempt createdAt url workflow { databaseId name } }
12 checkRuns(first: 100) { nodes { name status conclusion detailsUrl ${required} } }
13 }
14 }`;
15
16// The quota every query reports, shared with every other gh call the user makes.
17const RATE = 'rateLimit { cost remaining limit resetAt }';
18
19// An open pull request reads its head commit's runs, a merged one its merge
20// commit's: each costs 1 point, both together 2. The latest comments and
21// reviews, and who reads them: someone else's are news.
22const pullQuery = (isMerged: boolean) => `query($o: String!, $r: String!, $n: Int!) {
23 ${RATE}
24 viewer { login }
25 repository(owner: $o, name: $r) { pullRequest(number: $n) {
26 number title url state isDraft mergeStateStatus reviewDecision baseRefName mergedAt
27 ${
28 isMerged
29 ? `mergeCommit { ${suites('')} }`
30 : `commits(last: 1) { nodes { commit { ${suites('isRequired(pullRequestNumber: $n)')} } } }`
31 }
32 comments(last: 10) { nodes { author { login __typename } createdAt url } }
33 reviews(last: 10) { nodes { author { login __typename } submittedAt state url } }
34 } }
35}`;
36
37const PUSH_QUERY = `query($o: String!, $r: String!, $b: String!) {
38 ${RATE}
39 repository(owner: $o, name: $r) { nameWithOwner parent { nameWithOwner } ref(qualifiedName: $b) {
40 target { ... on Commit { ${suites('')} } }
41 associatedPullRequests(states: OPEN, first: 100) {
42 nodes { number url repository { nameWithOwner } headRepository { nameWithOwner } }
43 }
44 } }
45}`;
46
47function onHost(host: string): string[] {
48 return host === 'github.com' ? [] : ['--hostname', host];
49}
50
51export function pullArgs(host: string, repo: string, number: number, isMerged = false): string[] {
52 const [owner = '', name = ''] = repo.split('/');
53 return [
54 'gh',
55 'api',
56 'graphql',
57 ...onHost(host),
58 '-f',
59 `query=${pullQuery(isMerged)}`,
60 '-f',
61 `o=${owner}`,
62 '-f',
63 `r=${name}`,
64 '-F',
65 `n=${number}`,
66 ];
67}
68
69// A pull request's URL, state and merge time, as gh resolves a number, URL or
70// branch.
71export function viewArgs(pull: string, repo: string | null): string[] {
72 return [
73 'gh',
74 'pr',
75 'view',
76 pull,
77 ...(repo === null ? [] : ['--repo', repo]),
78 '--json',
79 'url,state,mergedAt',
80 ];
81}
82
83export function pushArgs(host: string, repo: string, branch: string): string[] {
84 const [owner = '', name = ''] = repo.split('/');
85 return [
86 'gh',
87 'api',
88 'graphql',
89 ...onHost(host),
90 '-f',
91 `query=${PUSH_QUERY}`,
92 '-f',
93 `o=${owner}`,
94 '-f',
95 `r=${name}`,
96 '-f',
97 `b=refs/heads/${branch}`,
98 ];
99}
100
101// Over three repositories' history, 10 runs predicted the next run as well as
102// 20 or better; a median of 20 lagged a CI slowdown by about 10 runs.
103const ESTIMATE_RUNS = 10;
104
105export function estimateArgs(host: string, repo: string, workflowId: number): string[] {
106 return [
107 'gh',
108 'api',
109 ...onHost(host),
110 `repos/${repo}/actions/workflows/${workflowId}/runs?status=success&per_page=${ESTIMATE_RUNS}`,
111 ];
112}
113
114function statusOf(status: unknown): RunStatus {
115 if (status === 'COMPLETED') return 'done';
116 if (status === 'IN_PROGRESS') return 'running';
117 return 'queued';
118}
119
120function str(value: unknown): string | null {
121 return typeof value === 'string' && value !== '' ? value : null;
122}
123
124function id(value: unknown): number | null {
125 return Number.isSafeInteger(value) && (value as number) > 0 ? (value as number) : null;
126}
127
128const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
129
130// Date.parse alone accepts almost any string, "1" included.
131function isTime(value: unknown): value is string {
132 return typeof value === 'string' && ISO_TIME.test(value) && Number.isFinite(Date.parse(value));
133}
134
135function list(value: unknown): any[] {
136 return Array.isArray(value) ? value : [];
137}
138
139function parse(stdout: string): unknown {
140 try {
141 return JSON.parse(stdout);
142 } catch {
143 throw new Error('gh printed something other than JSON');
144 }
145}
146
147function jobOf(j: any): Job | null {
148 const name = str(j?.name);
149 if (name === null) return null;
150 return {
151 name,
152 status: statusOf(j.status),
153 conclusion: str(j.conclusion),
154 url: str(j.detailsUrl) ?? '',
155 isRequired: j.isRequired === true,
156 };
157}
158
159function workflowOf(s: any): Workflow | null {
160 const run = s?.workflowRun;
161 const workflowId = id(run?.workflow?.databaseId);
162 const name = str(run?.workflow?.name);
163 if (workflowId === null || name === null || !isTime(run.createdAt)) return null;
164 const jobs: Job[] = [];
165 for (const node of list(s.checkRuns?.nodes)) {
166 const j = jobOf(node);
167 if (j) jobs.push(j);
168 }
169 return {
170 id: workflowId,
171 name,
172 status: statusOf(s.status),
173 conclusion: str(s.conclusion),
174 startedAt: run.createdAt,
175 // A re-run keeps the first attempt's creation time, so its clock is unknown.
176 isRerun: typeof run.runAttempt === 'number' && run.runAttempt > 1,
177 attempt: typeof run.runAttempt === 'number' ? run.runAttempt : 1,
178 url: str(run.url) ?? '',
179 jobs,
180 };
181}
182
183export type Checks = Pick<Pull, 'workflows' | 'isGated' | 'isRequiredPending' | 'isTruncated'>;
184
185// A commit's check suites. Those with no workflow run come from GitHub Apps,
186// not Actions, and can sit queued forever; they count only as required checks.
187function checksOf(suites: any): Checks {
188 // A commit keeps a suite for every run of a workflow; the newest stands for it.
189 const newest = new Map<number, Workflow>();
190 let isGated = false;
191 let isRequiredPending = false;
192 for (const s of list(suites?.nodes)) {
193 const required = list(s?.checkRuns?.nodes).filter((j) => j?.isRequired === true);
194 // A required check from a GitHub App gates the merge too.
195 if (required.length > 0) isGated = true;
196 const w = workflowOf(s);
197 if (!w) {
198 if (required.some((j) => j.status !== 'COMPLETED')) isRequiredPending = true;
199 continue;
200 }
201 const seen = newest.get(w.id);
202 if (!seen || Date.parse(w.startedAt) >= Date.parse(seen.startedAt)) newest.set(w.id, w);
203 }
204 return {
205 workflows: [...newest.values()],
206 isGated,
207 isRequiredPending,
208 isTruncated: suites?.pageInfo?.hasNextPage === true,
209 };
210}
211
212// Nothing on the base branch is required, so a merge keeps only its runs.
213function runsOf({ workflows, isTruncated }: Checks): Pull['mergeRuns'] {
214 return { workflows, isTruncated };
215}
216
217const isBot = (author: any) => author?.__typename === 'Bot';
218
219const REVIEWED: Record<string, string> = {
220 APPROVED: 'approved',
221 CHANGES_REQUESTED: 'requested changes on',
222 COMMENTED: 'reviewed',
223 DISMISSED: 'reviewed',
224};
225
226// Null without the viewer, whose own are not news, or without either list,
227// which a reply carrying errors may leave out.
228function activityOf(pr: any, viewer: string | null): Activity[] | null {
229 if (viewer === null || !Array.isArray(pr.comments?.nodes) || !Array.isArray(pr.reviews?.nodes)) {
230 return null;
231 }
232 const found: Activity[] = [];
233 for (const c of list(pr.comments?.nodes)) {
234 const author = str(c?.author?.login);
235 if (author === null || author === viewer || !isTime(c.createdAt)) continue;
236 const url = str(c.url) ?? '';
237 const bot = isBot(c.author);
238 found.push({ author, at: c.createdAt, url, did: 'commented on', isBot: bot, isReview: false });
239 }
240 for (const r of list(pr.reviews?.nodes)) {
241 const author = str(r?.author?.login);
242 const did = REVIEWED[str(r?.state) ?? ''];
243 if (author === null || author === viewer || !did || !isTime(r.submittedAt)) continue;
244 const url = str(r.url) ?? '';
245 found.push({ author, at: r.submittedAt, url, did, isBot: isBot(r.author), isReview: true });
246 }
247 return found.toSorted((a, b) => Date.parse(a.at) - Date.parse(b.at));
248}
249
250// The newest comment or review in the window, the viewer's included: an older
251// one can only come into view when a newer one is deleted.
252function windowNewest(pr: any): string | null {
253 let newest: string | null = null;
254 const times = [
255 ...list(pr.comments?.nodes).map((c) => c?.createdAt),
256 ...list(pr.reviews?.nodes).map((r) => r?.submittedAt),
257 ];
258 for (const t of times) {
259 if (isTime(t) && (newest === null || Date.parse(t) > Date.parse(newest))) newest = t;
260 }
261 return newest;
262}
263
264export function parsePull(stdout: string): Pull {
265 const body: any = parse(stdout);
266 const pr = body?.data?.repository?.pullRequest;
267 if (!pr) {
268 const problem = str(body?.errors?.[0]?.message);
269 throw new Error(problem ?? 'pull request not found');
270 }
271 const number = id(pr.number);
272 if (number === null) throw new Error('pull request has no number');
273 return {
274 number,
275 title: str(pr.title) ?? '',
276 url: str(pr.url) ?? '',
277 state: pr.state === 'MERGED' || pr.state === 'CLOSED' ? pr.state : 'OPEN',
278 isDraft: pr.isDraft === true,
279 merge: str(pr.mergeStateStatus) ?? 'UNKNOWN',
280 review: str(pr.reviewDecision),
281 base: str(pr.baseRefName) ?? 'base',
282 mergedAt: isTime(pr.mergedAt) ? pr.mergedAt : null,
283 ...checksOf(pr.commits?.nodes?.[0]?.commit?.checkSuites),
284 mergeRuns: pr.mergeCommit ? runsOf(checksOf(pr.mergeCommit.checkSuites)) : null,
285 activity: activityOf(pr, str(body?.data?.viewer?.login)),
286 activityAt: windowNewest(pr),
287 };
288}
289
290export type Quota = { remaining: number; limit: number; resetAt: string };
291
292// The GraphQL quota a query reported; null when its answer does not say.
293export function parseQuota(stdout: string): Quota | null {
294 let body: any;
295 try {
296 body = JSON.parse(stdout);
297 } catch {
298 return null;
299 }
300 const rate = body?.data?.rateLimit;
301 const { remaining, limit, resetAt } = rate ?? {};
302 // The reset time tells one window from the next, so an answer without one is no use.
303 if (typeof remaining !== 'number' || typeof limit !== 'number' || limit <= 0) return null;
304 return isTime(resetAt) ? { remaining, limit, resetAt } : null;
305}
306
307export type Pushed =
308 | { kind: 'runs'; runs: Pull['mergeRuns'] }
309 // The PR's own repository: a fork's branch heads a PR upstream.
310 | { kind: 'pr'; repo: string; number: number; url: string | null }
311 // No such branch: deleted since, or a forced tag read as one.
312 | { kind: 'gone' };
313
314// A pushed branch's tip runs, or the open pull request it heads.
315export function parsePush(stdout: string): Pushed {
316 const body: any = parse(stdout);
317 const problem = str(body?.errors?.[0]?.message);
318 if (problem) throw new Error(problem);
319 const repository = body?.data?.repository;
320 if (!repository) throw new Error('repository not found');
321 const ref = repository.ref;
322 if (!ref) return { kind: 'gone' };
323 // Strangers' forks open PRs from a popular branch too; only one from this
324 // repository into itself or its parent is this push's.
325 const self = str(repository.nameWithOwner)?.toLowerCase();
326 const parent = str(repository.parent?.nameWithOwner)?.toLowerCase();
327 const open = list(ref.associatedPullRequests?.nodes).find((n) => {
328 const base = str(n?.repository?.nameWithOwner)?.toLowerCase();
329 const head = str(n?.headRepository?.nameWithOwner)?.toLowerCase();
330 return self !== undefined && head === self && (base === self || base === parent);
331 });
332 const number = id(open?.number);
333 const repo = str(open?.repository?.nameWithOwner);
334 if (number !== null && repo !== null) return { kind: 'pr', repo, number, url: str(open.url) };
335 return { kind: 'runs', runs: runsOf(checksOf(ref.target?.checkSuites)) };
336}
337
338// The median length of the recent successful first attempts: one run off a
339// release branch or a cached re-run (7 s, measured) does not set it. 0 when the
340// workflow has none, null when gh's answer does not say.
341export function parseEstimate(stdout: string): number | null {
342 let body: any;
343 try {
344 body = JSON.parse(stdout);
345 } catch {
346 return null;
347 }
348 const runs = body?.workflow_runs;
349 if (!Array.isArray(runs)) return null;
350 const firsts = runs.filter((run) => run && (run.run_attempt ?? 1) === 1);
351 if (firsts.length === 0) return 0;
352 const lengths = firsts
353 .filter((run) => isTime(run.run_started_at) && isTime(run.updated_at))
354 .map((run) => Date.parse(run.updated_at) - Date.parse(run.run_started_at))
355 .filter((ms) => ms > 0)
356 .toSorted((a, b) => a - b);
357 if (lengths.length === 0) return null;
358 const mid = Math.floor(lengths.length / 2);
359 return lengths.length % 2 === 1 ? lengths[mid]! : (lengths[mid - 1]! + lengths[mid]!) / 2;
360}
361hooks/watch.ts 773 lines1// What the band says about a pull request, and when to look again. Pure.
2import type { Activity, Pull, Watch, Workflow } from '../types';
3
4const LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?';
5const PR_URL = new RegExp(
6 `^https://(${LABEL}(?:\\.${LABEL})*)/([\\w-][\\w.-]*/[\\w-][\\w.-]*)/pull/([1-9]\\d{0,9})\\s*$`,
7 'gm',
8);
9// The start of a command: the line's, or after a shell separator, with any
10// leading VAR=value assignments.
11const START = String.raw`(?:^|[;&|(\n])\s*(?:\w+=\S*\s+)*`;
12const PR_CREATE = new RegExp(String.raw`${START}gh\s+pr\s+create\b`);
13// git's own options may come before the subcommand: -C dir, -c key=value, --flag.
14const MOVES_PR = new RegExp(
15 String.raw`${START}(?:git(?:\s+(?:-[Cc]\s+\S+|--\S+))*\s+push\b|gh\s+pr\s+merge\b|gh\s+run\s+rerun\b|gh\s+workflow\s+run\b)`,
16);
17
18const GIT_PUSH = new RegExp(String.raw`${START}git(?:\s+(?:-[Cc]\s+\S+|--\S+))*\s+push\b`);
19// git's "To <remote>" line: scp, https:// or ssh:// form.
20const PUSH_TO =
21 /^To\s+(?:\w+:\/\/)?(?:[^@\s/]+@)?([A-Za-z0-9.-]+)(?::\d+)?[:/]([\w-][\w.-]*\/[\w-][\w.-]*?)(?:\.git)?\/?\s*$/;
22// A ref the push moved: "a..b src -> dst", "+ a...b src -> dst (forced
23// update)", "* [new branch] src -> dst". Deleted, rejected, up-to-date and
24// new-tag lines name none.
25const PUSH_REF =
26 /^\s*[+*]?\s*(?:[0-9a-f]{4,}\.{2,3}[0-9a-f]{4,}|\[new branch\])\s+\S+\s+->\s+(\S+)/;
27// The same with --porcelain: "<flag>\t<src>:<dst>\t<summary>".
28const PORCELAIN_REF = /^[ +*]\t\S*:(\S+)\t/;
29
30export type PushTarget = Pick<Target, 'host' | 'repo' | 'url'> & { branch: string };
31
32// The branches a `git push` moved, read from its output, which reaches the
33// hook in stdout with stderr merged in. A forced tag reads like a branch
34// here, and a --dry-run like a push; the read drops a branch that is not
35// there, and an existing one shows its tip's runs.
36export function pushedBranches(command: string, stdout: string): PushTarget[] {
37 if (!GIT_PUSH.test(command)) return [];
38 const found: PushTarget[] = [];
39 let to: { host: string; repo: string } | null = null;
40 for (const line of stdout.split('\n')) {
41 if (/^To\s/.test(line)) {
42 // A remote that is not on a host, such as a local path, names no repo.
43 const remote = PUSH_TO.exec(line);
44 to = remote ? { host: remote[1]!.toLowerCase(), repo: remote[2]! } : null;
45 continue;
46 }
47 const dst = (PUSH_REF.exec(line) ?? PORCELAIN_REF.exec(line))?.[1];
48 if (!dst || !to || (dst.startsWith('refs/') && !dst.startsWith('refs/heads/'))) continue;
49 const branch = dst.replace(/^refs\/heads\//, '');
50 const url = `https://${to.host}/${to.repo}/tree/${branch}`;
51 if (!found.some((p) => p.host === to!.host && p.repo === to!.repo && p.branch === branch)) {
52 found.push({ ...to, url, branch });
53 }
54 }
55 return found;
56}
57
58// How toasts and errors name a watch.
59export function labelOf(w: Pick<Watch, 'number' | 'push'>): string {
60 return w.push ? `push ${w.push.branch}` : `#${w.number}`;
61}
62
63// A push whose branch could not be read once in the grace, such as one on a
64// host gh does not know, leaves rather than staying an error.
65export function isUnreadable(w: Watch, now: number): boolean {
66 return w.push !== undefined && w.pull === undefined && now - w.push.pushedAt >= MERGE_GRACE_MS;
67}
68
69// Whether a line leaves with time alone: a merge or push that started no runs
70// in its grace, a passed one past its stay, or a push never read.
71export function isGone(w: Watch, estimates: Record<string, number>, now: number): boolean {
72 if (isUnreadable(w, now)) return true;
73 if (!w.pull) return false;
74 const verdict = verdictOf(w.pull, estimates, w.host, w.checkedAt);
75 return verdict.kind === 'closed' || isCleared(verdict, w.pull, w.checkedAt, now);
76}
77
78// A push follows its branch's runs as a merge follows its merge commit's.
79export function pushedPull(branch: string, pushedAt: number, runs: Pull['mergeRuns']): Pull {
80 return {
81 number: 0,
82 title: '',
83 url: '',
84 state: 'MERGED',
85 isDraft: false,
86 merge: 'UNKNOWN',
87 review: null,
88 workflows: [],
89 isGated: false,
90 isRequiredPending: false,
91 isTruncated: false,
92 base: branch,
93 mergedAt: new Date(pushedAt).toISOString(),
94 mergeRuns: runs,
95 activity: [],
96 activityAt: null,
97 };
98}
99
100// Whether a command can start new runs or close a pull request, so the
101// band should look again soon rather than at its next slow poll.
102export function movesPulls(command: string): boolean {
103 return MOVES_PR.test(command);
104}
105const FAILED = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'STARTUP_FAILURE', 'ACTION_REQUIRED']);
106const PASSED = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
107const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
108
109export type Target = Pick<Watch, 'host' | 'repo' | 'number' | 'url'>;
110
111// The pull request a `gh pr create` printed: the URL on its last line of its own.
112export function createdPull(command: string, stdout: string): Target | null {
113 if (!PR_CREATE.test(command)) return null;
114 return pullAt(stdout);
115}
116
117// The pull request `gh pr view --json url,…` names, with the rest of its answer.
118export function viewedPull(stdout: string): { target: Target; body: any } | null {
119 let body: any;
120 try {
121 body = JSON.parse(stdout);
122 } catch {
123 return null;
124 }
125 const target = typeof body?.url === 'string' ? pullAt(body.url) : null;
126 return target === null ? null : { target, body };
127}
128
129// How far GitHub's clock may run behind this machine's.
130const MERGE_SKEW_MS = 2 * 60_000;
131
132// 'stale' unless open (an --auto merge waits) or merged after the command
133// started: a merge from before is not the one it ran, but a mention of it.
134export function mergedPullOf(stdout: string, startedAt: number): Target | 'stale' | null {
135 const viewed = viewedPull(stdout);
136 if (viewed === null) return null;
137 const { target, body } = viewed;
138 if (body.state === 'OPEN') return target;
139 const at = typeof body.mergedAt === 'string' ? Date.parse(body.mergedAt) : Number.NaN;
140 return body.state === 'MERGED' && at >= startedAt - MERGE_SKEW_MS ? target : 'stale';
141}
142
143// The pull request a URL names: one line holding only the URL.
144export function pullAt(text: string): Target | null {
145 const m = [...text.matchAll(PR_URL)].at(-1);
146 if (!m) return null;
147 return { host: m[1]!.toLowerCase(), repo: m[2]!, number: Number(m[3]), url: m[0].trim() };
148}
149
150// gh pr merge's options that take a value, long and short.
151const MERGE_VALUED = new Set([
152 '--body',
153 '--body-file',
154 '--subject',
155 '--author-email',
156 '--match-head-commit',
157 '--repo',
158]);
159const MERGE_VALUED_SHORT = new Set(['b', 'F', 't', 'A', 'R']);
160// A command before the merge that moves it to another repository, where gh
161// resolves the pull request it names; a checkout does not change that.
162const MOVES_REPO = new RegExp(String.raw`${START}(?:cd|pushd|popd)\b`);
163
164// The words of the command `text` starts, quotes removed, up to the first
165// separator or comment outside them: enough for gh's arguments, not a shell
166// parser.
167function wordsOf(text: string): string[] {
168 const words: string[] = [];
169 const unbroken = text.replaceAll('\\\n', ' ');
170 for (const m of unbroken.matchAll(/'([^']*)'|"((?:[^"\\]|\\.)*)"|([;&|\n#])|([^\s;&|'"]+)/g)) {
171 if (m[3] !== undefined) break;
172 words.push(m[1] ?? m[2]?.replaceAll(/\\(.)/g, '$1') ?? m[4]!);
173 }
174 return words;
175}
176
177export type Merging = { pull: string; repo: string | null };
178
179// Null for a bare merge: after --delete-branch, or on a fork's branch, nothing
180// names its pull request. Null too where pr-watch cannot follow the merge:
181// after a cd in the same line, or on another GH_HOST.
182export function mergingPull(command: string): Merging | null {
183 const at = new RegExp(String.raw`${START}gh\s+pr\s+merge\b`).exec(command);
184 if (!at || MOVES_REPO.test(command.slice(0, at.index)) || /\bGH_HOST=/.test(at[0])) {
185 return null;
186 }
187 const env = /\bGH_REPO=(\S+)/.exec(at[0])?.[1];
188 let repo = env === undefined ? null : (wordsOf(env)[0] ?? null);
189 let pull: string | null = null;
190 const words = wordsOf(command.slice(at.index + at[0].length));
191 for (let i = 0; i < words.length; i += 1) {
192 const word = words[i]!;
193 if (word === '--help' || word === '--disable-auto') return null;
194 // A redirection, with its target when that is the next word.
195 if (/^\d*(?:>>?|<)/.test(word)) {
196 if (/^\d*(?:>>?|<)$/.test(word)) i += 1;
197 continue;
198 }
199 let flag: string | null = null;
200 let value: string | undefined;
201 if (word.startsWith('--')) {
202 const [name, inline] = word.split(/=(.*)/s, 2);
203 if (MERGE_VALUED.has(name!)) [flag, value] = [name!, inline];
204 } else if (/^-[A-Za-z]/.test(word)) {
205 // Short options group, and a valued one takes the rest of the word:
206 // -dR o/r, -Ro/r, -R=o/r.
207 for (let j = 1; j < word.length; j += 1) {
208 const letter = word[j]!;
209 if (letter === 'h') return null;
210 if (MERGE_VALUED_SHORT.has(letter)) {
211 flag = `-${letter}`;
212 value = word.slice(j + 1).replace(/^=/, '') || undefined;
213 break;
214 }
215 }
216 }
217 if (flag !== null) {
218 value ??= words[(i += 1)];
219 if (flag === '-R' || flag === '--repo') repo = value ?? null;
220 continue;
221 }
222 if (!word.startsWith('-') && pull === null) pull = word;
223 }
224 return pull === null ? null : { pull, repo };
225}
226
227// The 5,000 points an hour are shared with every gh call the user and Claude
228// make, so pr-watch backs off before they run out.
229export const LOW_QUOTA_MS = 60_000;
230const LOW_QUOTA_SHARE = 0.1;
231
232export function isQuotaLow(q: { remaining: number; limit: number } | undefined): boolean {
233 return q !== undefined && q.remaining < q.limit * LOW_QUOTA_SHARE;
234}
235
236// GitHub's words for a primary or secondary limit, and the status a secondary
237// one may answer with; its docs name no exact wording.
238const LIMITED = /rate limit (?:already )?exceeded|secondary rate limit|\bHTTP 429\b/i;
239
240// gh prints a GraphQL answer's body on stdout and its first message on stderr.
241export function isRateLimited(stdout: string, stderr: string): boolean {
242 if (LIMITED.test(stderr)) return true;
243 try {
244 const errors: unknown = JSON.parse(stdout)?.errors;
245 return (
246 Array.isArray(errors) &&
247 errors.some((e) => e?.type === 'RATE_LIMITED' || LIMITED.test(String(e?.message)))
248 );
249 } catch {
250 return false;
251 }
252}
253
254// GitHub asks for at least a minute's wait on a secondary limit, longer each time.
255const PAUSE_FIRST_MS = 60_000;
256const PAUSE_MAX_MS = 15 * 60_000;
257
258export type Pause = { until: number; backoff: number };
259
260// A spent quota waits for its reset; any other limit, a secondary one, waits
261// longer each time it is hit again.
262export function pauseOf(
263 q: { remaining: number; limit: number; resetAt: string } | undefined,
264 last: Pause | undefined,
265 now: number,
266): Pause {
267 const backoff = last ? Math.min(last.backoff * 2, PAUSE_MAX_MS) : PAUSE_FIRST_MS;
268 const reset = q && q.remaining <= 0 ? Date.parse(q.resetAt) : Number.NaN;
269 return { until: reset > now ? reset : now + backoff, backoff };
270}
271
272export function untilText(at: number): string {
273 const d = new Date(at);
274 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
275}
276
277// Reads that fail for any reason but a rate limit are retried less often each
278// time, and after GIVE_UP_AFTER in a row, some 15 minutes, the watch ends.
279export const GIVE_UP_AFTER = 8;
280const RETRY_MAX_MS = 5 * 60_000;
281
282export function retryDelayOf(failures: number): number {
283 return Math.min(10_000 * 2 ** Math.max(failures - 1, 0), RETRY_MAX_MS);
284}
285
286export function errorLine(text: string): string {
287 const line = text.trim().split('\n')[0]?.trim() ?? '';
288 return line.replace(/^gh: /, '') || 'no message';
289}
290
291export type Verdict =
292 | { kind: 'running'; gate: Workflow }
293 | { kind: 'failing'; workflowId: number; workflow: string; job: string; url: string }
294 | { kind: 'ready' }
295 | { kind: 'blocked'; reason: string }
296 | { kind: 'waiting'; reason: string }
297 | { kind: 'merged-running'; gate: Workflow }
298 | { kind: 'merged-failing'; workflowId: number; workflow: string; job: string; url: string }
299 | { kind: 'merged-passed' }
300 | { kind: 'merged-waiting' }
301 | { kind: 'closed' };
302
303// How long a merged pull request waits for its merge commit's runs to start.
304const MERGE_GRACE_MS = 90_000;
305
306// Merged inside the grace in which its runs may not have started.
307export function isMergeFresh(pull: Pick<Pull, 'mergedAt'>, now: number): boolean {
308 return pull.mergedAt !== null && now - Date.parse(pull.mergedAt) < MERGE_GRACE_MS;
309}
310
311const gates = (w: Workflow) => w.jobs.some((j) => j.isRequired);
312const failed = (conclusion: string | null) => FAILED.has(conclusion ?? '');
313
314type Runs = Pick<Pull, 'workflows' | 'isGated'>;
315
316// Whether the merge waits on this workflow: one holding a required check, or
317// any when nothing on the commit is required.
318function counts(runs: Runs): (w: Workflow) => boolean {
319 return (w) => !runs.isGated || gates(w);
320}
321
322// The job that failed, not the summary job that failed on it.
323function failure(runs: Runs): Verdict | null {
324 const isCounted = counts(runs);
325 for (const w of runs.workflows) {
326 if (!isCounted(w)) continue;
327 const bad = w.jobs.filter((j) => j.status === 'done' && failed(j.conclusion));
328 const job = bad.find((j) => !j.isRequired) ?? bad[0];
329 if (job) {
330 const url = job.url || w.url;
331 return { kind: 'failing', workflowId: w.id, workflow: w.name, job: job.name, url };
332 }
333 }
334 return null;
335}
336
337// The running workflow the merge waits on longest.
338function gateOf(running: Workflow[], estimates: Record<string, number>, host: string): Workflow {
339 let longest = running[0]!;
340 for (const w of running) {
341 if (
342 (estimates[estimateKey(host, w.id)] ?? 0) > (estimates[estimateKey(host, longest.id)] ?? 0)
343 ) {
344 longest = w;
345 }
346 }
347 return longest;
348}
349
350export function estimateKey(host: string, workflowId: number): string {
351 return `${host}/${workflowId}`;
352}
353
354// Null once every counted workflow has passed.
355function runsVerdict(runs: Runs, estimates: Record<string, number>, host: string): Verdict | null {
356 const fail = failure(runs);
357 if (fail) return fail;
358 const isCounted = counts(runs);
359 const running = runs.workflows.filter((w) => w.status !== 'done' && isCounted(w));
360 if (running.length > 0) return { kind: 'running', gate: gateOf(running, estimates, host) };
361 return null;
362}
363
364// A merged pull request follows its merge commit's runs on the base branch,
365// where nothing is required, so every workflow counts.
366function mergedVerdict(
367 pull: Pull,
368 estimates: Record<string, number>,
369 host: string,
370 now: number,
371): Verdict {
372 const merged = pull.mergeRuns;
373 if (!merged || merged.workflows.length === 0) {
374 const since = pull.mergedAt ? now - Date.parse(pull.mergedAt) : Infinity;
375 return since < MERGE_GRACE_MS ? { kind: 'merged-waiting' } : { kind: 'closed' };
376 }
377 const runs = runsVerdict({ workflows: merged.workflows, isGated: false }, estimates, host);
378 if (runs?.kind === 'failing') return { ...runs, kind: 'merged-failing' };
379 if (runs?.kind === 'running') return { ...runs, kind: 'merged-running' };
380 return { kind: 'merged-passed' };
381}
382
383export function verdictOf(
384 pull: Pull,
385 estimates: Record<string, number>,
386 host: string,
387 now: number,
388): Verdict {
389 if (pull.state === 'MERGED') return mergedVerdict(pull, estimates, host, now);
390 if (pull.state !== 'OPEN') return { kind: 'closed' };
391 const runs = runsVerdict(pull, estimates, host);
392 if (runs) return runs;
393 if (pull.isDraft) return { kind: 'waiting', reason: 'draft' };
394 if (pull.merge === 'DIRTY') return { kind: 'blocked', reason: 'conflicts' };
395 if (pull.review === 'CHANGES_REQUESTED') return { kind: 'blocked', reason: 'changes requested' };
396 if (pull.merge === 'BEHIND') return { kind: 'blocked', reason: 'behind base' };
397 if (['CLEAN', 'HAS_HOOKS', 'UNSTABLE'].includes(pull.merge)) return { kind: 'ready' };
398 if (pull.merge === 'BLOCKED') {
399 if (pull.review === 'REVIEW_REQUIRED') return { kind: 'waiting', reason: 'review' };
400 // A required check outside any workflow, a GitHub App's, still running.
401 if (pull.isRequiredPending) return { kind: 'waiting', reason: 'checks' };
402 // Required checks GitHub has not yet started leave the merge blocked.
403 if (pull.workflows.length === 0) return { kind: 'waiting', reason: 'checks to start' };
404 return { kind: 'blocked', reason: 'blocked' };
405 }
406 // GitHub computes the merge state lazily and reports UNKNOWN until it has.
407 return { kind: 'waiting', reason: 'checking' };
408}
409
410const PASSED_STAYS_MS = 5000;
411
412// Every run on the merge commit passed, and the grace for a late one is over.
413// A failed merge never settles: it is read until a re-run passes.
414export function isSettled(verdict: Verdict, pull: Pull, now: number): boolean {
415 if (verdict.kind !== 'merged-passed') return false;
416 const isDone = pull.mergeRuns?.workflows.every((w) => w.status === 'done') ?? true;
417 const since = pull.mergedAt ? now - Date.parse(pull.mergedAt) : Infinity;
418 return isDone && since >= MERGE_GRACE_MS;
419}
420
421export function isCleared(verdict: Verdict, pull: Pull, checkedAt: number, now: number): boolean {
422 return isSettled(verdict, pull, checkedAt) && now - checkedAt >= PASSED_STAYS_MS;
423}
424
425// A line that has waited on a person this long is read every STILL_MS.
426const STILL_AFTER_MS = 30 * 60_000;
427const STILL_MS = 5 * 60_000;
428
429// Poll fast while something moves, slowly while it waits on a person, and
430// slower once the line has said the same for half an hour. A workflow the
431// merge does not wait on still moves the line's marks.
432export function delayOf(verdict: Verdict, pull: Pull, now: number, shownAt = now): number | null {
433 if (verdict.kind === 'closed' || isSettled(verdict, pull, now)) return null;
434 const slow = now - shownAt >= STILL_AFTER_MS ? STILL_MS : 60_000;
435 // A failed merge waits on someone to re-run it.
436 if (verdict.kind === 'merged-failing') {
437 return pull.mergeRuns?.workflows.every((w) => w.status === 'done') === false ? 10_000 : slow;
438 }
439 if (verdict.kind === 'running' || verdict.kind.startsWith('merged-')) return 10_000;
440 if (pull.workflows.some((w) => w.status !== 'done')) return 10_000;
441 if (verdict.kind === 'waiting' && verdict.reason !== 'review' && verdict.reason !== 'draft') {
442 return 10_000;
443 }
444 return slow;
445}
446
447// What the line says, as a key: a new failure differs from an old one.
448export function shownOf(verdict: Verdict): string {
449 if (verdict.kind === 'failing' || verdict.kind === 'merged-failing') {
450 return `${verdict.kind}:${JSON.stringify([verdict.workflow, verdict.job])}`;
451 }
452 return verdict.kind;
453}
454
455export function toastOf(
456 label: string,
457 verdict: Verdict,
458 shown?: string,
459 after = ' merged',
460): string | null {
461 if (shownOf(verdict) === shown) return null;
462 if (verdict.kind === 'ready') return `${label} is ready to merge`;
463 if (verdict.kind === 'failing') return `${label} ${verdict.workflow}: ${verdict.job} failed`;
464 if (verdict.kind === 'merged-passed') return `${label}${after}: its checks passed`;
465 if (verdict.kind === 'merged-failing') {
466 return `${label}${after}: ${verdict.workflow}: ${verdict.job} failed`;
467 }
468 return null;
469}
470
471// Something Claude is told of once, for as long as it lasts.
472type Condition = { key: string; text: string };
473
474type Who = Pick<Watch, 'repo' | 'number' | 'push'>;
475
476// A watch's own conditions carry it, so watches on the host can share what
477// they told; a push's carries the push, its number being 0.
478const keyOf = (watch: Who, what: string) =>
479 JSON.stringify([
480 watch.repo,
481 watch.push ? [watch.push.branch, watch.push.pushedAt] : watch.number,
482 what,
483 ]);
484
485// A ready or passed state, conflicts and requested changes whatever the checks
486// say, and every failing run the line follows, gating or not, as soon as a job
487// in it fails. A run is told once, by its first failed jobs: later ones, a
488// summary job among them, are news Claude finds in the run it was sent to.
489function conditionsOf(
490 watch: Who,
491 pull: Pull,
492 verdict: Verdict,
493 name: string,
494 isEarly: boolean,
495): Condition[] {
496 const found: Condition[] = [];
497 if (verdict.kind === 'ready') {
498 found.push({ key: keyOf(watch, 'ready'), text: `GitHub reports ${name} ready to merge.` });
499 }
500 if (verdict.kind === 'merged-passed' && !isEarly) {
501 const text = watch.push
502 ? `The checks on ${name} passed.`
503 : `${name} merged into ${pull.base}, and the merge commit's checks passed.`;
504 found.push({ key: keyOf(watch, 'passed'), text });
505 }
506 if (pull.state === 'OPEN' && pull.merge === 'DIRTY') {
507 const text = `${name} has merge conflicts with ${pull.base}.`;
508 found.push({ key: keyOf(watch, 'conflicts'), text });
509 }
510 if (pull.state === 'OPEN' && pull.review === 'CHANGES_REQUESTED') {
511 const text = `A reviewer requested changes on ${name}.`;
512 found.push({ key: keyOf(watch, 'changes requested'), text });
513 }
514 const isMerged = verdict.kind.startsWith('merged-');
515 const workflows = isMerged ? (pull.mergeRuns?.workflows ?? []) : pull.workflows;
516 const where = isMerged && !watch.push ? ' on the merge commit' : '';
517 for (const w of workflows) {
518 const bad = w.jobs.filter((j) => j.status === 'done' && failed(j.conclusion));
519 if (bad.length === 0) continue;
520 const jobs = bad.map((j) => j.name).join(', ');
521 const log = bad[0]!.url || w.url;
522 const run = w.url && w.url !== log ? `; the run: ${w.url}` : '';
523 const text = `${w.name}: ${jobs} failed${where} for ${name}: ${log}${run}`;
524 found.push({ key: JSON.stringify([w.id, w.url, w.attempt]), text });
525 }
526 return found;
527}
528
529export type Memory = Pick<Watch, 'told' | 'heard'>;
530export type Wake = { text: string | null; told: string[]; heard: Watch['heard'] };
531
532// With waking off nothing new counts as told, so what lasts is told once it is
533// on again, while what ends is let go, so its return is news.
534export function toldAfter(now: string[], before: string[] | undefined, isAwake: boolean): string[] {
535 return isAwake ? now : now.filter((key) => (before ?? []).includes(key));
536}
537
538// GitHub reports UNKNOWN while it recomputes the merge state.
539export const isChecking = (verdict: Verdict) =>
540 verdict.kind === 'waiting' && verdict.reason === 'checking';
541
542// A comment's or review's identity; its time alone ties at the second.
543const heardKey = (a: Activity) => a.url || JSON.stringify([a.author, a.at, a.did]);
544
545// Enough to outlast an item leaving the 10-item window and coming back.
546const HEARD_KEPT = 100;
547
548// t3code stops a watch after 10 comment-only wakes in a row; this stops the
549// comments alone.
550export const QUIET_CAP = 10;
551
552// The comments and reviews not heard before, and what is heard after them. The
553// first read hears what is there without telling it; a read that cannot tell
554// whose they are hears nothing.
555function hearOf(
556 pull: Pull,
557 heard: Watch['heard'],
558 name: string,
559 tells: (a: Activity) => boolean,
560): { heard: Watch['heard']; lines: string[] } {
561 if (pull.activity === null) return { heard, lines: [] };
562 const keys = pull.activity.map(heardKey);
563 if (heard === undefined) return { heard: { since: pull.activityAt, keys }, lines: [] };
564 // An unheard item older than the first read's newest slid into the window
565 // when a newer one was deleted.
566 const since = heard.since === null ? -Infinity : Date.parse(heard.since);
567 const lines: string[] = [];
568 for (const a of pull.activity) {
569 if (heard.keys.includes(heardKey(a)) || Date.parse(a.at) < since || !tells(a)) continue;
570 lines.push(`@${a.author} ${a.did} ${name}: ${a.url}`);
571 }
572 const kept = [...new Set([...heard.keys, ...keys])].slice(-HEARD_KEPT);
573 return { heard: { since: heard.since, keys: kept }, lines };
574}
575
576const WAKE_SETTINGS = ['off', 'checks', 'checks and comments'] as const;
577const BOT_SETTINGS = ['never', 'reviews', 'comments and reviews'] as const;
578export type WakeSetting = (typeof WAKE_SETTINGS)[number];
579export type BotSetting = (typeof BOT_SETTINGS)[number];
580
581// The two /config settings as plugin.json declares them, each its default when unset.
582export function settingsOf(options: Record<string, unknown>): {
583 wake: WakeSetting;
584 bots: BotSetting;
585} {
586 const wake = WAKE_SETTINGS.find((s) => s === options.wake) ?? 'checks and comments';
587 const bots = BOT_SETTINGS.find((s) => s === options.botComments) ?? 'never';
588 return { wake, bots };
589}
590
591// Which comments and reviews are told, by the two /config settings. Those not
592// told are still heard, so a later change of setting reports no history.
593export function tellsOf(wake: WakeSetting, bots: BotSetting): (a: Activity) => boolean {
594 if (wake !== 'checks and comments') return () => false;
595 if (bots === 'comments and reviews') return () => true;
596 if (bots === 'reviews') return (a) => !a.isBot || a.isReview;
597 return (a) => !a.isBot;
598}
599
600const PEOPLE_ONLY = tellsOf('checks and comments', 'never');
601
602// What Claude is told unasked: each condition it has not been told of while it
603// lasts, and each comment or review it has not heard that `tells` lets through.
604// `isEarly` holds back a passed merge whose grace has not ended.
605export function wakeOf(
606 watch: Pick<Watch, 'repo' | 'number' | 'push' | 'url'>,
607 pull: Pull,
608 verdict: Verdict,
609 last: Memory,
610 { isEarly = false, tells = PEOPLE_ONLY } = {},
611): Wake {
612 const name = watch.push
613 ? `the push to ${watch.push.branch} on ${watch.repo}`
614 : `${watch.repo}#${watch.number}`;
615 const told = last.told ?? [];
616 const news: string[] = [];
617 const kept: string[] = [];
618 for (const c of conditionsOf(watch, pull, verdict, name, isEarly)) {
619 if (!told.includes(c.key)) news.push(c.text);
620 kept.push(c.key);
621 }
622 // GitHub reports UNKNOWN while it recomputes the merge state; ready stands only
623 // through a read that is otherwise ready, since a new run ends it.
624 if (pull.state === 'OPEN' && pull.merge === 'UNKNOWN') {
625 for (const what of isChecking(verdict) ? ['ready', 'conflicts'] : ['conflicts']) {
626 const key = keyOf(watch, what);
627 if (told.includes(key) && !kept.includes(key)) kept.push(key);
628 }
629 }
630 const { heard: next, lines } = hearOf(pull, last.heard, name, tells);
631 // So a chatty bot or thread cannot keep waking Claude, comments and reviews
632 // stop after QUIET_CAP wakes in a row of nothing else, until other news comes.
633 const before = last.heard?.streak ?? 0;
634 const streak = news.length > 0 ? 0 : lines.length > 0 ? before + 1 : before;
635 if (streak <= QUIET_CAP) news.push(...lines);
636 if (lines.length > 0 && streak === QUIET_CAP) {
637 news.push(
638 `pr-watch will tell you of no more comments or reviews on ${name} until other news comes.`,
639 );
640 }
641 const heard = next && { since: next.since, keys: next.keys, ...(streak > 0 && { streak }) };
642 if (news.length === 0) return { text: null, told: kept, heard };
643 const text = [
644 `pr-watch: ${news.join(' ')}`,
645 `This is news from pr-watch, not a request to merge. ${watch.url}`,
646 ].join('\n');
647 return { text, told: kept, heard };
648}
649
650export function clockText(ms: number): string {
651 const s = Math.max(0, Math.round(ms / 1000));
652 return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`;
653}
654
655// A bar `width` cells wide, filled to `fraction` in eighths of a cell.
656export function barOf(fraction: number, width: number): { filled: string; rest: string } {
657 const eighths = Math.round(Math.min(Math.max(fraction, 0), 1) * width * 8);
658 const full = Math.floor(eighths / 8);
659 const head = EIGHTHS[eighths % 8] ?? '';
660 const filled = '█'.repeat(full) + head;
661 return { filled, rest: '░'.repeat(width - full - (head ? 1 : 0)) };
662}
663
664export function markOf(w: Workflow): string {
665 if (w.status !== 'done') return '●';
666 return PASSED.has(w.conclusion ?? '') ? '✓' : '✗';
667}
668
669export type Segment = { text: string; color?: string; isDim?: boolean; url?: string };
670
671const BAR_MAX = 24;
672const BAR_MIN = 8;
673
674function markSegment(w: Workflow): Segment {
675 const mark = markOf(w);
676 return {
677 text: ` · ${w.name} ${mark}`,
678 color: mark === '✗' ? 'red' : mark === '●' ? 'blue' : undefined,
679 isDim: mark === '✓',
680 };
681}
682
683// One line of the band. A running gate whose length is known draws a bar that
684// stops short of full until the run ends.
685export function lineOf(
686 watch: Watch,
687 now: number,
688 estimates: Record<string, number>,
689 columns: number,
690): Segment[] {
691 const isPush = watch.push !== undefined;
692 const title = isPush ? `⟳ ${labelOf(watch)}` : labelOf(watch);
693 const label = { text: title, color: 'cyan', url: watch.url };
694 const pull = watch.pull;
695 // A failure and a pause are separate facts, so a line says both.
696 const pause =
697 watch.limitedUntil === undefined
698 ? undefined
699 : `rate limited until ${untilText(watch.limitedUntil)}`;
700 const problem = [watch.error, pause].filter(Boolean).join(' · ') || undefined;
701 if (!pull) {
702 return [
703 label,
704 {
705 text: ` ${problem ?? 'loading…'}`,
706 isDim: problem === undefined,
707 color: problem === undefined ? undefined : 'red',
708 },
709 ];
710 }
711 // The state is the last good read's; `now` only times the running clock.
712 const v = verdictOf(pull, estimates, watch.host, watch.checkedAt);
713 if (v.kind === 'closed') return [label, { text: ` ${pull.state.toLowerCase()}`, isDim: true }];
714 // After a merge the line follows the merge commit's runs on the base branch.
715 const isMerged = v.kind.startsWith('merged-');
716 const none = { workflows: [], isTruncated: false };
717 const runs = isMerged ? (pull.mergeRuns ?? none) : pull;
718 // The branch is named up front, so a running line is not read as the PR's own CI.
719 const after = { text: isPush ? ' ·' : ` merged into ${pull.base} ·`, isDim: true };
720 const lead: Segment[] = isMerged ? [label, after] : [label];
721 const tail: Segment[] = [];
722 if (runs.isTruncated) tail.push({ text: ' · more checks not shown', isDim: true });
723 if (problem !== undefined) tail.push({ text: ` · ${problem}`, color: 'red' });
724 const isRunning = v.kind === 'running' || v.kind === 'merged-running';
725 // Other workflows, while they run or once they failed; on a running line,
726 // every other workflow.
727 const others = (skip: number | null) =>
728 runs.workflows
729 .filter((w) => w.id !== skip && (isRunning || markOf(w) !== '✓'))
730 .map((w) => markSegment(w));
731 if (v.kind === 'ready') {
732 return [...lead, { text: ' ✓ ready to merge', color: 'green' }, ...others(null), ...tail];
733 }
734 if (v.kind === 'merged-passed') {
735 return [...lead, { text: ' ✓ checks passed', color: 'green' }, ...tail];
736 }
737 if (v.kind === 'merged-waiting') {
738 return [...lead, { text: ' ○ waiting on checks', isDim: true }, ...tail];
739 }
740 if (v.kind === 'failing' || v.kind === 'merged-failing') {
741 const text = ` ✗ ${v.workflow}: ${v.job} failed`;
742 const reason = { text, color: 'red', url: v.url || undefined };
743 return [...lead, reason, ...others(v.workflowId), ...tail];
744 }
745 if (v.kind === 'blocked') {
746 return [...lead, { text: ` ⚠ ${v.reason}`, color: 'yellow' }, ...others(null), ...tail];
747 }
748 if (v.kind === 'waiting') {
749 return [...lead, { text: ` ○ waiting on ${v.reason}`, isDim: true }, ...others(null), ...tail];
750 }
751 const gate = v.gate;
752 const elapsed = now - Date.parse(gate.startedAt);
753 const estimate = estimates[estimateKey(watch.host, gate.id)] ?? 0;
754 const name = { text: ` ● ${gate.name} `, color: 'blue', url: gate.url || undefined };
755 const head = [...lead, name];
756 if (gate.isRerun) return [...head, { text: 're-run', isDim: true }, ...others(gate.id), ...tail];
757 if (estimate <= 0) {
758 return [...head, { text: clockText(elapsed), isDim: true }, ...others(gate.id), ...tail];
759 }
760 const times = ` ${clockText(elapsed)} / ~${clockText(estimate)}`;
761 const used = head.reduce((n, s) => n + s.text.length, 0) + times.length;
762 const width = Math.max(BAR_MIN, Math.min(BAR_MAX, columns - used - 2));
763 const bar = barOf(Math.min(elapsed / estimate, 0.97), width);
764 return [
765 ...head,
766 { text: bar.filled, color: 'blue' },
767 { text: bar.rest, isDim: true },
768 { text: times, isDim: true },
769 ...others(gate.id),
770 ...tail,
771 ];
772}
773types/index.d.ts 100 lines1export type RunStatus = 'queued' | 'running' | 'done';
2
3export type Job = {
4 name: string;
5 status: RunStatus;
6 conclusion: string | null;
7 url: string;
8 isRequired: boolean;
9};
10
11export type Workflow = {
12 id: number;
13 name: string;
14 status: RunStatus;
15 conclusion: string | null;
16 // The run's creation, which a re-run keeps from its first attempt.
17 startedAt: string;
18 isRerun: boolean;
19 // The run's attempt, 1 for the first: a re-run keeps the run's URL.
20 attempt: number;
21 url: string;
22 jobs: Job[];
23};
24
25// A comment or review on a pull request by someone other than the viewer.
26export type Activity = {
27 author: string;
28 at: string;
29 url: string;
30 did: string;
31 isBot: boolean;
32 isReview: boolean;
33};
34
35export type Pull = {
36 number: number;
37 title: string;
38 url: string;
39 state: 'OPEN' | 'MERGED' | 'CLOSED';
40 isDraft: boolean;
41 merge: string;
42 review: string | null;
43 workflows: Workflow[];
44 // Some check on the head commit is required, from a workflow or an App.
45 isGated: boolean;
46 // A required check from a GitHub App has not finished.
47 isRequiredPending: boolean;
48 // The head commit has more check suites than one read returns.
49 isTruncated: boolean;
50 // The branch merged into, and once merged, when and the merge commit's runs.
51 base: string;
52 mergedAt: string | null;
53 mergeRuns: { workflows: Workflow[]; isTruncated: boolean } | null;
54 // The latest comments and reviews by others, oldest first; null when the
55 // reply lacks the viewer or either list.
56 activity: Activity[] | null;
57 // The newest comment or review in the window, the viewer's included.
58 activityAt: string | null;
59};
60
61// A pull request the band follows, as the last poll left it. A push Claude
62// made to a branch with no open pull request has `push` and number 0, and its
63// `pull` reads as a merge into that branch at the push.
64export type Watch = {
65 host: string;
66 repo: string;
67 number: number;
68 url: string;
69 push?: { branch: string; pushedAt: number };
70 pull?: Pull;
71 error?: string;
72 checkedAt: number;
73 // What the line last said, so a toast fires once per change.
74 shown?: string;
75 // When `shown` last changed, so a line long the same is read less often.
76 shownAt?: number;
77 // Reads in a row that failed, a rate limit aside.
78 failures?: number;
79 // When the rate limit pausing its host ends, until a read of its own.
80 limitedUntil?: number;
81 // The conditions Claude has been told of, so each is told once while it lasts.
82 told?: string[];
83 // The comments and reviews heard, and the newest time at the first read,
84 // which hears without telling: older ones are history.
85 // `streak` counts the reads in a row whose only news was comments and reviews.
86 heard?: { since: string | null; keys: string[]; streak?: number };
87};
88
89declare module 'claude-code' {
90 interface PluginState {
91 'pr-watch': {
92 watches: Watch[];
93 // The last successful run's length in ms, by `<host>/<workflow id>`; 0
94 // when the workflow has none.
95 estimates: Record<string, number>;
96 now: number;
97 };
98 }
99}
100