0a64659ee7
* refactor: extract helpers out of action/main.ts so non-orchestration churn stops touching the file main.ts had grown to ~1240 lines holding ~500 lines of helpers that have nothing to do with the resolver pipeline — billing-error UI/copy, proxy minting, summary/learnings persistence, log formatters, end-of-run cleanup waterfalls. any PR adding a new billing code branch or a new log line was forced to edit main.ts, and since main.ts is in ALWAYS_RUN_ALL the entire 52-job LLM CI matrix fired on what should have been a 0-job change (e.g. #748). extractions: - action/utils/billingErrors.ts — BillingError, TransientError, the format*Summary renderers, billingConsoleUrl - action/utils/proxy.ts — mintProxyKey, buildProxyTokenHeaders, resolveProxyModel, plus runProxyResolution wrapper that renders + rethrows BillingError/TransientError before the outer catch - action/utils/prSummary.ts — fetchPreviousSnapshot, persistSummary co-located with the existing seed/read file helpers - action/utils/learnings.ts — persistLearnings co-located with the existing seed/read file helpers - action/utils/runStartupLog.ts — resolveOutputSchema + logRunStartup (the model/agent/push/shell/timeout block) - action/utils/runErrorRenderer.ts — renderRunError classifies (BillingError reclassify / hang detect / API-key auth) and emits {summary, comment} markdown bodies - action/utils/runLifecycle.ts — persistRunArtifacts, finalizeSuccessRun, writeRunErrorOutputs — the three end-of-run cleanup phases shared between the success path and the error catch path main.ts is now ~570 lines — the irreducible orchestrator: disposables (`await using` for tokenRef / gitAuthServer / mcpHttpServer), the toolContext construction, the agent-timeout race, the catch/finally shape, and the named phase calls. behavior is preserved verbatim (verified: pnpm -r typecheck + pnpm test 695/695 pass, action/test 596/596 pass). wiki/main.md gets a new "file layout" section describing the split. AGENTS.md gets a single line pointing future edits at the helpers instead of main.ts. * anneal: address review findings - restore MainResult.result?: string (accidental removal in initial commit; field was unused in current code but is part of the exported interface surface — keep the diff truly behavior-preserving) - move resolveOutputSchema from runStartupLog.ts to payload.ts (it's an action-input resolver alongside resolvePromptInput / resolvePayload, not a log helper — was placed in runStartupLog.ts for matrix-churn pragmatism but the domain fit is in payload.ts) - un-export resolveProxyModel (only used internally by runProxyResolution in proxy.ts; no external importer) - fix runErrorRenderer.ts JSDoc "Three classifications" → four (Billing, hang, API-key, default) - expand runLifecycle.ts module banner to note that finalizeSuccessRun calls persistRunArtifacts first, and to explain why the catch path splits writeRunErrorOutputs + persistRunArtifacts - update billingErrors.ts header to point at proxy.ts and runErrorRenderer.ts as the actual origin sites (was stale "main.ts") - expand proxy.ts header to spell out the runProxyResolution entrypoint contract (was stale "main.ts can render") - update wiki/main.md resolver chain + dependency table to name runProxyResolution as the actual call site and document the early BillingError/TransientError rendering branch - update wiki/main.md file-layout table to lead with runProxyResolution and describe mintProxyKey/buildProxyTokenHeaders/resolveProxyModel as internal helpers (was implying they were public surface)
148 lines
5.9 KiB
TypeScript
148 lines
5.9 KiB
TypeScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import type { ToolContext } from "../mcp/server.ts";
|
|
import { apiFetch } from "./apiFetch.ts";
|
|
import { log } from "./cli.ts";
|
|
import { patchWorkflowRunFields } from "./patchWorkflowRunFields.ts";
|
|
|
|
/**
|
|
* The PR-level summary snapshot is a markdown file the agent edits in place
|
|
* during a Review / IncrementalReview run. The server seeds the file with
|
|
* either the previous run's snapshot (incremental) or a stub scaffold (first
|
|
* run), lets the agent edit it with its native file-editing tools, then
|
|
* reads it back at end-of-run and persists it to `WorkflowRun.summarySnapshot`.
|
|
*
|
|
* The snapshot is an internal artifact — it is consumed by future agent runs
|
|
* as durable cross-run context, not surfaced to humans. User-visible summary
|
|
* content lives in the Review / IncrementalReview review bodies, governed by
|
|
* `action/modes.ts`.
|
|
*
|
|
* Edit-in-place avoids the output-token tax of a tool call that regurgitates
|
|
* the full snapshot, and gives incremental runs a clean surface that
|
|
* range-diffs cleanly across runs because the section headings are stable.
|
|
*/
|
|
|
|
export const SUMMARY_FILE_NAME = "pullfrog-summary.md";
|
|
|
|
/**
|
|
* minimal seed for first-run PRs. just a header + a one-line note about
|
|
* what this file is for. structure is intentionally NOT prescribed —
|
|
* different PRs warrant different organization, and the agent should pick
|
|
* a shape that fits this PR. the agent's prompt (see selectMode.ts
|
|
* `buildSummaryAddendum`) carries the actual instructions for what to
|
|
* capture and how.
|
|
*
|
|
* keeping the seed short also makes the unchanged-from-seed gate more
|
|
* sensitive — any meaningful edit moves the file off the seed, so
|
|
* `persistSummary` can reliably skip the DB write when the agent didn't
|
|
* touch the file.
|
|
*/
|
|
export const SUMMARY_SCAFFOLD = `# PR summary
|
|
|
|
<!-- durable cross-run context. edit in place; the next agent run reads this
|
|
before reviewing new commits. structure however serves the PR best. -->
|
|
`;
|
|
|
|
const MIN_SNAPSHOT_LENGTH = 60;
|
|
/** PG TEXT can hold ~1GB but a sane cap protects the DB / API payloads. */
|
|
const MAX_SNAPSHOT_LENGTH = 32_768;
|
|
|
|
export function summaryFilePath(tmpdir: string): string {
|
|
return join(tmpdir, SUMMARY_FILE_NAME);
|
|
}
|
|
|
|
/** seed the summary file with previous snapshot (incremental) or scaffold (first run). */
|
|
export async function seedSummaryFile(params: {
|
|
tmpdir: string;
|
|
previousSnapshot: string | null;
|
|
}): Promise<string> {
|
|
const path = summaryFilePath(params.tmpdir);
|
|
await mkdir(dirname(path), { recursive: true });
|
|
const seed =
|
|
params.previousSnapshot && params.previousSnapshot.trim().length >= MIN_SNAPSHOT_LENGTH
|
|
? params.previousSnapshot
|
|
: SUMMARY_SCAFFOLD;
|
|
await writeFile(path, seed, "utf8");
|
|
return path;
|
|
}
|
|
|
|
/** read + validate the summary file written by the agent.
|
|
* returns null when the file is missing or fails sanity checks. */
|
|
export async function readSummaryFile(path: string): Promise<string | null> {
|
|
let raw: string;
|
|
try {
|
|
raw = await readFile(path, "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
const trimmed = raw.trim();
|
|
if (trimmed.length < MIN_SNAPSHOT_LENGTH) return null;
|
|
if (trimmed.length > MAX_SNAPSHOT_LENGTH) return trimmed.slice(0, MAX_SNAPSHOT_LENGTH);
|
|
return trimmed;
|
|
}
|
|
|
|
/**
|
|
* Fetch the most recent persisted PR summary snapshot for this PR.
|
|
* Returns null on first-time PRs, when summary is disabled, or on any error.
|
|
* Best-effort: a transient API failure should not block the run.
|
|
*/
|
|
export async function fetchPreviousSnapshot(
|
|
ctx: ToolContext,
|
|
prNumber: number
|
|
): Promise<string | null> {
|
|
if (!ctx.githubInstallationToken) return null;
|
|
try {
|
|
const response = await apiFetch({
|
|
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/pr/${prNumber}/summary-comment`,
|
|
method: "GET",
|
|
headers: { authorization: `Bearer ${ctx.githubInstallationToken}` },
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
if (!response.ok) return null;
|
|
const data = (await response.json()) as { snapshot?: string | null };
|
|
return typeof data.snapshot === "string" && data.snapshot.length > 0 ? data.snapshot : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the agent-edited PR summary tmpfile and persist to
|
|
* `WorkflowRun.summarySnapshot`.
|
|
*
|
|
* Best-effort: any failure is logged and does not affect the run's success
|
|
* status. Skips the PATCH when the file is byte-identical to its seed —
|
|
* persisting the seed verbatim would either re-write what the DB already has
|
|
* (on incremental runs) or serialize the placeholder scaffold (on first
|
|
* runs), neither of which is useful.
|
|
*
|
|
* Funnels through both the success path and the SIGINT/SIGTERM handler;
|
|
* `summaryPersistAttempted` guards against double-execution.
|
|
*/
|
|
export async function persistSummary(ctx: ToolContext): Promise<void> {
|
|
const filePath = ctx.toolState.summaryFilePath;
|
|
if (!filePath) return;
|
|
if (ctx.toolState.summaryPersistAttempted) return;
|
|
ctx.toolState.summaryPersistAttempted = true;
|
|
const snapshot = await readSummaryFile(filePath);
|
|
if (!snapshot) {
|
|
log.debug(`pr summary tmpfile missing or invalid at ${filePath} — skipping persist`);
|
|
return;
|
|
}
|
|
// soft gate: agent never touched the seeded file. saving the seed back
|
|
// is a no-op at best (incremental run — DB already has it) and a bug at
|
|
// worst (first run — serializes the placeholder italics). log a warning
|
|
// so the failure mode is visible in CI without flipping the run to
|
|
// failed.
|
|
const seed = ctx.toolState.summarySeed?.trim();
|
|
if (seed !== undefined && snapshot === seed) {
|
|
log.warning(
|
|
"» pr summary tmpfile unchanged from seed — skipping persist (agent did not edit it)"
|
|
);
|
|
return;
|
|
}
|
|
await patchWorkflowRunFields(ctx, { summarySnapshot: snapshot }).catch((err) => {
|
|
log.debug(`pr summary persist failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
});
|
|
}
|