SLOPSHOPPER

pr-watch

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…

newbandguardtoasttoolprocess
v0.4.0MITupdated 2026-10-09oakoss/claude-plugins/plugins/pr-watch
A shopper browsing a rack in a slop shop
README

pr-watch

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

What it shows

  • Running. Of the running workflows that hold a required check (any running one when nothing on the commit is required), the longest gets a progress bar. Its length is the median of that workflow's last 10 successful runs, re-runs left out, learned again each time a run of it finishes, so the bar stops short of full until the run ends; a workflow with no successful run shows only the time so far, and a re-run shows re-run, since GitHub keeps its first attempt's start time. The other workflows follow as marks: ✓ passed, ✗ failed, ● running.
  • Ready to merge. GitHub reports the pull request mergeable: required checks pass, no review blocks it, and it has no conflicts.
  • Failing. A job failed in a workflow that holds a required check, or in any workflow when nothing on the commit is required. The line names that job rather than a summary job that failed on it, and links to its log.
  • Blocked. Conflicts, changes requested, a branch behind its base, or a rule GitHub does not name (⚠ blocked).
  • Waiting. On a review, on a required check from a GitHub App, on GitHub to start the checks, on GitHub to work out the merge state, or on a draft to be marked ready.
  • Merged. The line names the base branch, #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.
  • Pushed. A branch Claude pushes with 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.

Telling Claude

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:

  • GitHub reports a pull request ready to merge;
  • a run fails, in any workflow, as soon as its first job does, naming the jobs failed by then with links to the first job's log and to the run;
  • a pull request has merge conflicts or a reviewer requests changes, whatever its checks say;
  • a merge's or a push's checks pass, once its 90 seconds are up;
  • someone other than you comments on or reviews it.

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.

Settings

In /config:

SettingValuesDefault
Wake Claudeoff, checks, checks and commentschecks and comments
Bots wake Claudenever, reviews, comments and reviewsnever

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.

Tools for Claude

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.

How it works

  • It watches the pull request in the output of a 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.
  • A 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.
  • It reads each pull request, with its latest 10 comments and 10 reviews, in one 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.
  • Each read costs one point of GitHub's GraphQL quota, 5,000 an hour, which your own and Claude's 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.
  • When GitHub refuses a read for a rate limit, every watch on that host pauses and its line says 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.
  • It needs gh, logged in to the pull request's host. GitHub Enterprise hosts are passed to gh as --hostname.
  • Watched pull requests last for the session; a new session starts with none.

Install

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.

Source 4 files
hooks/register.tsx 754 lines
1import { 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};
754
hooks/github.ts 361 lines
1// 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}
361
hooks/watch.ts 773 lines
1// 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}
773
types/index.d.ts 100 lines
1export 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