commands/_prEvents.ts
Rama main · réplica de lectura del repositorio original.
// shared client for the PR event stream (`/api/cli/pr-events`), used by both
// `pullfrog watch` (daemon + `--once`) and the `pullfrog mcp` server.
//
// two things live here rather than in watch.ts because the MCP server needs
// them too: the poll call itself, and the on-disk cursor. the cursor is what
// makes a one-shot poll safe to call repeatedly — without it two consecutive
// `--once` calls silently drop everything that landed in the gap between them,
// and nothing on the client can detect that it happened.
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
CLI_CONTRACT_HEADER,
CLI_CONTRACT_VERSION,
CLI_UPGRADE_MESSAGE,
CliContractError,
} from "../cliContract.ts";
import { PULLFROG_API_URL } from "./_shared.ts";
export type StreamEvent = {
cursor: string;
repo: string;
pr: number;
kind: string;
createdAt: string;
data: Record<string, unknown>;
};
export type PollResult = { cursor: string; events: StreamEvent[] };
export type PrTarget = { owner: string; repo: string; pr: number };
/** per-request ceiling — must exceed the server long-poll window (~20s) so the
* held connection returns normally rather than aborting client-side. */
const REQUEST_TIMEOUT_MS = 35_000;
/** the server's own long-poll budget, and so the granularity of any wait. keep
* it at or below that budget: `waitForPrEvents` loops until this deadline, so a
* value above it leaves enough time for a SECOND request — re-running the GitHub
* authorization round-trip and the lease/sweep writes — only to abort it
* mid-flight. at or below, the second iteration hits an already-fired abort
* signal and never reaches the network. the server measures its budget from
* request entry (app/api/cli/pr-events/route.ts). */
export const SERVER_POLL_WINDOW_MS = 20_000;
/**
* terminal, non-retryable failures. these are thrown rather than exiting so
* the MCP server can report them to the agent and stay up; `watch` translates
* them into a CLI `bail`.
*/
export class PrEventsAuthError extends Error {}
export class PrEventsAccessError extends Error {}
/** an answer no retry can improve: the token is bad, or the repo is not ours to
* read. everything else is worth another attempt. */
export function isTerminal(
error: unknown
): error is PrEventsAuthError | PrEventsAccessError | CliContractError {
return (
error instanceof PrEventsAuthError ||
error instanceof PrEventsAccessError ||
error instanceof CliContractError
);
}
export async function pollPrEvents(
ctx: PrTarget & { token: string; cursor: string | undefined; signal?: AbortSignal }
): Promise<PollResult> {
const params = new URLSearchParams({
owner: ctx.owner,
repo: ctx.repo,
pr: String(ctx.pr),
});
if (ctx.cursor !== undefined) params.set("since", ctx.cursor);
// two independent reasons to give up: our own per-request ceiling, and a
// caller's overall deadline. wired by hand rather than with AbortSignal.any
// so the published CLI keeps working below Node 20.3.
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const onCallerAbort = () => controller.abort();
if (ctx.signal?.aborted) controller.abort();
else ctx.signal?.addEventListener("abort", onCallerAbort, { once: true });
try {
const response = await fetch(`${PULLFROG_API_URL}/api/cli/pr-events?${params}`, {
headers: {
authorization: `Bearer ${ctx.token}`,
[CLI_CONTRACT_HEADER]: CLI_CONTRACT_VERSION,
},
signal: controller.signal,
});
if (response.status === 426) throw new CliContractError(CLI_UPGRADE_MESSAGE);
if (response.status === 401 || response.status === 403) {
throw new PrEventsAuthError("invalid or expired github token — run `gh auth login`.");
}
if (response.status === 404) {
throw new PrEventsAccessError(
`repository ${ctx.owner}/${ctx.repo} not found or Pullfrog not installed on it.`
);
}
if (!response.ok) {
throw new Error(`pr-events returned ${response.status}`);
}
return (await response.json()) as PollResult;
} finally {
clearTimeout(timeout);
ctx.signal?.removeEventListener("abort", onCallerAbort);
}
}
// ── cursor store ──
function stateDir(): string {
return process.env.PULLFROG_STATE_DIR || join(homedir(), ".pullfrog");
}
function cursorPath(target: PrTarget): string {
const key = [target.owner, target.repo, String(target.pr)].map(encodeURIComponent).join("__");
return join(stateDir(), "watch", `${key}.cursor`);
}
/** last cursor persisted for this PR, or undefined if this host has never
* watched it. a malformed or unreadable file reads as absent — resubscribing
* from now is always safe, where trusting a bad cursor is not. */
function readCursor(target: PrTarget): string | undefined {
try {
const raw = readFileSync(cursorPath(target), "utf-8").trim();
return /^\d+$/.test(raw) ? raw : undefined;
} catch {
return undefined;
}
}
/** persist the cursor. best-effort: a read-only HOME degrades this to the
* in-memory behaviour it replaced rather than killing a running watcher. */
export function writeCursor(target: PrTarget, cursor: string): void {
try {
const path = cursorPath(target);
mkdirSync(join(stateDir(), "watch"), { recursive: true });
writeFileSync(path, cursor, "utf-8");
} catch {
// ignore — a persisted cursor is an optimization, not a correctness gate.
}
}
/**
* resolve the cursor to start from, subscribing if this host has no stored
* position. a fresh subscribe returns the current max seq and no events, so it
* must NOT be treated as "nothing happened" — callers wanting to block for real
* activity poll again with the returned cursor.
*/
export async function resolveCursor(
ctx: PrTarget & { token: string; since: string | undefined }
): Promise<string> {
if (ctx.since !== undefined) return ctx.since;
const stored = readCursor(ctx);
if (stored !== undefined) return stored;
const subscribed = await pollPrEvents({ ...ctx, cursor: undefined });
writeCursor(ctx, subscribed.cursor);
return subscribed.cursor;
}
/**
* block until new events land on the PR or `maxWaitMs` elapses, whichever comes
* first. the server holds each request for its own ~20s window, so the deadline
* has to cancel the request in flight rather than be checked between requests —
* otherwise every wait rounds up to a full server window and a caller asking
* for 8s waits the whole window.
*
* cancelling mid-hold loses nothing: the cursor only advances on a response we
* actually received, so anything the server was about to send is still pending
* for the next call.
*/
export async function waitForPrEvents(
ctx: PrTarget & { token: string; cursor: string; maxWaitMs: number }
): Promise<PollResult> {
const controller = new AbortController();
const deadline = setTimeout(() => controller.abort(), ctx.maxWaitMs);
let cursor = ctx.cursor;
try {
for (;;) {
const result = await pollPrEvents({ ...ctx, cursor, signal: controller.signal });
cursor = result.cursor;
if (result.events.length > 0) return { cursor, events: result.events };
}
} catch (error) {
// the deadline firing is the one error we swallow. a bad token that lands
// in the same tick must still surface, or a caller loops forever being told
// the PR is quiet.
if (isTerminal(error) || !controller.signal.aborted) throw error;
return { cursor, events: [] };
} finally {
clearTimeout(deadline);
writeCursor(ctx, cursor);
}
}