71feba0a76
* fix: prevent log.writeSummary from overwriting reportProgress content The run summary was showing logs instead of the final reportProgress content because log.writeSummary() was called after reportProgress. Now log.writeSummary() checks if the summary was already overwritten by reportProgress and skips if so. Fixes #86 * refactor: replace dynamic import with static import in cli.ts Replace unnecessary dynamic import of wasSummaryOverwritten with static import. No circular dependency exists since comment.ts doesn't import from cli.ts. * Fix run summary writing --------- Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
561 lines
18 KiB
TypeScript
561 lines
18 KiB
TypeScript
import { mkdtemp } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { isAbsolute, join, resolve } from "node:path";
|
|
import { flatMorph } from "@ark/util";
|
|
import type { Octokit } from "@octokit/rest";
|
|
import { encode as toonEncode } from "@toon-format/toon";
|
|
import { type } from "arktype";
|
|
import { type Agent, agents } from "./agents/index.ts";
|
|
import type { AgentResult } from "./agents/shared.ts";
|
|
import { AgentName, agentsManifest, Effort, type Payload } from "./external.ts";
|
|
import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts";
|
|
import { createMcpConfigs } from "./mcp/config.ts";
|
|
import { startMcpHttpServer } from "./mcp/server.ts";
|
|
import { getModes, type Mode, ModeSchema, modes } from "./modes.ts";
|
|
import packageJson from "./package.json" with { type: "json" };
|
|
import type { PrepResult } from "./prep/index.ts";
|
|
import { fetchRepoSettings, fetchWorkflowRunInfo, type RepoSettings } from "./utils/api.ts";
|
|
import { log } from "./utils/cli.ts";
|
|
import { reportErrorToComment } from "./utils/errorReport.ts";
|
|
import { createOctokit, parseRepoContext, setupGitHubInstallationToken } from "./utils/github.ts";
|
|
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts";
|
|
import { Timer } from "./utils/timer.ts";
|
|
|
|
// inputs schema - mirrors Payload fields without the discriminated union for event
|
|
export const Inputs = type({
|
|
prompt: "string",
|
|
"effort?": Effort,
|
|
"agent?": AgentName.or("null"),
|
|
"event?": "object",
|
|
"modes?": ModeSchema.array(),
|
|
"sandbox?": "boolean",
|
|
"disableProgressComment?": "true",
|
|
"comment_id?": "number|null",
|
|
"issue_id?": "number|null",
|
|
"pr_id?": "number|null",
|
|
"cwd?": "string|null",
|
|
});
|
|
|
|
export type Inputs = typeof Inputs.infer;
|
|
|
|
export interface MainResult {
|
|
success: boolean;
|
|
output?: string | undefined;
|
|
error?: string | undefined;
|
|
}
|
|
|
|
// intermediate result types for deterministic context building
|
|
interface GitHubSetup {
|
|
owner: string;
|
|
name: string;
|
|
octokit: Octokit;
|
|
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
|
repoSettings: RepoSettings;
|
|
}
|
|
|
|
type ApiKeySetup =
|
|
| { success: true; apiKey: string; apiKeys: Record<string, string> }
|
|
| { success: false; error: string };
|
|
|
|
export async function main(inputs: Inputs): Promise<MainResult> {
|
|
// change to cwd input or GITHUB_WORKSPACE (where actions/checkout puts the repo)
|
|
// JavaScript actions run from the action's directory, not the checked out repo
|
|
let cwd = inputs.cwd || process.env.GITHUB_WORKSPACE;
|
|
if (inputs.cwd && !isAbsolute(inputs.cwd) && process.env.GITHUB_WORKSPACE) {
|
|
cwd = resolve(process.env.GITHUB_WORKSPACE, inputs.cwd);
|
|
}
|
|
if (cwd && process.cwd() !== cwd) {
|
|
log.debug(`changing to working directory: ${cwd}`);
|
|
process.chdir(cwd);
|
|
}
|
|
|
|
const timer = new Timer();
|
|
// `await using` ensures the token is automatically revoked when the function exits
|
|
await using tokenRef = await setupGitHubInstallationToken();
|
|
let payload: Payload | undefined;
|
|
|
|
try {
|
|
// phase 1: parse and validate inputs
|
|
payload = parsePayload(inputs);
|
|
Inputs.assert(inputs);
|
|
setupGitConfig();
|
|
|
|
// phase 2: fast setup (github + temp dir)
|
|
const [githubSetup, sharedTempDir] = await Promise.all([
|
|
initializeGitHub(tokenRef.token),
|
|
createTempDirectory(),
|
|
]);
|
|
timer.checkpoint("githubSetup");
|
|
|
|
// phase 3: resolve agent (needs repo settings)
|
|
const agent = resolveAgent({
|
|
payload,
|
|
repoSettings: githubSetup.repoSettings,
|
|
});
|
|
const resolvedPayload = { ...payload, agent: agent.name };
|
|
|
|
// phase 4: validate API key (sync, needs agent) - fail fast before long-running operations
|
|
const apiKeySetup = validateApiKey({
|
|
agent,
|
|
owner: githubSetup.owner,
|
|
name: githubSetup.name,
|
|
});
|
|
if (!apiKeySetup.success) {
|
|
await reportErrorToComment({ error: apiKeySetup.error });
|
|
return { success: false, error: apiKeySetup.error };
|
|
}
|
|
|
|
// phase 5: parallel long-running operations (agent install + git auth)
|
|
const toolState: ToolState = {};
|
|
const [cliPath] = await Promise.all([
|
|
installAgentCli({ agent, token: tokenRef.token }),
|
|
setupGitAuth({
|
|
token: tokenRef.token,
|
|
owner: githubSetup.owner,
|
|
name: githubSetup.name,
|
|
payload: resolvedPayload,
|
|
octokit: githubSetup.octokit,
|
|
toolState,
|
|
}),
|
|
]);
|
|
timer.checkpoint("agentSetup+gitAuth");
|
|
|
|
// phase 6: compute modes
|
|
const computedModes: Mode[] = [
|
|
...getModes({
|
|
disableProgressComment: resolvedPayload.disableProgressComment,
|
|
}),
|
|
...(resolvedPayload.modes || []),
|
|
];
|
|
|
|
// phase 7: compute runId/jobId for MCP tools
|
|
const runId = process.env.GITHUB_RUN_ID || "";
|
|
if (runId) {
|
|
const workflowRunInfo = await fetchWorkflowRunInfo(runId);
|
|
if (workflowRunInfo.progressCommentId) {
|
|
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
|
|
log.info(`📝 Using pre-created progress comment: ${workflowRunInfo.progressCommentId}`);
|
|
}
|
|
}
|
|
|
|
let jobId: string | undefined;
|
|
const jobName = process.env.GITHUB_JOB;
|
|
if (jobName && runId) {
|
|
const jobs = await githubSetup.octokit.rest.actions.listJobsForWorkflowRun({
|
|
owner: githubSetup.owner,
|
|
repo: githubSetup.name,
|
|
run_id: parseInt(runId, 10),
|
|
});
|
|
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
|
|
if (matchingJob) {
|
|
jobId = String(matchingJob.id);
|
|
log.info(`📋 Found job ID: ${jobId}`);
|
|
}
|
|
}
|
|
|
|
// phase 8: build tool context and start MCP server
|
|
const toolContext: ToolContext = {
|
|
owner: githubSetup.owner,
|
|
name: githubSetup.name,
|
|
githubInstallationToken: tokenRef.token,
|
|
octokit: githubSetup.octokit,
|
|
payload: resolvedPayload,
|
|
repo: githubSetup.repo,
|
|
repoSettings: githubSetup.repoSettings,
|
|
modes: computedModes,
|
|
toolState,
|
|
agent,
|
|
sharedTempDir,
|
|
runId,
|
|
jobId,
|
|
};
|
|
|
|
await using mcpHttpServer = await startMcpHttpServer(toolContext);
|
|
log.info(`🚀 MCP server started at ${mcpHttpServer.url}`);
|
|
|
|
const mcpServers = createMcpConfigs(mcpHttpServer.url);
|
|
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
|
|
timer.checkpoint("mcpServer");
|
|
|
|
// BUILD FINAL IMMUTABLE CONTEXT
|
|
const ctx: AgentContext = {
|
|
...toolContext,
|
|
inputs,
|
|
mcpServerUrl: mcpHttpServer.url,
|
|
mcpServers,
|
|
cliPath,
|
|
apiKey: apiKeySetup.apiKey,
|
|
apiKeys: apiKeySetup.apiKeys,
|
|
};
|
|
|
|
// check for empty comment_ids in fix_review trigger - report and exit early
|
|
if (
|
|
ctx.payload.event.trigger === "fix_review" &&
|
|
Array.isArray(ctx.payload.event.comment_ids) &&
|
|
ctx.payload.event.comment_ids.length === 0
|
|
) {
|
|
const noThumbsMessage = `👍 **No approved comments found**\n\nTo use "Fix 👍s", add a 👍 reaction to one or more inline review comments you want fixed.`;
|
|
log.error(noThumbsMessage);
|
|
await reportProgress(ctx, { body: noThumbsMessage });
|
|
return { success: true };
|
|
}
|
|
|
|
const result = await runAgent(ctx);
|
|
const mainResult = await handleAgentResult(result);
|
|
return mainResult;
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
log.error(errorMessage);
|
|
try {
|
|
await reportErrorToComment({ error: errorMessage });
|
|
} catch {
|
|
// error reporting failed, but don't let it mask the original error
|
|
}
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
};
|
|
} finally {
|
|
// ensure progress comment is updated if it was never updated during execution
|
|
// do this before revoking the token so we can still make API calls
|
|
try {
|
|
await ensureProgressCommentUpdated(payload);
|
|
} catch {
|
|
// error updating comment, but don't let it mask the original error
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if an agent has API keys available (from process.env)
|
|
*/
|
|
function agentHasApiKeys(agent: Agent): boolean {
|
|
if (agent.name === "opencode") {
|
|
// opencode accepts any API_KEY from environment
|
|
return Object.keys(process.env).some((key) => key.includes("API_KEY") && process.env[key]);
|
|
}
|
|
// check if any of the agent's expected keys are in environment
|
|
return agent.apiKeyNames.some((envKey) => !!process.env[envKey]);
|
|
}
|
|
|
|
function getAvailableAgents(): Agent[] {
|
|
return Object.values(agents).filter((agent) => agentHasApiKeys(agent));
|
|
}
|
|
|
|
/**
|
|
* Get all possible API key names from agentsManifest using flatMorph
|
|
*/
|
|
function getAllPossibleKeyNames(): string[] {
|
|
return Object.keys(
|
|
flatMorph(agentsManifest, (_, manifest) =>
|
|
manifest.apiKeyNames.map((keyName) => [keyName, true] as const)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build a helpful error message for missing API key with links to repo settings
|
|
*/
|
|
function buildMissingApiKeyError(params: { agent: Agent; owner: string; name: string }): string {
|
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
|
const settingsUrl = `${apiUrl}/console/${params.owner}/${params.name}`;
|
|
|
|
const githubRepoUrl = `https://github.com/${params.owner}/${params.name}`;
|
|
const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
|
|
|
|
// for OpenCode, use a generic message since it accepts any API key
|
|
const isOpenCode = params.agent.name === "opencode";
|
|
let secretNameList: string;
|
|
if (isOpenCode) {
|
|
secretNameList =
|
|
"any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)";
|
|
} else {
|
|
const inputKeys =
|
|
params.agent.apiKeyNames.length > 0 ? params.agent.apiKeyNames : getAllPossibleKeyNames();
|
|
const secretNames = inputKeys.map((key) => `\`${key}\``);
|
|
secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
|
|
}
|
|
|
|
return `Pullfrog is configured to use ${params.agent.displayName}, but the associated API key was not provided.
|
|
|
|
To fix this, add the required secret to your GitHub repository:
|
|
|
|
1. Go to: ${githubSecretsUrl}
|
|
2. Click "New repository secret"
|
|
3. Set the name to ${secretNameList}
|
|
4. Set the value to your API key
|
|
5. Click "Add secret"
|
|
|
|
Alternatively, configure Pullfrog to use a different agent at ${settingsUrl}`;
|
|
}
|
|
|
|
// tool context - subset of Context needed by MCP tools
|
|
export interface ToolContext {
|
|
owner: string;
|
|
name: string;
|
|
githubInstallationToken: string;
|
|
octokit: Octokit;
|
|
payload: Payload;
|
|
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
|
repoSettings: RepoSettings;
|
|
modes: Mode[];
|
|
toolState: ToolState;
|
|
agent: Agent;
|
|
sharedTempDir: string;
|
|
runId: string;
|
|
jobId: string | undefined;
|
|
}
|
|
|
|
export interface AgentContext extends Readonly<ToolContext> {
|
|
readonly inputs: Inputs;
|
|
readonly mcpServerUrl: string;
|
|
readonly mcpServers: ReturnType<typeof createMcpConfigs>;
|
|
readonly cliPath: string;
|
|
readonly apiKey: string;
|
|
readonly apiKeys: Record<string, string>;
|
|
}
|
|
|
|
export interface DependencyInstallationState {
|
|
status: "not_started" | "in_progress" | "completed" | "failed";
|
|
promise: Promise<PrepResult[]> | undefined;
|
|
results: PrepResult[] | undefined;
|
|
}
|
|
|
|
export interface ToolState {
|
|
prNumber?: number;
|
|
issueNumber?: number;
|
|
selectedMode?: string;
|
|
review?: {
|
|
id: number; // REST API database ID (for fix URLs)
|
|
nodeId: string; // GraphQL node ID (for mutations)
|
|
};
|
|
dependencyInstallation?: DependencyInstallationState;
|
|
}
|
|
|
|
/**
|
|
* Initialize GitHub connection: token, octokit, repo data, settings
|
|
*/
|
|
async function initializeGitHub(token: string): Promise<GitHubSetup> {
|
|
log.info(`🐸 Running pullfrog/pullfrog@${packageJson.version}...`);
|
|
|
|
const { owner, name } = parseRepoContext();
|
|
|
|
const octokit = createOctokit(token);
|
|
|
|
// fetch repo data and settings in parallel
|
|
const [repoResponse, repoSettings] = await Promise.all([
|
|
octokit.repos.get({ owner, repo: name }),
|
|
fetchRepoSettings({ token, repoContext: { owner, name } }),
|
|
]);
|
|
|
|
return {
|
|
owner,
|
|
name,
|
|
octokit,
|
|
repo: repoResponse.data,
|
|
repoSettings,
|
|
};
|
|
}
|
|
|
|
function resolveAgent({
|
|
payload,
|
|
repoSettings,
|
|
}: {
|
|
payload: Payload;
|
|
repoSettings: RepoSettings;
|
|
}): Agent {
|
|
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
|
|
log.debug(
|
|
`» determineAgent: agentOverride=${agentOverride}, payload.agent=${payload.agent}, repoSettings.defaultAgent=${repoSettings.defaultAgent}`
|
|
);
|
|
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
|
|
|
|
if (configuredAgentName) {
|
|
const agent = agents[configuredAgentName];
|
|
if (!agent) {
|
|
throw new Error(`invalid agent name: ${configuredAgentName}`);
|
|
}
|
|
|
|
// if explicitly configured (via override or payload), respect it even without matching keys
|
|
// this allows users to force an agent selection (will fail later with clear error if no keys)
|
|
const isExplicitOverride = agentOverride !== undefined || payload.agent !== null;
|
|
if (isExplicitOverride) {
|
|
log.info(`Selected configured agent: ${agent.name}`);
|
|
return agent;
|
|
}
|
|
|
|
// for repo-level defaults, check if agent has matching keys before selecting
|
|
if (agentHasApiKeys(agent)) {
|
|
log.info(`Selected configured agent: ${agent.name}`);
|
|
return agent;
|
|
}
|
|
|
|
// fall through to auto-selection
|
|
const availableAgents = getAvailableAgents();
|
|
log.warning(
|
|
`Repo default agent ${agent.name} has no matching API keys. Available: ${
|
|
availableAgents.map((a) => a.name).join(", ") || "none"
|
|
}`
|
|
);
|
|
}
|
|
|
|
const availableAgents = getAvailableAgents();
|
|
if (availableAgents.length === 0) {
|
|
throw new Error("no agents available - missing API keys");
|
|
}
|
|
|
|
const agent = availableAgents[0];
|
|
log.info(`No agent configured, defaulting to first available agent: ${agent.name}`);
|
|
return agent;
|
|
}
|
|
|
|
async function createTempDirectory(): Promise<string> {
|
|
const sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-"));
|
|
process.env.PULLFROG_TEMP_DIR = sharedTempDir;
|
|
log.info(`📂 PULLFROG_TEMP_DIR has been created at ${sharedTempDir}`);
|
|
return sharedTempDir;
|
|
}
|
|
|
|
function parsePayload(inputs: Inputs): Payload {
|
|
// helper to convert "null" string to null, with proper type narrowing
|
|
const agent =
|
|
inputs.agent === undefined || inputs.agent === "null" ? null : (inputs.agent as AgentName);
|
|
|
|
// dispatch action provides structured inputs directly (event, modes, etc.)
|
|
if (inputs.event) {
|
|
return {
|
|
"~pullfrog": true,
|
|
agent,
|
|
prompt: inputs.prompt,
|
|
event: inputs.event as Payload["event"],
|
|
modes: inputs.modes ?? modes,
|
|
effort: inputs.effort ?? "auto",
|
|
sandbox: inputs.sandbox,
|
|
disableProgressComment: inputs.disableProgressComment,
|
|
comment_id: inputs.comment_id,
|
|
issue_id: inputs.issue_id,
|
|
pr_id: inputs.pr_id,
|
|
} as Payload;
|
|
}
|
|
|
|
// run action: try to parse prompt as JSON (legacy internal invocation)
|
|
try {
|
|
const parsedPrompt = JSON.parse(inputs.prompt);
|
|
if (!("~pullfrog" in parsedPrompt)) {
|
|
throw new Error();
|
|
}
|
|
// internal invocation: use effort from payload, fallback to input, default to "auto"
|
|
return {
|
|
...parsedPrompt,
|
|
effort: parsedPrompt.effort ?? inputs.effort ?? "auto",
|
|
} as Payload;
|
|
} catch {
|
|
// external invocation: use effort from input
|
|
return {
|
|
"~pullfrog": true,
|
|
agent,
|
|
prompt: inputs.prompt,
|
|
event: {
|
|
trigger: "unknown",
|
|
},
|
|
modes: inputs.modes ?? modes,
|
|
effort: inputs.effort ?? "auto",
|
|
sandbox: inputs.sandbox,
|
|
} as Payload;
|
|
}
|
|
}
|
|
|
|
async function installAgentCli(params: { agent: Agent; token: string }): Promise<string> {
|
|
// gemini is the only agent that needs githubInstallationToken for install
|
|
if (params.agent.name === "gemini") {
|
|
return params.agent.install(params.token);
|
|
}
|
|
return params.agent.install();
|
|
}
|
|
|
|
function collectApiKeys(agent: Agent): Record<string, string> {
|
|
const apiKeys: Record<string, string> = {};
|
|
|
|
// read API keys from environment variables
|
|
for (const envKey of agent.apiKeyNames) {
|
|
const value = process.env[envKey];
|
|
if (value) {
|
|
apiKeys[envKey] = value;
|
|
}
|
|
}
|
|
|
|
// for OpenCode: check process.env for any API_KEY variables
|
|
if (agent.name === "opencode" && Object.keys(apiKeys).length === 0) {
|
|
for (const [key, value] of Object.entries(process.env)) {
|
|
if (value && typeof value === "string" && key.includes("API_KEY")) {
|
|
apiKeys[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return apiKeys;
|
|
}
|
|
|
|
function validateApiKey(params: { agent: Agent; owner: string; name: string }): ApiKeySetup {
|
|
const apiKeys = collectApiKeys(params.agent);
|
|
|
|
if (Object.keys(apiKeys).length === 0) {
|
|
return {
|
|
success: false,
|
|
error: buildMissingApiKeyError({
|
|
agent: params.agent,
|
|
owner: params.owner,
|
|
name: params.name,
|
|
}),
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
apiKey: Object.values(apiKeys)[0],
|
|
apiKeys,
|
|
};
|
|
}
|
|
|
|
async function runAgent(ctx: AgentContext): Promise<AgentResult> {
|
|
const effort = ctx.payload.effort ?? "auto";
|
|
log.info(`Running ${ctx.agent.name} with effort=${effort}...`);
|
|
// strip context from event - it's already available via MCP tools
|
|
const { context: _context, ...eventWithoutContext } = ctx.payload.event;
|
|
// format: prompt + two newlines + TOON encoded event
|
|
const promptContent = `${ctx.payload.prompt}\n\n${toonEncode(eventWithoutContext)}`;
|
|
log.box(promptContent, { title: "Prompt" });
|
|
|
|
return ctx.agent.run({
|
|
payload: ctx.payload,
|
|
mcpServers: ctx.mcpServers,
|
|
apiKey: ctx.apiKey,
|
|
apiKeys: ctx.apiKeys,
|
|
cliPath: ctx.cliPath,
|
|
repo: {
|
|
owner: ctx.owner,
|
|
name: ctx.name,
|
|
defaultBranch: ctx.repo.default_branch,
|
|
isPublic: !ctx.repo.private,
|
|
},
|
|
effort,
|
|
});
|
|
}
|
|
|
|
async function handleAgentResult(result: AgentResult): Promise<MainResult> {
|
|
if (!result.success) {
|
|
return {
|
|
success: false,
|
|
error: result.error || "Agent execution failed",
|
|
output: result.output!,
|
|
};
|
|
}
|
|
|
|
log.success("Task complete.");
|
|
|
|
return {
|
|
success: true,
|
|
output: result.output || "",
|
|
};
|
|
}
|