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)
202 lines
7.3 KiB
TypeScript
202 lines
7.3 KiB
TypeScript
import { isAbsolute, resolve } from "node:path";
|
|
import * as core from "@actions/core";
|
|
import { type } from "arktype";
|
|
import type { AuthorPermission, PayloadEvent } from "../external.ts";
|
|
import packageJson from "../package.json" with { type: "json" };
|
|
import { log } from "./cli.ts";
|
|
import type { RepoSettings } from "./runContext.ts";
|
|
import { validateCompatibility } from "./versioning.ts";
|
|
|
|
// tool permission enum types for inputs
|
|
const ShellPermissionInput = type.enumerated("disabled", "restricted", "enabled");
|
|
const PushPermissionInput = type.enumerated("disabled", "restricted", "enabled");
|
|
|
|
// schema for JSON payload passed via prompt (internal dispatch invocation)
|
|
// note: permissions are intentionally NOT included here to prevent injection attacks
|
|
// permissions are derived from event.authorPermission instead
|
|
export const JsonPayload = type({
|
|
"~pullfrog": "true",
|
|
version: "string",
|
|
"model?": "string | undefined",
|
|
prompt: "string",
|
|
"triggerer?": "string | undefined",
|
|
|
|
"eventInstructions?": "string",
|
|
"previousRunsNote?": "string",
|
|
"event?": "object",
|
|
"timeout?": "string | undefined",
|
|
"progressComment?": type({
|
|
id: "string",
|
|
type: "'issue' | 'review'",
|
|
}).or("undefined"),
|
|
"generateSummary?": "boolean | undefined",
|
|
});
|
|
|
|
// permission levels that indicate collaborator status (have push access)
|
|
const COLLABORATOR_PERMISSIONS: AuthorPermission[] = ["admin", "maintain", "write"];
|
|
|
|
// check if the event author has collaborator-level permissions
|
|
function isCollaborator(event: PayloadEvent): boolean {
|
|
const perm = event.authorPermission;
|
|
return perm !== undefined && COLLABORATOR_PERMISSIONS.includes(perm);
|
|
}
|
|
|
|
// inputs schema - action inputs from core.getInput()
|
|
// note: tool permissions use .or("undefined") because getInput() || undefined
|
|
// explicitly sets the property to undefined when empty, which is different from
|
|
// the property being absent. arktype's "prop?" means "optional to include" but
|
|
// if included, must match the type - so we need to explicitly allow undefined.
|
|
export const Inputs = type({
|
|
prompt: "string",
|
|
"model?": type.string.or("undefined"),
|
|
"timeout?": type.string.or("undefined"),
|
|
"push?": PushPermissionInput.or("undefined"),
|
|
"shell?": ShellPermissionInput.or("undefined"),
|
|
"cwd?": type.string.or("undefined"),
|
|
"output_schema?": type.string.or("undefined"),
|
|
});
|
|
|
|
export type Inputs = typeof Inputs.infer;
|
|
|
|
function isPayloadEvent(value: unknown): value is PayloadEvent {
|
|
return typeof value === "object" && value !== null && "trigger" in value;
|
|
}
|
|
|
|
function resolveCwd(cwd: string | undefined): string | undefined {
|
|
const workspace = process.env.GITHUB_WORKSPACE;
|
|
if (!cwd) return workspace;
|
|
if (isAbsolute(cwd)) return cwd;
|
|
return workspace ? resolve(workspace, cwd) : cwd;
|
|
}
|
|
|
|
export type ResolvedPromptInput = string | typeof JsonPayload.infer;
|
|
|
|
export function resolvePromptInput(): ResolvedPromptInput {
|
|
const prompt = core.getInput("prompt", { required: true });
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(prompt);
|
|
} catch {
|
|
// JSON parse error is fine (plain text prompt)
|
|
return prompt;
|
|
}
|
|
|
|
if (!parsed || typeof parsed !== "object" || !("~pullfrog" in parsed)) {
|
|
// if it doesn't look like a pullfrog payload, return the plain text prompt
|
|
return prompt;
|
|
}
|
|
|
|
// validation errors should propagate
|
|
const jsonPayload = JsonPayload.assert(parsed);
|
|
validateCompatibility(jsonPayload.version, packageJson.version);
|
|
return jsonPayload;
|
|
}
|
|
|
|
function resolveNonPromptInputs() {
|
|
return Inputs.omit("prompt").assert({
|
|
model: core.getInput("model") || undefined,
|
|
timeout: core.getInput("timeout") || undefined,
|
|
cwd: core.getInput("cwd") || undefined,
|
|
push: core.getInput("push") || undefined,
|
|
shell: core.getInput("shell") || undefined,
|
|
});
|
|
}
|
|
|
|
const isPullfrog = (actor: string | null | undefined): boolean => {
|
|
actor = actor?.replace("[bot]", "");
|
|
return !!actor && (actor === "pullfrog" || actor === "pullfrogdev");
|
|
};
|
|
|
|
export function resolvePayload(
|
|
resolvedPromptInput: ResolvedPromptInput,
|
|
repoSettings: RepoSettings
|
|
) {
|
|
const [prompt, jsonPayload] =
|
|
typeof resolvedPromptInput !== "string"
|
|
? [resolvedPromptInput.prompt, resolvedPromptInput]
|
|
: [resolvedPromptInput, undefined];
|
|
|
|
const inputs = resolveNonPromptInputs();
|
|
|
|
// resolve event - use type guard for jsonPayload.event, fallback to unknown trigger
|
|
const rawEvent = jsonPayload?.event;
|
|
const event: PayloadEvent = isPayloadEvent(rawEvent) ? rawEvent : { trigger: "unknown" };
|
|
|
|
const model = jsonPayload?.model ?? inputs.model ?? repoSettings.model ?? undefined;
|
|
|
|
// determine shell permission - strictest setting wins
|
|
// precedence: disabled > restricted > enabled
|
|
// non-collaborators always get at least "restricted"
|
|
const isNonCollaborator = !isCollaborator(event);
|
|
const repoShell = repoSettings.shell ?? "restricted";
|
|
const inputShell = inputs.shell;
|
|
|
|
// resolve shell: start with repo setting, then apply restrictions
|
|
let resolvedShell = repoShell;
|
|
|
|
// input can only make it stricter (disabled > restricted > enabled)
|
|
if (inputShell === "disabled") {
|
|
resolvedShell = "disabled";
|
|
} else if (inputShell === "restricted" && resolvedShell === "enabled") {
|
|
resolvedShell = "restricted";
|
|
}
|
|
|
|
// non-collaborators get at least "restricted" (can't have "enabled")
|
|
if (isNonCollaborator && resolvedShell === "enabled") {
|
|
resolvedShell = "restricted";
|
|
}
|
|
|
|
// build payload - precedence: inputs > repoSettings > fallbacks
|
|
// note: modes are NOT in payload - they come from repoSettings in main()
|
|
return {
|
|
"~pullfrog": true as const,
|
|
version: jsonPayload?.version ?? packageJson.version,
|
|
model,
|
|
prompt,
|
|
triggerer:
|
|
jsonPayload?.triggerer ??
|
|
// it's not a common use case but GITHUB_ACTOR can be a user when the workflow is manually triggered by a user through GitHub Actions UI
|
|
(!isPullfrog(process.env.GITHUB_ACTOR) ? process.env.GITHUB_ACTOR : undefined),
|
|
eventInstructions: jsonPayload?.eventInstructions,
|
|
previousRunsNote: jsonPayload?.previousRunsNote,
|
|
event,
|
|
timeout: inputs.timeout ?? jsonPayload?.timeout,
|
|
cwd: resolveCwd(inputs.cwd),
|
|
progressComment: jsonPayload?.progressComment,
|
|
generateSummary: jsonPayload?.generateSummary,
|
|
|
|
// permissions: inputs > repoSettings > fallbacks
|
|
push: inputs.push ?? repoSettings.push ?? "restricted",
|
|
shell: resolvedShell,
|
|
|
|
// set by proxy logic in main.ts when routing through OpenRouter
|
|
proxyModel: undefined as string | undefined,
|
|
};
|
|
}
|
|
|
|
export type ResolvedPayload = ReturnType<typeof resolvePayload>;
|
|
|
|
/**
|
|
* Parse and validate the optional `output_schema` action input. Returns the
|
|
* parsed object when present, or `undefined` when absent. Throws on invalid
|
|
* JSON or non-object payloads — these are workflow-author errors that should
|
|
* surface immediately, not silently degrade to "no schema".
|
|
*/
|
|
export function resolveOutputSchema(): Record<string, unknown> | undefined {
|
|
const raw = core.getInput("output_schema");
|
|
if (!raw) return undefined;
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
throw new Error(`invalid output_schema: not valid JSON`);
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error(`invalid output_schema: must be a JSON object`);
|
|
}
|
|
log.info("» structured output schema provided — output will be required");
|
|
return parsed as Record<string, unknown>;
|
|
}
|