Ctrl K
ComunidadGuías…

commands/watch.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// `pullfrog watch <owner>/<repo> --pr <N>` — 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 [<owner>/<repo>] --pr <number> [options]\n`);
  stream("subscribe to a PR's activity and print one JSON line per event.\n");
  stream("arguments:");
  stream("  <owner>/<repo>   target repo (defaults to the current git remote)");
  stream("");
  stream("options:");
  stream("  --pr <number>    pull request number to watch (required)");
  stream("  --once           wait for one batch of events, print it, and exit");
  stream("  --since <cursor> 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 <owner>/<repo>`);
  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<typeof parseWatchArgs>;
  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 <number> 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));
    }
  }
}