// `pullfrog watch / --pr ` — subscribe to a PR's activity and // emit one structured JSON line per new review / comment / inline review thread // / PR state change / check-suite completion, WITHOUT polling GitHub. the server // fans these out from the webhook pipeline it already receives, so latency is // sub-second and there is no per-event GitHub API spend. // // transport is cursor-based long-poll: each request holds open on the server // until new events land (or a short window elapses), then the client // immediately re-requests with the returned cursor. // // two shapes, because agent harnesses differ. the default is a daemon whose // stdout Claude Code's Monitor can consume. `--once` blocks for a single server // window and exits, which is the only shape Codex and opencode can consume — // neither has an affordance that reads a stream, but every harness can call a // command that returns. see `pullfrog mcp` for the same primitive as a tool. import arg from "arg"; import pc from "picocolors"; import * as yes from "yes"; import { isTerminal, type PrTarget, pollPrEvents, resolveCursor, SERVER_POLL_WINDOW_MS, type StreamEvent, waitForPrEvents, writeCursor, } from "./_prEvents.ts"; import { bail, getGhToken, parseGitRemote } from "./_shared.ts"; function parseWatchArgs(args: string[]) { return arg( { "--pr": Number, "--pretty": Boolean, "--since": String, "--once": Boolean, "--help": Boolean, "-h": "--help", "-p": "--pretty", }, { argv: args } ); } function printUsage(prog: string, stream: typeof console.log): void { stream(`usage: ${prog} watch [/] --pr [options]\n`); stream("subscribe to a PR's activity and print one JSON line per event.\n"); stream("arguments:"); stream(" / target repo (defaults to the current git remote)"); stream(""); stream("options:"); stream(" --pr pull request number to watch (required)"); stream(" --once wait for one batch of events, print it, and exit"); stream(" --since resume from a cursor instead of the saved position"); stream(" -p, --pretty human-readable output instead of JSON lines"); stream(" -h, --help show help"); } function resolveRepo(positional: string | undefined): { owner: string; repo: string } { if (!positional) return parseGitRemote(); const match = positional.match(/^([^/\s]+)\/([^/\s]+)$/); if (!match) bail(`invalid repo "${positional}" — expected /`); return { owner: match[1], repo: match[2] }; } function formatPretty(event: StreamEvent): string { const time = new Date(event.createdAt).toLocaleTimeString(); const action = typeof event.data.action === "string" ? event.data.action : ""; const actor = typeof event.data.author === "string" ? event.data.author : typeof event.data.reviewer === "string" ? event.data.reviewer : ""; const detail = [action, actor && pc.dim(`by ${actor}`)].filter(Boolean).join(" "); return `${pc.dim(time)} ${pc.cyan(event.kind)} ${pc.bold(`#${event.pr}`)} ${detail}`.trimEnd(); } function emit(events: StreamEvent[], pretty: boolean): void { for (const event of events) { process.stdout.write(pretty ? `${formatPretty(event)}\n` : `${JSON.stringify(event)}\n`); } } export async function runCli(input: { args: string[]; prog: string; showHelp: boolean }) { let parsed: ReturnType; try { parsed = parseWatchArgs(input.args); } catch (error) { console.error(`${error instanceof Error ? error.message : String(error)}\n`); printUsage(input.prog, console.error); process.exit(1); } if (input.showHelp || parsed["--help"]) { printUsage(input.prog, console.log); process.exit(0); } const target = resolveRepo(parsed._[0]); const pr = parsed["--pr"]; if (pr === undefined || !Number.isInteger(pr) || pr <= 0) { bail("--pr is required"); } const token = getGhToken(); const pretty = parsed["--pretty"] === true; const watched = { ...target, pr }; let cursor: string; try { cursor = await resolveCursor({ ...watched, token, since: parsed["--since"] }); // `--once`: one bounded wait, then exit — the shape a harness can call as // a tool. the daemon below is the shape only a stream reader can consume. if (parsed["--once"] === true) { const result = await waitForPrEvents({ ...watched, token, cursor, maxWaitMs: SERVER_POLL_WINDOW_MS, }); emit(result.events, pretty); return; } } catch (error) { if (isTerminal(error)) bail(error.message); throw error; } // narrowed past `pollPrEvents`'s optional `signal`, which a query cannot key const poll = yes.query({ run: (input: PrTarget & { token: string; cursor: string }) => pollPrEvents(input), name: "pr-events poll", retry: (error, attempt) => isTerminal(error) ? -1 : yes.backoff([1000, 2000, 5000, 10_000, 15_000], attempt), }); // daemon loop — the loop is the whole command. the query smooths transient // network blips within a cycle. a longer outage that exhausts the op's // retries must NOT kill the watcher, so we back off and keep going rather // than crash; only a terminal auth/access answer exits. for (;;) { try { const result = await poll({ ...watched, token, cursor }); cursor = result.cursor; writeCursor(watched, cursor); emit(result.events, pretty); } catch (error) { if (isTerminal(error)) bail(error.message); const message = error instanceof Error ? error.message : String(error); console.error(pc.dim(`watch: ${message} — retrying in 30s`)); await new Promise((resolve) => setTimeout(resolve, 30_000)); } } }