Clean up actions and payloads (#98)

* Clean up actions and payloads

* Clean up action

* Cleanup
This commit is contained in:
Colin McDonnell
2026-01-16 07:16:25 +00:00
committed by pullfrog[bot]
parent 5c60791b34
commit 9e019d89d2
68 changed files with 28182 additions and 306308 deletions
+1 -3
View File
@@ -1,12 +1,10 @@
# note: this file must remain identical to action/action.yml and action/run/action.yml
# future agents: keep both files in sync manually
name: "Pullfrog Action" name: "Pullfrog Action"
description: "Execute coding agents with a prompt" description: "Execute coding agents with a prompt"
author: "Pullfrog" author: "Pullfrog"
inputs: inputs:
prompt: prompt:
description: "Prompt to send to the agent" description: "Prompt to send to the agent (string or JSON payload)"
required: true required: true
effort: effort:
description: "Effort level: mini (fast), auto (default), max (most capable)" description: "Effort level: mini (fast), auto (default), max (most capable)"
+25 -20
View File
@@ -1,9 +1,10 @@
import { type Options, query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { type Options, query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { Effort } from "../external.ts"; import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import packageJson from "../package.json" with { type: "json" }; import packageJson from "../package.json" with { type: "json" };
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts"; import { installFromNpmTarball } from "../utils/install.ts";
import { type AgentConfig, agent, createAgentEnv, installFromNpmTarball } from "./shared.ts"; import { type AgentRunContext, agent } from "./shared.ts";
// Model selection based on effort level // Model selection based on effort level
// Note: mini uses Haiku for speed, auto uses opusplan for balance, max uses Opus for capability // Note: mini uses Haiku for speed, auto uses opusplan for balance, max uses Opus for capability
@@ -22,7 +23,7 @@ const claudeEffortModels: Record<Effort, string> = {
/** /**
* Build disallowedTools list from ToolPermissions. * Build disallowedTools list from ToolPermissions.
*/ */
function buildDisallowedTools(ctx: AgentConfig): string[] { function buildDisallowedTools(ctx: AgentRunContext): string[] {
const disallowed: string[] = []; const disallowed: string[] = [];
if (ctx.tools.web === "disabled") disallowed.push("WebFetch"); if (ctx.tools.web === "disabled") disallowed.push("WebFetch");
if (ctx.tools.search === "disabled") disallowed.push("WebSearch"); if (ctx.tools.search === "disabled") disallowed.push("WebSearch");
@@ -33,31 +34,33 @@ function buildDisallowedTools(ctx: AgentConfig): string[] {
return disallowed; return disallowed;
} }
async function installClaude(): Promise<string> {
const versionRange = packageJson.dependencies["@anthropic-ai/claude-agent-sdk"] || "latest";
return await installFromNpmTarball({
packageName: "@anthropic-ai/claude-agent-sdk",
version: versionRange,
executablePath: "cli.js",
});
}
export const claude = agent({ export const claude = agent({
name: "claude", name: "claude",
install: async () => { install: installClaude,
const versionRange = packageJson.dependencies["@anthropic-ai/claude-agent-sdk"] || "latest";
return await installFromNpmTarball({
packageName: "@anthropic-ai/claude-agent-sdk",
version: versionRange,
executablePath: "cli.js",
});
},
run: async (ctx) => { run: async (ctx) => {
// install CLI at start of run
const cliPath = await installClaude();
// Ensure API key is NOT in process.env - only pass via SDK's env option // Ensure API key is NOT in process.env - only pass via SDK's env option
delete process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_API_KEY;
const prompt = addInstructions(ctx);
log.group("Full prompt", () => log.info(prompt));
// select model based on effort level // select model based on effort level
const model = claudeEffortModels[ctx.effort]; const model = claudeEffortModels[ctx.effort];
log.info(`Using model: ${model} (effort: ${ctx.effort})`); log.info(`» using model: ${model} (effort: ${ctx.effort})`);
// build disallowedTools based on tool permissions // build disallowedTools based on tool permissions
const disallowedTools = buildDisallowedTools(ctx); const disallowedTools = buildDisallowedTools(ctx);
if (disallowedTools.length > 0) { if (disallowedTools.length > 0) {
log.info(`🔒 disallowed tools: ${disallowedTools.join(", ")}`); log.info(`» disallowed tools: ${disallowedTools.join(", ")}`);
} }
// Pass secrets via SDK's env option only (not process.env) // Pass secrets via SDK's env option only (not process.env)
@@ -65,14 +68,16 @@ export const claude = agent({
const queryOptions: Options = { const queryOptions: Options = {
permissionMode: "bypassPermissions" as const, permissionMode: "bypassPermissions" as const,
disallowedTools, disallowedTools,
mcpServers: ctx.mcpServers, mcpServers: {
[ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl },
},
model, model,
pathToClaudeCodeExecutable: ctx.cliPath, pathToClaudeCodeExecutable: cliPath,
env: createAgentEnv({ ANTHROPIC_API_KEY: ctx.apiKey }), env: process.env,
}; };
const queryInstance = query({ const queryInstance = query({
prompt, prompt: ctx.instructions,
options: queryOptions, options: queryOptions,
}); });
+26 -35
View File
@@ -8,14 +8,10 @@ import {
type ThreadOptions, type ThreadOptions,
} from "@openai/codex-sdk"; } from "@openai/codex-sdk";
import type { Effort } from "../external.ts"; import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts"; import { installFromNpmTarball } from "../utils/install.ts";
import { import { type AgentRunContext, agent } from "./shared.ts";
agent,
type AgentConfig,
installFromNpmTarball,
setupProcessAgentEnv,
} from "./shared.ts";
// model configuration based on effort level // model configuration based on effort level
const codexModel: Record<Effort, string> = { const codexModel: Record<Effort, string> = {
@@ -34,19 +30,14 @@ const codexReasoningEffort: Record<Effort, ModelReasoningEffort | undefined> = {
max: "high", max: "high",
}; };
function writeCodexConfig(ctx: AgentConfig): string { function writeCodexConfig(ctx: AgentRunContext): string {
const tempHome = process.env.PULLFROG_TEMP_DIR!; const codexDir = join(ctx.tmpdir, ".codex");
const codexDir = join(tempHome, ".codex");
mkdirSync(codexDir, { recursive: true }); mkdirSync(codexDir, { recursive: true });
const configPath = join(codexDir, "config.toml"); const configPath = join(codexDir, "config.toml");
// build MCP servers section // build MCP servers section
const mcpServerSections: string[] = []; log.info(`» adding MCP server '${ghPullfrogMcpName}' at ${ctx.mcpServerUrl}`);
for (const [name, config] of Object.entries(ctx.mcpServers)) { const mcpServerSections = [`[mcp_servers.${ghPullfrogMcpName}]\nurl = "${ctx.mcpServerUrl}"`];
if (config.type !== "http") continue;
log.info(`» adding MCP server '${name}' at ${config.url}`);
mcpServerSections.push(`[mcp_servers.${name}]\nurl = "${config.url}"`);
}
// build features section for tool control // build features section for tool control
// disable native shell if bash is "disabled" or "restricted" // disable native shell if bash is "disabled" or "restricted"
@@ -74,42 +65,42 @@ ${mcpServerSections.join("\n\n")}
return codexDir; return codexDir;
} }
async function installCodex(): Promise<string> {
return await installFromNpmTarball({
packageName: "@openai/codex",
version: "latest",
executablePath: "bin/codex.js",
});
}
export const codex = agent({ export const codex = agent({
name: "codex", name: "codex",
install: async () => { install: installCodex,
return await installFromNpmTarball({
packageName: "@openai/codex",
version: "latest",
executablePath: "bin/codex.js",
});
},
run: async (ctx) => { run: async (ctx) => {
const tempHome = process.env.PULLFROG_TEMP_DIR!; // install CLI at start of run
const cliPath = await installCodex();
// create config directory for codex before setting HOME // create config directory for codex before setting HOME
const configDir = join(tempHome, ".config", "codex"); const configDir = join(ctx.tmpdir, ".config", "codex");
mkdirSync(configDir, { recursive: true }); mkdirSync(configDir, { recursive: true });
const codexDir = writeCodexConfig(ctx); const codexDir = writeCodexConfig(ctx);
setupProcessAgentEnv({ process.env.HOME = ctx.tmpdir;
OPENAI_API_KEY: ctx.apiKey, process.env.CODEX_HOME = codexDir;
HOME: tempHome,
CODEX_HOME: codexDir, // point Codex to our config directory
});
// get model and reasoning effort based on effort level // get model and reasoning effort based on effort level
const model = codexModel[ctx.effort]; const model = codexModel[ctx.effort];
const modelReasoningEffort = codexReasoningEffort[ctx.effort]; const modelReasoningEffort = codexReasoningEffort[ctx.effort];
log.info(`Using model: ${model}`); log.info(`» using model: ${model}`);
if (modelReasoningEffort) { if (modelReasoningEffort) {
log.info(`Using modelReasoningEffort: ${modelReasoningEffort}`); log.info(`» using modelReasoningEffort: ${modelReasoningEffort}`);
} }
// Configure Codex // Configure Codex
const codexOptions: CodexOptions = { const codexOptions: CodexOptions = {
apiKey: ctx.apiKey, apiKey: ctx.apiKey,
codexPathOverride: ctx.cliPath, codexPathOverride: cliPath,
}; };
const codex = new Codex(codexOptions); const codex = new Codex(codexOptions);
@@ -128,13 +119,13 @@ export const codex = agent({
}; };
log.info( log.info(
`🔧 Codex options: sandboxMode=${threadOptions.sandboxMode}, networkAccessEnabled=${threadOptions.networkAccessEnabled}, webSearchEnabled=${threadOptions.webSearchEnabled}` `» Codex options: sandboxMode=${threadOptions.sandboxMode}, networkAccessEnabled=${threadOptions.networkAccessEnabled}, webSearchEnabled=${threadOptions.webSearchEnabled}`
); );
const thread = codex.startThread(threadOptions); const thread = codex.startThread(threadOptions);
try { try {
const streamedTurn = await thread.runStreamed(addInstructions(ctx)); const streamedTurn = await thread.runStreamed(ctx.instructions);
let finalOutput = ""; let finalOutput = "";
for await (const event of streamedTurn.events) { for await (const event of streamedTurn.events) {
+34 -40
View File
@@ -3,9 +3,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { Effort } from "../external.ts"; import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts"; import { installFromCurl } from "../utils/install.ts";
import { type AgentConfig, agent, createAgentEnv, installFromCurl } from "./shared.ts"; import { type AgentRunContext, agent } from "./shared.ts";
// effort configuration for Cursor // effort configuration for Cursor
// only "max" overrides the model; mini/auto use default ("auto") // only "max" overrides the model; mini/auto use default ("auto")
@@ -87,15 +88,20 @@ type CursorEvent =
| CursorToolCallEvent | CursorToolCallEvent
| CursorResultEvent; | CursorResultEvent;
async function installCursor(): Promise<string> {
return await installFromCurl({
installUrl: "https://cursor.com/install",
executableName: "cursor-agent",
});
}
export const cursor = agent({ export const cursor = agent({
name: "cursor", name: "cursor",
install: async () => { install: installCursor,
return await installFromCurl({
installUrl: "https://cursor.com/install",
executableName: "cursor-agent",
});
},
run: async (ctx) => { run: async (ctx) => {
// install CLI at start of run
const cliPath = await installCursor();
configureCursorMcpServers(ctx); configureCursorMcpServers(ctx);
configureCursorTools(ctx); configureCursorTools(ctx);
@@ -108,7 +114,7 @@ export const cursor = agent({
try { try {
const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8")); const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8"));
if (projectConfig.model) { if (projectConfig.model) {
log.info(`Using model from project .cursor/cli.json: ${projectConfig.model}`); log.info(`» using model from project .cursor/cli.json: ${projectConfig.model}`);
} else { } else {
modelOverride = cursorEffortModels[ctx.effort]; modelOverride = cursorEffortModels[ctx.effort];
} }
@@ -120,9 +126,9 @@ export const cursor = agent({
} }
if (modelOverride) { if (modelOverride) {
log.info(`Using model: ${modelOverride} (effort: ${ctx.effort})`); log.info(`» using model: ${modelOverride}, effort=${ctx.effort}`);
} else if (!existsSync(projectCliConfigPath)) { } else if (!existsSync(projectCliConfigPath)) {
log.info(`Using default model (effort: ${ctx.effort})`); log.info(`» using default model, effort=${ctx.effort}`);
} }
// track logged model_call_ids to avoid duplicates // track logged model_call_ids to avoid duplicates
@@ -196,11 +202,14 @@ export const cursor = agent({
}; };
try { try {
const fullPrompt = addInstructions(ctx);
log.group("Full prompt", () => log.info(fullPrompt));
// build CLI args // build CLI args
const baseArgs = ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps"]; const baseArgs = [
"--print",
ctx.instructions,
"--output-format",
"stream-json",
"--approve-mcps",
];
// add model flag if we have an override // add model flag if we have an override
if (modelOverride) { if (modelOverride) {
@@ -210,16 +219,14 @@ export const cursor = agent({
// always use --force since permissions are controlled via cli-config.json // always use --force since permissions are controlled via cli-config.json
const cursorArgs = [...baseArgs, "--force"]; const cursorArgs = [...baseArgs, "--force"];
log.info("Running Cursor CLI..."); log.info("» running Cursor CLI...");
const startTime = Date.now(); const startTime = Date.now();
return new Promise((resolve) => { return new Promise((resolve) => {
const child = spawn(ctx.cliPath, cursorArgs, { const child = spawn(cliPath, cursorArgs, {
cwd: process.cwd(), cwd: process.cwd(),
env: createAgentEnv({ env: process.env,
CURSOR_API_KEY: ctx.apiKey,
}),
stdio: ["ignore", "pipe", "pipe"], // Ignore stdin, pipe stdout/stderr stdio: ["ignore", "pipe", "pipe"], // Ignore stdin, pipe stdout/stderr
}); });
@@ -311,28 +318,16 @@ export const cursor = agent({
// There was an issue on macOS when you set HOME to a temp directory // There was an issue on macOS when you set HOME to a temp directory
// it was unable to find the macOS keychain and would fail // it was unable to find the macOS keychain and would fail
// temp solution is to stick with the actual $HOME // temp solution is to stick with the actual $HOME
function configureCursorMcpServers(ctx: AgentConfig): void { function configureCursorMcpServers(ctx: AgentRunContext): void {
const realHome = homedir(); const realHome = homedir();
const cursorConfigDir = join(realHome, ".cursor"); const cursorConfigDir = join(realHome, ".cursor");
const mcpConfigPath = join(cursorConfigDir, "mcp.json"); const mcpConfigPath = join(cursorConfigDir, "mcp.json");
mkdirSync(cursorConfigDir, { recursive: true }); mkdirSync(cursorConfigDir, { recursive: true });
// Convert to Cursor's expected format (HTTP config) const mcpServers = {
const cursorMcpServers: Record<string, { type: string; url: string }> = {}; [ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl },
for (const [serverName, serverConfig] of Object.entries(ctx.mcpServers)) { };
if (serverConfig.type !== "http") { writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8");
throw new Error(
`Unsupported MCP server type for Cursor: ${(serverConfig as any).type || "unknown"}`
);
}
cursorMcpServers[serverName] = {
type: "http",
url: serverConfig.url,
};
}
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: cursorMcpServers }, null, 2), "utf-8");
log.info(`» MCP config written to ${mcpConfigPath}`); log.info(`» MCP config written to ${mcpConfigPath}`);
} }
@@ -350,10 +345,9 @@ interface CursorCliConfig {
/** /**
* Configure Cursor CLI tool permissions via cli-config.json. * Configure Cursor CLI tool permissions via cli-config.json.
* *
* Config path: $XDG_CONFIG_HOME/cursor/ (not ~/.cursor/) because createAgentEnv * Config path: $HOME/.config/cursor/ (not ~/.cursor/).
* sets XDG_CONFIG_HOME=$HOME/.config.
*/ */
function configureCursorTools(ctx: AgentConfig): void { function configureCursorTools(ctx: AgentRunContext): void {
const realHome = homedir(); const realHome = homedir();
const cursorConfigDir = join(realHome, ".config", "cursor"); const cursorConfigDir = join(realHome, ".config", "cursor");
const cliConfigPath = join(cursorConfigDir, "cli-config.json"); const cliConfigPath = join(cursorConfigDir, "cli-config.json");
+37 -32
View File
@@ -2,10 +2,12 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { Effort } from "../external.ts"; import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { installFromGithub } from "../utils/install.ts";
import { spawn } from "../utils/subprocess.ts"; import { spawn } from "../utils/subprocess.ts";
import { addInstructions } from "./instructions.ts"; import { getGitHubInstallationToken } from "../utils/token.ts";
import { type AgentConfig, agent, createAgentEnv, installFromGithub } from "./shared.ts"; import { type AgentRunContext, agent } from "./shared.ts";
// effort configuration: model + thinking level // effort configuration: model + thinking level
// thinkingLevel is set via settings.json modelConfig.generateContentConfig.thinkingConfig // thinkingLevel is set via settings.json modelConfig.generateContentConfig.thinkingConfig
@@ -156,29 +158,38 @@ const messageHandlers = {
}, },
}; };
async function installGemini(githubInstallationToken?: string): Promise<string> {
return await installFromGithub({
owner: "google-gemini",
repo: "gemini-cli",
assetName: "gemini.js",
...(githubInstallationToken && { githubInstallationToken }),
});
}
export const gemini = agent({ export const gemini = agent({
name: "gemini", name: "gemini",
install: async (githubInstallationToken?: string) => { install: installGemini,
return await installFromGithub({
owner: "google-gemini",
repo: "gemini-cli",
assetName: "gemini.js",
...(githubInstallationToken && { githubInstallationToken }),
});
},
run: async (ctx) => { run: async (ctx) => {
// install CLI at start of run - use token for GitHub API rate limiting
const cliPath = await installGemini(getGitHubInstallationToken());
const model = configureGeminiSettings(ctx); const model = configureGeminiSettings(ctx);
if (!ctx.apiKey) { if (!ctx.apiKey) {
throw new Error("google_api_key or gemini_api_key is required for gemini agent"); throw new Error("google_api_key or gemini_api_key is required for gemini agent");
} }
const sessionPrompt = addInstructions(ctx);
log.group("Full prompt", () => log.info(sessionPrompt));
// build CLI args - --yolo for auto-approval // build CLI args - --yolo for auto-approval
// tool restrictions handled via settings.json tools.exclude // tool restrictions handled via settings.json tools.exclude
const args = ["--model", model, "--yolo", "--output-format=stream-json", "-p", sessionPrompt]; const args = [
"--model",
model,
"--yolo",
"--output-format=stream-json",
"-p",
ctx.instructions,
];
let finalOutput = ""; let finalOutput = "";
let stdoutBuffer = ""; let stdoutBuffer = "";
@@ -186,8 +197,8 @@ export const gemini = agent({
try { try {
const result = await spawn({ const result = await spawn({
cmd: "node", cmd: "node",
args: [ctx.cliPath, ...args], args: [cliPath, ...args],
env: createAgentEnv({ GEMINI_API_KEY: ctx.apiKey }), env: process.env,
onStdout: async (chunk) => { onStdout: async (chunk) => {
const text = chunk.toString(); const text = chunk.toString();
finalOutput += text; finalOutput += text;
@@ -242,7 +253,7 @@ export const gemini = agent({
} }
finalOutput = finalOutput || result.stdout || "Gemini CLI completed successfully."; finalOutput = finalOutput || result.stdout || "Gemini CLI completed successfully.";
log.info(" Gemini CLI completed successfully"); log.info("» Gemini CLI completed successfully");
return { return {
success: true, success: true,
@@ -266,9 +277,9 @@ export const gemini = agent({
* *
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md * See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
*/ */
function configureGeminiSettings(ctx: AgentConfig): string { function configureGeminiSettings(ctx: AgentRunContext): string {
const { model, thinkingLevel } = geminiEffortConfig[ctx.effort]; const { model, thinkingLevel } = geminiEffortConfig[ctx.effort];
log.info(`Using model: ${model}, thinkingLevel: ${thinkingLevel}`); log.info(`» using model: ${model}, thinkingLevel: ${thinkingLevel}`);
const realHome = homedir(); const realHome = homedir();
const geminiConfigDir = join(realHome, ".gemini"); const geminiConfigDir = join(realHome, ".gemini");
@@ -299,19 +310,13 @@ function configureGeminiSettings(ctx: AgentConfig): string {
includeTools?: string[]; includeTools?: string[];
excludeTools?: string[]; excludeTools?: string[];
} }
const geminiMcpServers: Record<string, GeminiMcpServerConfig> = {}; log.info(`» adding MCP server '${ghPullfrogMcpName}' at ${ctx.mcpServerUrl}...`);
for (const [serverName, serverConfig] of Object.entries(ctx.mcpServers)) { const geminiMcpServers: Record<string, GeminiMcpServerConfig> = {
if (serverConfig.type !== "http") { [ghPullfrogMcpName]: {
throw new Error( httpUrl: ctx.mcpServerUrl,
`Unsupported MCP server type for Gemini: ${(serverConfig as { type?: string }).type || "unknown"}`
);
}
geminiMcpServers[serverName] = {
httpUrl: serverConfig.url,
trust: true, // trust our own MCP server to avoid confirmation prompts trust: true, // trust our own MCP server to avoid confirmation prompts
}; },
log.info(`adding MCP server '${serverName}' at ${serverConfig.url}...`); };
}
// build tools.exclude based on permissions (v0.3.0+ nested format) // build tools.exclude based on permissions (v0.3.0+ nested format)
const exclude: string[] = []; const exclude: string[] = [];
@@ -340,7 +345,7 @@ function configureGeminiSettings(ctx: AgentConfig): string {
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
log.info(`» Gemini settings written to ${settingsPath}`); log.info(`» Gemini settings written to ${settingsPath}`);
if (exclude.length > 0) { if (exclude.length > 0) {
log.info(`🔒 excluded tools: ${exclude.join(", ")}`); log.info(`» excluded tools: ${exclude.join(", ")}`);
} }
return model; return model;
+55 -82
View File
@@ -1,70 +1,59 @@
import { mkdirSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { ghPullfrogMcpName } from "../external.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { spawn } from "../utils/subprocess.ts"; import { spawn } from "../utils/subprocess.ts";
import { addInstructions } from "./instructions.ts"; import { type AgentRunContext, agent } from "./shared.ts";
import {
agent, async function installOpencode(): Promise<string> {
type AgentConfig, return await installFromNpmTarball({
createAgentEnv, packageName: "opencode-ai",
installFromNpmTarball, version: "latest",
setupProcessAgentEnv, executablePath: "bin/opencode",
} from "./shared.ts"; installDependencies: true,
});
}
export const opencode = agent({ export const opencode = agent({
name: "opencode", name: "opencode",
install: async () => { install: installOpencode,
return await installFromNpmTarball({
packageName: "opencode-ai",
version: "latest",
executablePath: "bin/opencode",
installDependencies: true,
});
},
run: async (ctx) => { run: async (ctx) => {
// install CLI at start of run
const cliPath = await installOpencode();
// 1. configure home/config directory // 1. configure home/config directory
const tempHome = process.env.PULLFROG_TEMP_DIR!; const tempHome = ctx.tmpdir;
const configDir = join(tempHome, ".config", "opencode"); const configDir = join(tempHome, ".config", "opencode");
mkdirSync(configDir, { recursive: true }); mkdirSync(configDir, { recursive: true });
configureOpenCode(ctx); configureOpenCode(ctx);
const prompt = addInstructions(ctx);
log.group("Full prompt", () => log.info(prompt));
// message positional must come right after "run", before flags // message positional must come right after "run", before flags
const args = ["run", prompt, "--format", "json"]; const args = ["run", ctx.instructions, "--format", "json"];
// 6. set up environment process.env.HOME = tempHome;
setupProcessAgentEnv({ HOME: tempHome });
// SECURITY: build env vars from whitelisted base env to prevent API key leakage
// this prevents leaking other API keys (ANTHROPIC, GEMINI, etc.) to OpenCode subprocess
// XDG_CONFIG_HOME must be set because GitHub Actions sets it to a different path, // XDG_CONFIG_HOME must be set because GitHub Actions sets it to a different path,
// and OpenCode follows XDG spec (checks XDG_CONFIG_HOME before falling back to $HOME/.config) // and OpenCode follows XDG spec (checks XDG_CONFIG_HOME before falling back to $HOME/.config)
const env: Record<string, string> = { const env: NodeJS.ProcessEnv = {
...createAgentEnv({ HOME: tempHome }), ...process.env,
HOME: tempHome,
XDG_CONFIG_HOME: join(tempHome, ".config"), XDG_CONFIG_HOME: join(tempHome, ".config"),
// set GOOGLE_GENERATIVE_AI_API_KEY alias for Google provider compatibility (if not already set)
GOOGLE_GENERATIVE_AI_API_KEY:
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY,
}; };
// OpenCode doesn't support GitHub App installation tokens // OpenCode doesn't support GitHub App installation tokens
delete env.GITHUB_TOKEN; delete env.GITHUB_TOKEN;
// add API keys from apiKeys object
for (const [key, value] of Object.entries(ctx.apiKeys || {})) {
env[key.toUpperCase()] = value;
// also set GOOGLE_GENERATIVE_AI_API_KEY for Google provider compatibility
if (key === "GEMINI_API_KEY") {
env.GOOGLE_GENERATIVE_AI_API_KEY = value;
}
}
// run OpenCode in the repository directory (process.cwd() is set to GITHUB_WORKSPACE or repo dir) // run OpenCode in the repository directory (process.cwd() is set to GITHUB_WORKSPACE or repo dir)
const repoDir = process.cwd(); const repoDir = process.cwd();
log.info(`🚀 Starting OpenCode CLI: ${ctx.cliPath} ${args.join(" ")}`); log.debug(`» starting OpenCode: ${cliPath} ${args.join(" ")}`);
log.info(`📁 Working directory: ${repoDir}`); log.debug(`» working directory: ${repoDir}`);
log.debug(`🏠 HOME: ${env.HOME}`); log.debug(`» HOME: ${env.HOME}`);
log.debug(`📋 XDG_CONFIG_HOME: ${env.XDG_CONFIG_HOME}`); log.debug(`» XDG_CONFIG_HOME: ${env.XDG_CONFIG_HOME}`);
const startTime = Date.now(); const startTime = Date.now();
let lastActivityTime = startTime; let lastActivityTime = startTime;
@@ -73,7 +62,7 @@ export const opencode = agent({
let output = ""; let output = "";
let stdoutBuffer = ""; // buffer for incomplete lines across chunks let stdoutBuffer = ""; // buffer for incomplete lines across chunks
const result = await spawn({ const result = await spawn({
cmd: ctx.cliPath, cmd: cliPath,
args, args,
cwd: repoDir, cwd: repoDir,
env, env,
@@ -111,7 +100,7 @@ export const opencode = agent({
? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})` ? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})`
: " (OpenCode may be processing internally - LLM calls, planning, etc.)"; : " (OpenCode may be processing internally - LLM calls, planning, etc.)";
log.warning( log.warning(
`⚠️ No activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)` `» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)`
); );
} }
lastActivityTime = Date.now(); lastActivityTime = Date.now();
@@ -121,7 +110,7 @@ export const opencode = agent({
} else { } else {
// log unhandled event types for visibility // log unhandled event types for visibility
log.info( log.info(
`📋 OpenCode event (unhandled): type=${event.type}, data=${JSON.stringify(event).substring(0, 500)}` `» OpenCode event (unhandled): type=${event.type}, data=${JSON.stringify(event).substring(0, 500)}`
); );
} }
} catch { } catch {
@@ -145,7 +134,7 @@ export const opencode = agent({
}); });
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
log.info(` OpenCode CLI completed in ${duration}ms with exit code ${result.exitCode}`); log.info(`» OpenCode CLI completed in ${duration}ms with exit code ${result.exitCode}`);
// 8. log tokens if they weren't logged yet (fallback if result event wasn't emitted) // 8. log tokens if they weren't logged yet (fallback if result event wasn't emitted)
if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) { if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) {
@@ -185,29 +174,15 @@ export const opencode = agent({
* Configure OpenCode via opencode.json config file. * Configure OpenCode via opencode.json config file.
* Builds complete config with MCP servers and permissions in a single write to avoid race conditions. * Builds complete config with MCP servers and permissions in a single write to avoid race conditions.
*/ */
function configureOpenCode(ctx: AgentConfig): void { function configureOpenCode(ctx: AgentRunContext): void {
const tempHome = process.env.PULLFROG_TEMP_DIR!; const configDir = join(ctx.tmpdir, ".config", "opencode");
const configDir = join(tempHome, ".config", "opencode");
mkdirSync(configDir, { recursive: true }); mkdirSync(configDir, { recursive: true });
const configPath = join(configDir, "opencode.json"); const configPath = join(configDir, "opencode.json");
// build MCP servers config // build MCP servers config
const opencodeMcpServers: Record<string, { type: "remote"; url: string }> = {}; const opencodeMcpServers = {
for (const [serverName, serverConfig] of Object.entries(ctx.mcpServers)) { [ghPullfrogMcpName]: { type: "remote" as const, url: ctx.mcpServerUrl },
if (serverConfig.type !== "http") { };
log.error(
`unsupported MCP server type for OpenCode: ${(serverConfig as never as { type: string }).type || "unknown"}`
);
throw new Error(
`Unsupported MCP server type for OpenCode: ${(serverConfig as never as { type: string }).type || "unknown"}`
);
}
opencodeMcpServers[serverName] = {
type: "remote",
url: serverConfig.url,
};
}
// build permission object based on tool permissions // build permission object based on tool permissions
// note: OpenCode has no built-in web search tool // note: OpenCode has no built-in web search tool
@@ -237,7 +212,7 @@ function configureOpenCode(ctx: AgentConfig): void {
log.info(`» OpenCode config written to ${configPath}`); log.info(`» OpenCode config written to ${configPath}`);
log.info( log.info(
`🔧 OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}` `» OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}`
); );
log.debug(`OpenCode config contents:\n${configJson}`); log.debug(`OpenCode config contents:\n${configJson}`);
} }
@@ -396,10 +371,10 @@ let stepHistory: Array<{ stepId: string; stepType: string; toolCalls: string[] }
const messageHandlers = { const messageHandlers = {
init: (event: OpenCodeInitEvent) => { init: (event: OpenCodeInitEvent) => {
// initialization event - reset state // initialization event - reset state
log.info( log.debug(
`🔵 OpenCode init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}` `» OpenCode init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}`
); );
log.info(`🔵 OpenCode init event (full): ${JSON.stringify(event)}`); log.debug(`» OpenCode init event (full): ${JSON.stringify(event)}`);
finalOutput = ""; finalOutput = "";
accumulatedTokens = { input: 0, output: 0 }; accumulatedTokens = { input: 0, output: 0 };
tokensLogged = false; tokensLogged = false;
@@ -410,20 +385,20 @@ const messageHandlers = {
if (message) { if (message) {
if (event.delta) { if (event.delta) {
// delta messages are streaming thoughts/reasoning // delta messages are streaming thoughts/reasoning
log.info( log.debug(
`💭 OpenCode thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}` `» OpenCode thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}`
); );
} else { } else {
// complete messages // complete messages
log.info( log.debug(
`💬 OpenCode message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}` `» OpenCode message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}`
); );
finalOutput = message; finalOutput = message;
} }
} }
} else if (event.role === "user") { } else if (event.role === "user") {
log.info( log.debug(
`💬 OpenCode message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}` `» OpenCode message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}`
); );
} }
}, },
@@ -503,22 +478,22 @@ const messageHandlers = {
const toolDuration = Date.now() - toolStartTime; const toolDuration = Date.now() - toolStartTime;
toolCallTimings.delete(toolId); toolCallTimings.delete(toolId);
const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : ""; const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : "";
log.info( log.debug(
`🔧 OpenCode tool_result${stepContext}: id=${toolId}, status=${status}, duration=${toolDuration}ms` `» OpenCode tool_result${stepContext}: id=${toolId}, status=${status}, duration=${toolDuration}ms`
); );
if (output) { if (output) {
log.debug(` output: ${typeof output === "string" ? output : JSON.stringify(output)}`); log.debug(` output: ${typeof output === "string" ? output : JSON.stringify(output)}`);
} }
if (toolDuration > 5000) { if (toolDuration > 5000) {
log.warning( log.warning(
`⚠️ Tool call took ${(toolDuration / 1000).toFixed(1)}s - this may indicate network latency or slow processing` `» ⚠️ tool call took ${(toolDuration / 1000).toFixed(1)}s - this may indicate network latency or slow processing`
); );
} }
} }
} }
if (status === "error") { if (status === "error") {
const errorMsg = typeof output === "string" ? output : JSON.stringify(output); const errorMsg = typeof output === "string" ? output : JSON.stringify(output);
log.warning(`Tool call failed: ${errorMsg}`); log.error(`» tool call failed: ${errorMsg}`);
} }
}, },
result: async (event: OpenCodeResultEvent) => { result: async (event: OpenCodeResultEvent) => {
@@ -526,19 +501,17 @@ const messageHandlers = {
const duration = event.stats?.duration_ms || 0; const duration = event.stats?.duration_ms || 0;
const toolCalls = event.stats?.tool_calls || 0; const toolCalls = event.stats?.tool_calls || 0;
log.info( log.info(
`🏁 OpenCode result: status=${status}, duration=${duration}ms, tool_calls=${toolCalls}` `» OpenCode result: status=${status}, duration=${duration}ms, tool_calls=${toolCalls}`
); );
if (event.status === "error") { if (event.status === "error") {
log.error(` OpenCode CLI failed: ${JSON.stringify(event)}`); log.error(`» OpenCode CLI failed: ${JSON.stringify(event)}`);
} else { } else {
// log tokens once at the end (use stats from result if available, otherwise use accumulated from step_finish) // log tokens once at the end (use stats from result if available, otherwise use accumulated from step_finish)
const inputTokens = event.stats?.input_tokens || accumulatedTokens.input || 0; const inputTokens = event.stats?.input_tokens || accumulatedTokens.input || 0;
const outputTokens = event.stats?.output_tokens || accumulatedTokens.output || 0; const outputTokens = event.stats?.output_tokens || accumulatedTokens.output || 0;
const totalTokens = event.stats?.total_tokens || inputTokens + outputTokens; const totalTokens = event.stats?.total_tokens || inputTokens + outputTokens;
log.info( log.info(`» run complete: tool_calls=${toolCalls}, duration=${duration}ms`);
`📊 OpenCode final stats: input=${inputTokens}, output=${outputTokens}, total=${totalTokens}, tool_calls=${toolCalls}, duration=${duration}ms`
);
if ((inputTokens > 0 || outputTokens > 0) && !tokensLogged) { if ((inputTokens > 0 || outputTokens > 0) && !tokensLogged) {
log.table([ log.table([
+21 -484
View File
@@ -1,20 +1,6 @@
import { spawnSync } from "node:child_process";
import { chmodSync, createWriteStream, existsSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import type { show } from "@ark/util"; import type { show } from "@ark/util";
import { import { type AgentManifest, type AgentName, agentsManifest, type Effort } from "../external.ts";
type AgentManifest,
type AgentName,
agentsManifest,
type Effort,
type Payload,
} from "../external.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { getGitHubInstallationToken } from "../utils/github.ts";
/** /**
* Result returned by agent execution * Result returned by agent execution
@@ -26,16 +12,6 @@ export interface AgentResult {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }
/**
* Repo info for agent context
*/
export interface RepoInfo {
owner: string;
name: string;
defaultBranch: string;
isPublic: boolean;
}
/** /**
* Tool permission levels * Tool permission levels
*/ */
@@ -53,476 +29,37 @@ export interface ToolPermissions {
} }
/** /**
* Configuration for agent creation * Minimal context passed to agent.run()
*/ */
export interface AgentConfig { export interface AgentRunContext {
apiKey: string;
apiKeys?: Record<string, string>; // all available keys for this agent
payload: Payload;
mcpServers: Record<string, McpHttpServerConfig>;
cliPath: string;
repo: RepoInfo;
effort: Effort; effort: Effort;
tools: ToolPermissions; tools: ToolPermissions;
} mcpServerUrl: string;
tmpdir: string;
/** instructions: string;
* Add agent-specific vars to a whitelisted environment object for agent subprocesses. apiKey: string;
* apiKeys: Record<string, string>;
* @param agentSpecificVars - Object containing agent-specific environment variables to include
* @returns Whitelisted environment object safe for subprocess spawning
*/
export function createAgentEnv(agentSpecificVars: Record<string, string>): Record<string, string> {
const home = agentSpecificVars.HOME || process.env.HOME;
return {
PATH: process.env.PATH,
HOME: home,
// XDG_CONFIG_HOME must match HOME to ensure CLI tools find config files in the right place.
// GitHub Actions sets XDG_CONFIG_HOME to /home/runner/.config which would override $HOME/.config lookup.
XDG_CONFIG_HOME: home ? join(home, ".config") : undefined,
LOG_LEVEL: process.env.LOG_LEVEL,
NODE_ENV: process.env.NODE_ENV,
GITHUB_TOKEN: getGitHubInstallationToken(),
...agentSpecificVars,
// values could be undefined but will be ignored
} as never;
}
/**
* Set up whitelisted environment variables in the current process.
* Used for SDKs that run in the same process (e.g., Claude SDK, Codex SDK).
* Includes agent-agnostic vars (PATH, HOME, LOG_LEVEL, NODE_ENV) plus agent-specific vars.
*
* @param agentSpecificVars - Object containing agent-specific environment variables to include
*/
export function setupProcessAgentEnv(agentSpecificVars: Record<string, string>): void {
Object.assign(process.env, createAgentEnv(agentSpecificVars));
}
/**
* Parameters for installing from npm tarball
*/
export interface InstallFromNpmTarballParams {
packageName: string;
version: string;
executablePath: string;
installDependencies?: boolean;
}
/**
* Parameters for installing from curl script
*/
export interface InstallFromCurlParams {
installUrl: string;
executableName: string;
}
/**
* Parameters for installing from GitHub releases
*/
export interface InstallFromGithubParams {
owner: string;
repo: string;
assetName?: string;
executablePath?: string;
githubInstallationToken?: string;
}
/**
* Parameters for installing from GitHub releases tarball
*/
export interface InstallFromGithubTarballParams {
owner: string;
repo: string;
assetNamePattern: string;
executablePath: string;
githubInstallationToken?: string;
}
/**
* NPM registry response data structure
*/
export interface NpmRegistryData {
"dist-tags": { latest: string };
versions: Record<string, unknown>;
}
/**
* Install a CLI tool from an npm package tarball
* Downloads the tarball, extracts it to a temp directory, and returns the path to the CLI executable
* The temp directory will be cleaned up by the OS automatically
*/
export async function installFromNpmTarball({
packageName,
version,
executablePath,
installDependencies,
}: InstallFromNpmTarballParams): Promise<string> {
// Resolve version if it's a range or "latest"
let resolvedVersion = version;
if (version.startsWith("^") || version.startsWith("~") || version === "latest") {
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
log.debug(`» resolving version for ${version}...`);
try {
const registryResponse = await fetch(`${npmRegistry}/${packageName}`);
if (!registryResponse.ok) {
throw new Error(`Failed to query registry: ${registryResponse.status}`);
}
const registryData = (await registryResponse.json()) as NpmRegistryData;
resolvedVersion = registryData["dist-tags"].latest;
log.debug(`» resolved to version ${resolvedVersion}`);
} catch (error) {
log.warning(
`Failed to resolve version from registry: ${error instanceof Error ? error.message : String(error)}`
);
throw error;
}
}
log.debug(`» installing ${packageName}@${resolvedVersion}...`);
const tempDir = process.env.PULLFROG_TEMP_DIR!;
const tarballPath = join(tempDir, "package.tgz");
// Download tarball from npm
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
// Handle scoped packages (e.g., @scope/package -> @scope%2Fpackage/-/package-version.tgz)
let tarballUrl: string;
if (packageName.startsWith("@")) {
const [scope, name] = packageName.slice(1).split("/");
const scopedPackageName = `@${scope}%2F${name}`;
tarballUrl = `${npmRegistry}/${scopedPackageName}/-/${name}-${resolvedVersion}.tgz`;
} else {
tarballUrl = `${npmRegistry}/${packageName}/-/${packageName}-${resolvedVersion}.tgz`;
}
log.debug(`» downloading from ${tarballUrl}...`);
const response = await fetch(tarballUrl);
if (!response.ok) {
throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`);
}
// Write tarball to file
if (!response.body) throw new Error("Response body is null");
const fileStream = createWriteStream(tarballPath);
await pipeline(response.body, fileStream);
log.debug(`» downloaded tarball to ${tarballPath}`);
// Extract tarball
log.debug(`» extracting tarball...`);
const extractResult = spawnSync("tar", ["-xzf", tarballPath, "-C", tempDir], {
stdio: "pipe",
encoding: "utf-8",
});
if (extractResult.status !== 0) {
throw new Error(
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
);
}
// Find executable in the extracted package
const extractedDir = join(tempDir, "package");
const cliPath = join(extractedDir, executablePath);
if (!existsSync(cliPath)) {
throw new Error(`Executable not found in extracted package at ${cliPath}`);
}
// Install dependencies if requested
if (installDependencies) {
log.debug(`» installing dependencies for ${packageName}...`);
const installResult = spawnSync("npm", ["install", "--production"], {
cwd: extractedDir,
stdio: "pipe",
encoding: "utf-8",
});
if (installResult.status !== 0) {
throw new Error(
`Failed to install dependencies: ${installResult.stderr || installResult.stdout || "Unknown error"}`
);
}
log.debug(`» dependencies installed`);
}
// Make the file executable
chmodSync(cliPath, 0o755);
log.debug(`» ${packageName} installed at ${cliPath}`);
return cliPath;
}
/**
* Fetch with retry logic if Retry-After header is present
*/
async function fetchWithRetry(
url: string,
headers: Record<string, string>,
errorMessage: string
): Promise<Response> {
const response = await fetch(url, { headers });
if (!response.ok) {
const retryAfter = response.headers.get("Retry-After") || response.headers.get("retry-after");
if (retryAfter) {
const waitSeconds = parseInt(retryAfter, 10);
if (!Number.isNaN(waitSeconds) && waitSeconds > 0) {
log.info(`Rate limited, waiting ${waitSeconds} seconds before retry...`);
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
const retryResponse = await fetch(url, { headers });
if (!retryResponse.ok) {
throw new Error(
`${errorMessage}: ${retryResponse.status} ${retryResponse.statusText} (retry failed)`
);
}
return retryResponse;
}
}
throw new Error(`${errorMessage}: ${response.status} ${response.statusText}`);
}
return response;
}
/**
* Install a CLI tool from GitHub releases
* Downloads the latest release asset from GitHub and returns the path to the executable
* The temp directory will be cleaned up by the OS automatically
*/
export async function installFromGithub({
owner,
repo,
assetName,
executablePath,
githubInstallationToken,
}: InstallFromGithubParams): Promise<string> {
log.info(`📦 Installing ${owner}/${repo} from GitHub releases...`);
// fetch release from GitHub API (latest)
const releaseUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
log.info(`Fetching release from ${releaseUrl}...`);
const headers: Record<string, string> = {};
if (githubInstallationToken) {
headers.Authorization = `Bearer ${githubInstallationToken}`;
}
const releaseResponse = await fetchWithRetry(releaseUrl, headers, "Failed to fetch release");
const releaseData = (await releaseResponse.json()) as {
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
}>;
};
log.info(`Found release: ${releaseData.tag_name}`);
const asset = releaseData.assets.find((a) => a.name === assetName);
if (!asset) {
throw new Error(`Asset '${assetName}' not found in release ${releaseData.tag_name}`);
}
const assetUrl = asset.browser_download_url;
log.info(`Downloading asset from ${assetUrl}...`);
// create temp directory
const tempDirPrefix = `${owner}-${repo}-github-`;
const tempDir = await mkdtemp(join(tmpdir(), tempDirPrefix));
// determine file extension and download path
const urlPath = new URL(assetUrl).pathname;
const fileName = urlPath.split("/").pop() || "asset";
const downloadPath = join(tempDir, fileName);
// download the asset
const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset");
if (!assetResponse.body) throw new Error("Response body is null");
const fileStream = createWriteStream(downloadPath);
await pipeline(assetResponse.body, fileStream);
log.info(`Downloaded asset to ${downloadPath}`);
// determine the executable path
let cliPath: string;
if (executablePath) {
cliPath = join(tempDir, executablePath);
} else {
// no executablePath, assume the downloaded file is the executable
cliPath = downloadPath;
}
if (!existsSync(cliPath)) {
throw new Error(`Executable not found at ${cliPath}`);
}
chmodSync(cliPath, 0o755);
log.info(`✓ Installed from GitHub release at ${cliPath}`);
return cliPath;
}
/**
* Install a CLI tool from a GitHub release tarball
* Downloads the tar.gz from GitHub releases, extracts it, and returns the path to the CLI executable
* The temp directory will be cleaned up by the OS automatically
*/
export async function installFromGithubTarball({
owner,
repo,
assetNamePattern,
executablePath,
githubInstallationToken,
}: InstallFromGithubTarballParams): Promise<string> {
log.info(`📦 Installing ${owner}/${repo} from GitHub releases...`);
// determine platform-specific asset name
const os = process.platform === "darwin" ? "darwin" : "linux";
const arch = process.arch === "arm64" ? "arm64" : "x64";
const assetName = assetNamePattern.replace("{os}", os).replace("{arch}", arch);
// fetch release from GitHub API (latest)
const releaseUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
log.info(`Fetching release from ${releaseUrl}...`);
const headers: Record<string, string> = {};
if (githubInstallationToken) {
headers.Authorization = `Bearer ${githubInstallationToken}`;
}
const releaseResponse = await fetchWithRetry(releaseUrl, headers, "Failed to fetch release");
const releaseData = (await releaseResponse.json()) as {
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
}>;
};
log.info(`Found release: ${releaseData.tag_name}`);
const asset = releaseData.assets.find((a) => a.name === assetName);
if (!asset) {
throw new Error(`Asset '${assetName}' not found in release ${releaseData.tag_name}`);
}
const assetUrl = asset.browser_download_url;
log.info(`Downloading asset from ${assetUrl}...`);
const tempDir = process.env.PULLFROG_TEMP_DIR!;
const tarballPath = join(tempDir, assetName);
// download the asset
const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset");
if (!assetResponse.body) throw new Error("Response body is null");
const fileStream = createWriteStream(tarballPath);
await pipeline(assetResponse.body, fileStream);
log.info(`Downloaded tarball to ${tarballPath}`);
// extract tar.gz
log.info(`Extracting tarball...`);
const extractResult = spawnSync("tar", ["-xzf", tarballPath, "-C", tempDir], {
stdio: "pipe",
encoding: "utf-8",
});
if (extractResult.status !== 0) {
throw new Error(
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
);
}
// find executable in the extracted tarball
const cliPath = join(tempDir, executablePath);
if (!existsSync(cliPath)) {
throw new Error(`Executable not found in extracted tarball at ${cliPath}`);
}
// make the file executable
chmodSync(cliPath, 0o755);
log.info(`${owner}/${repo} installed at ${cliPath}`);
return cliPath;
}
/**
* Install a CLI tool from a curl-based install script
* Downloads the install script, runs it with HOME set to temp directory, and returns the path to the CLI executable
* The temp directory will be cleaned up by the OS automatically
*/
export async function installFromCurl({
installUrl,
executableName,
}: InstallFromCurlParams): Promise<string> {
log.info(`📦 Installing ${executableName}...`);
const tempDir = process.env.PULLFROG_TEMP_DIR!;
const installScriptPath = join(tempDir, "install.sh");
// Download the install script
log.info(`Downloading install script from ${installUrl}...`);
const installScriptResponse = await fetch(installUrl);
if (!installScriptResponse.ok) {
throw new Error(`Failed to download install script: ${installScriptResponse.status}`);
}
if (!installScriptResponse.body) throw new Error("Response body is null");
const fileStream = createWriteStream(installScriptPath);
await pipeline(installScriptResponse.body, fileStream);
log.info(`Downloaded install script to ${installScriptPath}`);
// Make install script executable
chmodSync(installScriptPath, 0o755);
log.info(`Installing to temp directory at ${tempDir}...`);
const installResult = spawnSync("bash", [installScriptPath], {
cwd: tempDir,
env: {
// Run the install script with HOME set to temp directory
// ensuring a fresh install for each run
HOME: tempDir,
// XDG_CONFIG_HOME must match HOME so CLI tools find config in the right place
XDG_CONFIG_HOME: join(tempDir, ".config"),
SHELL: process.env.SHELL,
USER: process.env.USER,
},
stdio: "pipe",
encoding: "utf-8",
});
if (installResult.status !== 0) {
const errorOutput = installResult.stderr || installResult.stdout || "No output";
throw new Error(
`Failed to install ${executableName}. Install script exited with code ${installResult.status}. Output: ${errorOutput}`
);
}
// The Cursor install script creates a symlink at $HOME/.local/bin/{executableName}
// Since we set HOME=tempDir, the deterministic path is:
const cliPath = join(tempDir, ".local", "bin", executableName);
if (!existsSync(cliPath)) {
throw new Error(`Executable not found at ${cliPath}`);
}
// Ensure binary is executable
chmodSync(cliPath, 0o755);
log.info(`${executableName} installed at ${cliPath}`);
return cliPath;
} }
export const agent = <const input extends AgentInput>(input: input): defineAgent<input> => { export const agent = <const input extends AgentInput>(input: input): defineAgent<input> => {
return { ...input, ...agentsManifest[input.name] } as never; return {
...input,
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
log.info(`» running ${input.name} with effort=${ctx.effort}...`);
log.box(ctx.instructions, { title: "Instructions" });
log.info(
`» tool permissions: web=${ctx.tools.web}, search=${ctx.tools.search}, write=${ctx.tools.write}, bash=${ctx.tools.bash}`
);
return input.run(ctx);
},
...agentsManifest[input.name],
} as never;
}; };
export interface AgentInput { export interface AgentInput {
name: AgentName; name: AgentName;
install: (token?: string) => Promise<string>; install: (token?: string) => Promise<string>;
run: (config: AgentConfig) => Promise<AgentResult>; run: (ctx: AgentRunContext) => Promise<AgentResult>;
} }
export interface Agent extends AgentInput, AgentManifest {} export interface Agent extends AgentInput, AgentManifest {}
-19
View File
@@ -1,19 +0,0 @@
name: "Pullfrog Action (Dispatch)"
description: "Execute coding agents with JSON payload input"
author: "Pullfrog"
inputs:
payload:
description: "JSON payload containing prompt, event, and other configuration"
required: true
cwd:
description: "Working directory for the agent (defaults to GITHUB_WORKSPACE)"
required: false
runs:
using: "node24"
main: "entry"
branding:
icon: "code"
color: "green"
-138798
View File
File diff suppressed because one or more lines are too long
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env node
/**
* entry point for pullfrog/pullfrog/dispatch - JSON payload input for internal use
*/
import * as core from "@actions/core";
import { Inputs, main } from "../main.ts";
async function run(): Promise<void> {
try {
const payloadStr = core.getInput("payload", { required: true });
// parse JSON payload
let payload: unknown;
try {
payload = JSON.parse(payloadStr);
} catch {
throw new Error(`failed to parse payload as JSON: ${payloadStr.slice(0, 100)}...`);
}
// validate and convert to Inputs
if (typeof payload !== "object" || payload === null) {
throw new Error("payload must be a JSON object");
}
const payloadObj = payload as Record<string, unknown>;
// build inputs from payload fields
const inputs = Inputs.assert({
prompt: payloadObj.prompt,
effort: payloadObj.effort,
agent: payloadObj.agent,
event: payloadObj.event,
modes: payloadObj.modes,
// granular tool permissions
web: payloadObj.web,
search: payloadObj.search,
write: payloadObj.write,
bash: payloadObj.bash,
disableProgressComment: payloadObj.disableProgressComment,
comment_id: payloadObj.comment_id,
issue_id: payloadObj.issue_id,
pr_id: payloadObj.pr_id,
cwd: core.getInput("cwd") || null,
});
const result = await main(inputs);
if (!result.success) {
throw new Error(result.error || "Agent execution failed");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
core.setFailed(`Action failed: ${errorMessage}`);
}
}
await run();
+26675 -26820
View File
File diff suppressed because it is too large Load Diff
+3 -9
View File
@@ -1,21 +1,15 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* entry point for pullfrog/pullfrog - main action * entry point for pullfrog/pullfrog - unified action
*/ */
import * as core from "@actions/core"; import * as core from "@actions/core";
import { Inputs, main } from "./main.ts"; import { main } from "./main.ts";
async function run(): Promise<void> { async function run(): Promise<void> {
try { try {
const inputs = Inputs.assert({ const result = await main(core);
prompt: core.getInput("prompt", { required: true }),
effort: core.getInput("effort") || "auto",
cwd: core.getInput("cwd") || null,
});
const result = await main(inputs);
if (!result.success) { if (!result.success) {
throw new Error(result.error || "Agent execution failed"); throw new Error(result.error || "Agent execution failed");
+1 -17
View File
@@ -75,20 +75,4 @@ await build({
plugins: [stripShebangPlugin], plugins: [stripShebangPlugin],
}); });
// Build the run sub-action console.log("» build completed successfully");
await build({
...sharedConfig,
entryPoints: ["./run/entry.ts"],
outfile: "./run/entry",
plugins: [stripShebangPlugin],
});
// Build the dispatch sub-action
await build({
...sharedConfig,
entryPoints: ["./dispatch/entry.ts"],
outfile: "./dispatch/entry",
plugins: [stripShebangPlugin],
});
console.log("✅ Build completed successfully!");
+30 -55
View File
@@ -1,17 +1,17 @@
/** /**
* ⚠️ NO IMPORTS except modes.ts - this file is imported by Next.js and must avoid pulling in backend code. * ⚠️ LIMITED IMPORTS - this file is imported by Next.js and must avoid pulling in backend code.
* All shared constants, types, and data used by both the Next.js app and the action runtime live here. * All shared constants, types, and data used by both the Next.js app and the action runtime live here.
* Other files in action/ re-export from this file for backward compatibility. * Other files in action/ re-export from this file for backward compatibility.
*/ */
import { type } from "arktype"; import { type } from "arktype";
import type { Mode } from "./modes.ts";
// mcp name constant // mcp name constant
export const ghPullfrogMcpName = "gh_pullfrog"; export const ghPullfrogMcpName = "gh_pullfrog";
export interface AgentManifest { export interface AgentManifest {
displayName: string; displayName: string;
/** empty array means accepts any *API_KEY* env var */
apiKeyNames: string[]; apiKeyNames: string[];
url: string; url: string;
} }
@@ -40,7 +40,7 @@ export const agentsManifest = {
}, },
opencode: { opencode: {
displayName: "OpenCode", displayName: "OpenCode",
apiKeyNames: [], // empty array means OpenCode accepts any API_KEY from environment apiKeyNames: [],
url: "https://opencode.ai", url: "https://opencode.ai",
}, },
} as const satisfies Record<string, AgentManifest>; } as const satisfies Record<string, AgentManifest>;
@@ -256,60 +256,35 @@ export type PayloadEvent =
| ImplementPlanEvent | ImplementPlanEvent
| UnknownEvent; | UnknownEvent;
// | undefined needed on optional props for exactOptionalPropertyTypes
export interface DispatchOptions { export interface DispatchOptions {
/** /** when true, disables progress comment (no "leaping into action" comment, no report_progress tool) */
* When true, disables progress comment (no "leaping into action" comment, no report_progress tool) disableProgressComment?: true | undefined;
*/ /** granular tool permissions set server-side for dispatch workflows */
readonly disableProgressComment?: true; web?: ToolPermission | undefined;
search?: ToolPermission | undefined;
/** write?: ToolPermission | undefined;
* Granular tool permissions set server-side for dispatch workflows. bash?: BashPermission | undefined;
*/
readonly web?: ToolPermission;
readonly search?: ToolPermission;
readonly write?: ToolPermission;
readonly bash?: BashPermission;
} }
export type MutableDispatchOptions = { // writeable payload type for building payloads
-readonly [K in keyof DispatchOptions]: DispatchOptions[K]; export interface WriteablePayload extends DispatchOptions {
};
// payload type for agent execution
export interface Payload extends DispatchOptions {
"~pullfrog": true; "~pullfrog": true;
/** agent slug identifier (e.g., "claude", "codex", "gemini") */
/** agent: AgentName | null;
* Agent slug identifier (e.g., "claude", "codex", "gemini") /** the prompt/instructions for the agent to execute */
*/ prompt: string;
readonly agent: AgentName | null; /** event data from webhook payload - discriminated union based on trigger field */
event: PayloadEvent;
/** /** effort level for model selection (mini, auto, max) - defaults to "auto" */
* The prompt/instructions for the agent to execute effort?: Effort | undefined;
*/ /** optional IDs of the issue, PR, or comment that the agent is working on */
readonly prompt: string; comment_id?: number | null | undefined;
issue_id?: number | null | undefined;
/** pr_id?: number | null | undefined;
* Event data from webhook payload. /** working directory for the agent */
* Discriminated union based on trigger field. cwd?: string | null | undefined;
*/
readonly event: PayloadEvent;
/**
* Execution mode configuration
*/
modes: readonly Mode[];
/**
* Effort level for model selection (mini, auto, max)
* Defaults to "auto" if not specified
*/
readonly effort?: Effort;
/**
* Optional IDs of the issue, PR, or comment that the agent is working on
*/
readonly comment_id?: number | null;
readonly issue_id?: number | null;
readonly pr_id?: number | null;
} }
// immutable payload type for agent execution
export type Payload = Readonly<WriteablePayload>;
-1
View File
@@ -31,5 +31,4 @@ This tests that you can execute shell commands properly.`,
event: { event: {
trigger: "workflow_dispatch", trigger: "workflow_dispatch",
}, },
modes: [],
} satisfies Payload; } satisfies Payload;
-1
View File
@@ -8,5 +8,4 @@ export default {
event: { event: {
trigger: "workflow_dispatch", trigger: "workflow_dispatch",
}, },
modes: [],
} satisfies Payload; } satisfies Payload;
+1 -1
View File
@@ -1 +1 @@
Find all markdown files in the repository and list their names from https://github.com/ShawnMorreau/cal.com/ Tell me a joke.
+34 -37
View File
@@ -25499,11 +25499,10 @@ var require_src = __commonJS({
}); });
// get-installation-token/entry.ts // get-installation-token/entry.ts
var core3 = __toESM(require_core(), 1); var core4 = __toESM(require_core(), 1);
// utils/github.ts // utils/token.ts
var core2 = __toESM(require_core(), 1); var core3 = __toESM(require_core(), 1);
import { createSign } from "node:crypto";
// utils/log.ts // utils/log.ts
var core = __toESM(require_core(), 1); var core = __toESM(require_core(), 1);
@@ -25635,7 +25634,7 @@ var log = {
}, },
/** Print success message */ /** Print success message */
success: (...args) => { success: (...args) => {
core.info(`\u2705 ${formatArgs(args)}`); core.info(`\xBB ${formatArgs(args)}`);
}, },
/** Print debug message (only if LOG_LEVEL=debug) */ /** Print debug message (only if LOG_LEVEL=debug) */
debug: (...args) => { debug: (...args) => {
@@ -25668,6 +25667,10 @@ function formatJsonValue(value) {
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value, null, 2) : compact; return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value, null, 2) : compact;
} }
// utils/github.ts
var core2 = __toESM(require_core(), 1);
import { createSign } from "node:crypto";
// utils/retry.ts // utils/retry.ts
var defaultShouldRetry = (error2) => { var defaultShouldRetry = (error2) => {
if (!(error2 instanceof Error)) return false; if (!(error2 instanceof Error)) return false;
@@ -25844,6 +25847,19 @@ async function acquireNewToken(opts) {
return await acquireTokenViaGitHubApp(); return await acquireTokenViaGitHubApp();
} }
} }
function parseRepoContext() {
const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo) {
throw new Error("GITHUB_REPOSITORY environment variable is required");
}
const [owner, name] = githubRepo.split("/");
if (!owner || !name) {
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
}
return { owner, name };
}
// utils/token.ts
async function revokeGitHubInstallationToken(token) { async function revokeGitHubInstallationToken(token) {
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com"; const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
try { try {
@@ -25862,52 +25878,33 @@ async function revokeGitHubInstallationToken(token) {
); );
} }
} }
function parseRepoContext() {
const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo) {
throw new Error("GITHUB_REPOSITORY environment variable is required");
}
const [owner, name] = githubRepo.split("/");
if (!owner || !name) {
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
}
return { owner, name };
}
// get-installation-token/token.ts
async function acquireInstallationToken(opts) {
return acquireNewToken(opts);
}
async function revokeInstallationToken(token) {
return revokeGitHubInstallationToken(token);
}
// get-installation-token/entry.ts // get-installation-token/entry.ts
var STATE_TOKEN = "token"; var STATE_TOKEN = "token";
var STATE_IS_POST = "isPost"; var STATE_IS_POST = "isPost";
async function main() { async function main() {
core3.saveState(STATE_IS_POST, "true"); core4.saveState(STATE_IS_POST, "true");
const reposInput = core3.getInput("repos"); const reposInput = core4.getInput("repos");
const additionalRepos = reposInput ? reposInput.split(",").map((r) => r.trim()).filter(Boolean) : []; const additionalRepos = reposInput ? reposInput.split(",").map((r) => r.trim()).filter(Boolean) : [];
const token = await acquireInstallationToken({ repos: additionalRepos }); const token = await acquireNewToken({ repos: additionalRepos });
core3.setSecret(token); core4.setSecret(token);
core3.saveState(STATE_TOKEN, token); core4.saveState(STATE_TOKEN, token);
core3.setOutput("token", token); core4.setOutput("token", token);
const scope = additionalRepos.length ? `current repo + ${additionalRepos.join(", ")}` : "current repo only"; const scope = additionalRepos.length ? `current repo + ${additionalRepos.join(", ")}` : "current repo only";
core3.info(`\xBB installation token acquired (${scope})`); core4.info(`\xBB installation token acquired (${scope})`);
} }
async function post() { async function post() {
const token = core3.getState(STATE_TOKEN); const token = core4.getState(STATE_TOKEN);
if (!token) { if (!token) {
core3.debug("no token found in state, skipping revocation"); core4.debug("no token found in state, skipping revocation");
return; return;
} }
await revokeInstallationToken(token); await revokeGitHubInstallationToken(token);
core3.info("\xBB installation token revoked"); core4.info("\xBB installation token revoked");
} }
async function run() { async function run() {
try { try {
const isPost = core3.getState(STATE_IS_POST) === "true"; const isPost = core4.getState(STATE_IS_POST) === "true";
if (isPost) { if (isPost) {
await post(); await post();
} else { } else {
@@ -25915,7 +25912,7 @@ async function run() {
} }
} catch (error2) { } catch (error2) {
const message = error2 instanceof Error ? error2.message : String(error2); const message = error2 instanceof Error ? error2.message : String(error2);
core3.setFailed(message); core4.setFailed(message);
} }
} }
await run(); await run();
+1 -1
View File
@@ -6,7 +6,7 @@
*/ */
import * as core from "@actions/core"; import * as core from "@actions/core";
import { acquireInstallationToken, revokeInstallationToken } from "./token.ts"; import { acquireInstallationToken, revokeInstallationToken } from "../utils/token.ts";
const STATE_TOKEN = "token"; const STATE_TOKEN = "token";
const STATE_IS_POST = "isPost"; const STATE_IS_POST = "isPost";
-14
View File
@@ -1,14 +0,0 @@
/**
* token acquisition and revocation for get-installation-token action.
* reuses the existing github.ts utilities.
*/
import { acquireNewToken, revokeGitHubInstallationToken } from "../utils/github.ts";
export async function acquireInstallationToken(opts?: { repos?: string[] }): Promise<string> {
return acquireNewToken(opts);
}
export async function revokeInstallationToken(token: string): Promise<void> {
return revokeGitHubInstallationToken(token);
}
+1 -1
View File
@@ -3,7 +3,7 @@
* This exports the main function for programmatic usage * This exports the main function for programmatic usage
*/ */
export type { Agent, AgentConfig, AgentResult } from "./agents/shared.ts"; export type { Agent, AgentRunContext, AgentResult } from "./agents/shared.ts";
export { export {
type Inputs as ExecutionInputs, type Inputs as ExecutionInputs,
type MainResult, type MainResult,
+81 -531
View File
@@ -1,49 +1,20 @@
import { mkdtemp } from "node:fs/promises"; import { ensureProgressCommentUpdated } from "./mcp/comment.ts";
import { tmpdir } from "node:os"; import { startMcpHttpServer, type ToolContext, type ToolState } from "./mcp/server.ts";
import { isAbsolute, join, resolve } from "node:path"; import { computeModes } from "./modes.ts";
import { flatMorph } from "@ark/util"; import { validateApiKey } from "./utils/apiKeys.ts";
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, ToolPermissions } 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 { log } from "./utils/cli.ts";
import { reportErrorToComment } from "./utils/errorReport.ts"; import { reportErrorToComment } from "./utils/errorReport.ts";
import { createOctokit, parseRepoContext, setupGitHubInstallationToken } from "./utils/github.ts"; import { resolveInstructions } from "./utils/instructions.ts";
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts"; import { resolvePayload } from "./utils/payload.ts";
import { resolveRepoData } from "./utils/repoData.ts";
import { resolveAgent } from "./utils/resolveAgent.ts";
import { handleAgentResult, resolvePermissions } from "./utils/run.ts";
import { createTempDirectory, setupGit } from "./utils/setup.ts";
import { Timer } from "./utils/timer.ts"; import { Timer } from "./utils/timer.ts";
import { resolveInstallationToken } from "./utils/token.ts";
import { resolveRunId } from "./utils/workflow.ts";
// tool permission enum types for inputs export { Inputs } from "./utils/payload.ts";
const ToolPermissionInput = type.enumerated("disabled", "enabled");
const BashPermissionInput = type.enumerated("disabled", "restricted", "enabled");
// 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(),
"web?": ToolPermissionInput,
"search?": ToolPermissionInput,
"write?": ToolPermissionInput,
"bash?": BashPermissionInput,
"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 { export interface MainResult {
success: boolean; success: boolean;
@@ -51,163 +22,93 @@ export interface MainResult {
error?: string | undefined; error?: string | undefined;
} }
// intermediate result types for deterministic context building export async function main(core: {
interface GitHubSetup { getInput: (name: string, options?: { required?: boolean }) => string;
owner: string; }): Promise<MainResult> {
name: string; // store original GITHUB_TOKEN
octokit: Octokit; process.env.ORIGINAL_GITHUB_TOKEN = process.env.GITHUB_TOKEN;
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
repoSettings: RepoSettings;
}
type ApiKeySetup = const payload = resolvePayload(core);
| { success: true; apiKey: string; apiKeys: Record<string, string> } if (payload.cwd && process.cwd() !== payload.cwd) {
| { success: false; error: string }; process.chdir(payload.cwd);
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(); const timer = new Timer();
// `await using` ensures the token is automatically revoked when the function exits await using tokenRef = await resolveInstallationToken();
await using tokenRef = await setupGitHubInstallationToken(); process.env.GITHUB_TOKEN = tokenRef.token;
let payload: Payload | undefined;
try { try {
// phase 1: parse and validate inputs const repoData = await resolveRepoData(tokenRef.token);
payload = parsePayload(inputs); const tmpdir = await createTempDirectory();
Inputs.assert(inputs); timer.checkpoint("repoData");
setupGitConfig();
// phase 2: fast setup (github + temp dir) const agent = resolveAgent({ payload, repoSettings: repoData.repoSettings });
const [githubSetup, sharedTempDir] = await Promise.all([
initializeGitHub(tokenRef.token),
createTempDirectory(),
]);
timer.checkpoint("githubSetup");
// phase 3: resolve agent (needs repo settings) const { apiKey, apiKeys } = validateApiKey({
const agent = resolveAgent({ agent,
owner: repoData.owner,
name: repoData.name,
});
// compute tool permissions early
const tools = resolvePermissions({
payload, payload,
repoSettings: githubSetup.repoSettings, isPublicRepo: !repoData.repo.private,
}); });
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 toolState: ToolState = {};
const [cliPath] = await Promise.all([ await setupGit({
installAgentCli({ agent, token: tokenRef.token }), token: tokenRef.token,
setupGitAuth({ owner: repoData.owner,
token: tokenRef.token, name: repoData.name,
owner: githubSetup.owner, event: payload.event,
name: githubSetup.name, octokit: repoData.octokit,
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, toolState,
});
timer.checkpoint("git");
const modes = [
...computeModes({ disableProgressComment: payload.disableProgressComment }),
...repoData.repoSettings.modes,
];
const { runId, jobId } = await resolveRunId(repoData);
const toolContext: ToolContext = {
owner: repoData.owner,
name: repoData.name,
repo: { default_branch: repoData.repo.default_branch, private: repoData.repo.private },
githubInstallationToken: tokenRef.token,
octokit: repoData.octokit,
agent, agent,
sharedTempDir, event: payload.event,
disableProgressComment: payload.disableProgressComment,
modes,
toolState,
runId, runId,
jobId, jobId,
}; };
await using mcpHttpServer = await startMcpHttpServer(toolContext); await using mcpHttpServer = await startMcpHttpServer(toolContext);
log.info(`🚀 MCP server started at ${mcpHttpServer.url}`); 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"); timer.checkpoint("mcpServer");
// BUILD FINAL IMMUTABLE CONTEXT const instructions = resolveInstructions({
const ctx: AgentContext = { prompt: payload.prompt,
...toolContext, event: payload.event,
inputs, repoData,
modes,
bash: tools.bash,
});
const result = await agent.run({
effort: payload.effort,
tools,
mcpServerUrl: mcpHttpServer.url, mcpServerUrl: mcpHttpServer.url,
mcpServers, tmpdir,
cliPath, instructions,
apiKey: apiKeySetup.apiKey, apiKey,
apiKeys: apiKeySetup.apiKeys, 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); const mainResult = await handleAgentResult(result);
return mainResult; return mainResult;
} catch (error) { } catch (error) {
@@ -226,362 +127,11 @@ export async function main(inputs: Inputs): Promise<MainResult> {
// ensure progress comment is updated if it was never updated during execution // 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 // do this before revoking the token so we can still make API calls
try { try {
await ensureProgressCommentUpdated(payload); await ensureProgressCommentUpdated({
disableProgressComment: payload.disableProgressComment,
});
} catch { } catch {
// error updating comment, but don't let it mask the original error // 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",
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",
} 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,
};
}
/**
* Compute tool permissions from inputs.
* For run action, bash defaults to restricted for public repos when unset.
*/
function computeToolPermissions(params: {
inputs: Inputs;
isPublicRepo: boolean;
}): ToolPermissions {
return {
web: params.inputs.web ?? "enabled",
search: params.inputs.search ?? "enabled",
write: params.inputs.write ?? "enabled",
bash: params.inputs.bash ?? (params.isPublicRepo ? "restricted" : "enabled"),
};
}
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" });
const tools = computeToolPermissions({ inputs: ctx.inputs, isPublicRepo: !ctx.repo.private });
log.info(
`Tool permissions: web=${tools.web}, search=${tools.search}, write=${tools.write}, bash=${tools.bash}`
);
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,
tools,
});
}
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 || "",
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { type ChildProcess, spawn } from "node:child_process"; import { type ChildProcess, spawn } from "node:child_process";
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const BashParams = type({ export const BashParams = type({
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const GetCheckSuiteLogs = type({ export const GetCheckSuiteLogs = type({
+10 -10
View File
@@ -2,9 +2,9 @@ import { writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest"; import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number]; type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
@@ -118,7 +118,7 @@ export async function checkoutPrBranch(
params: CheckoutPrBranchParams params: CheckoutPrBranchParams
): Promise<CheckoutPrBranchResult> { ): Promise<CheckoutPrBranchResult> {
const { octokit, owner, name, token, pullNumber } = params; const { octokit, owner, name, token, pullNumber } = params;
log.info(`🔀 checking out PR #${pullNumber}...`); log.info(`» checking out PR #${pullNumber}...`);
// fetch PR metadata // fetch PR metadata
const pr = await octokit.rest.pulls.get({ const pr = await octokit.rest.pulls.get({
@@ -149,7 +149,7 @@ export async function checkoutPrBranch(
log.debug(`already on PR branch ${localBranch}, skipping checkout`); log.debug(`already on PR branch ${localBranch}, skipping checkout`);
} else { } else {
// fetch base branch so origin/<base> exists for diff operations // fetch base branch so origin/<base> exists for diff operations
log.debug(`📥 fetching base branch (${baseBranch})...`); log.debug(`» fetching base branch (${baseBranch})...`);
$("git", ["fetch", "--no-tags", "origin", baseBranch]); $("git", ["fetch", "--no-tags", "origin", baseBranch]);
// checkout base branch first to avoid "refusing to fetch into current branch" error // checkout base branch first to avoid "refusing to fetch into current branch" error
@@ -157,18 +157,18 @@ export async function checkoutPrBranch(
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]); $("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs) // fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
log.debug(`🌿 fetching PR #${pullNumber} (${localBranch})...`); log.debug(`» fetching PR #${pullNumber} (${localBranch})...`);
$("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`]); $("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`]);
// checkout the branch // checkout the branch
$("git", ["checkout", localBranch]); $("git", ["checkout", localBranch]);
log.debug(` checked out PR #${pullNumber}`); log.debug(`» checked out PR #${pullNumber}`);
} }
// ensure base branch is fetched (needed for diff operations) // ensure base branch is fetched (needed for diff operations)
// fetch if we skipped checkout (already on branch) - otherwise already fetched above // fetch if we skipped checkout (already on branch) - otherwise already fetched above
if (alreadyOnBranch) { if (alreadyOnBranch) {
log.debug(`📥 fetching base branch (${baseBranch})...`); log.debug(`» fetching base branch (${baseBranch})...`);
$("git", ["fetch", "--no-tags", "origin", baseBranch]); $("git", ["fetch", "--no-tags", "origin", baseBranch]);
} }
@@ -182,23 +182,23 @@ export async function checkoutPrBranch(
// add fork as a named remote (suppress logging to avoid "error: remote already exists" spam) // add fork as a named remote (suppress logging to avoid "error: remote already exists" spam)
try { try {
$("git", ["remote", "add", remoteName, forkUrl], { log: false }); $("git", ["remote", "add", remoteName, forkUrl], { log: false });
log.debug(`📌 added remote '${remoteName}' for fork ${headRepo.full_name}`); log.debug(`» added remote '${remoteName}' for fork ${headRepo.full_name}`);
} catch { } catch {
// remote already exists, update its URL // remote already exists, update its URL
$("git", ["remote", "set-url", remoteName, forkUrl], { log: false }); $("git", ["remote", "set-url", remoteName, forkUrl], { log: false });
log.debug(`📌 updated remote '${remoteName}' for fork ${headRepo.full_name}`); log.debug(`» updated remote '${remoteName}' for fork ${headRepo.full_name}`);
} }
// set branch push config so `git push` knows where to push // set branch push config so `git push` knows where to push
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName]); $("git", ["config", `branch.${localBranch}.pushRemote`, remoteName]);
// set merge ref so git knows the remote branch name (may differ from local) // set merge ref so git knows the remote branch name (may differ from local)
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`]); $("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`]);
log.debug(`📌 configured branch '${localBranch}' to push to '${remoteName}/${headBranch}'`); log.debug(`» configured branch '${localBranch}' to push to '${remoteName}/${headBranch}'`);
// warn if maintainer can't modify (push will likely fail) // warn if maintainer can't modify (push will likely fail)
if (!pr.data.maintainer_can_modify) { if (!pr.data.maintainer_can_modify) {
log.warning( log.warning(
`⚠️ fork PR has maintainer_can_modify=false - push operations will fail. ` + `» fork PR has maintainer_can_modify=false - push operations will fail. ` +
`ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.` `ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.`
); );
} }
+37 -34
View File
@@ -1,16 +1,11 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Payload } from "../external.ts"; import type { Agent } from "../agents/index.ts";
import { agentsManifest } from "../external.ts";
import type { ToolContext } from "../main.ts";
import { fetchWorkflowRunInfo } from "../utils/api.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { writeSummary } from "../utils/cli.ts"; import { writeSummary } from "../utils/cli.ts";
import { import { createOctokit, type OctokitWithPlugins, parseRepoContext } from "../utils/github.ts";
createOctokit, import { getGitHubInstallationToken } from "../utils/token.ts";
getGitHubInstallationToken, import { fetchWorkflowRunInfo } from "../utils/workflowRun.ts";
type OctokitWithPlugins, import type { ToolContext } from "./server.ts";
parseRepoContext,
} from "../utils/github.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
/** /**
@@ -21,22 +16,19 @@ import { execute, tool } from "./shared.ts";
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action"; export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
interface BuildCommentFooterParams { interface BuildCommentFooterParams {
payload: Payload; agent: Agent | undefined;
octokit?: OctokitWithPlugins | undefined; octokit?: OctokitWithPlugins | undefined;
customParts?: string[] | undefined; customParts?: string[] | undefined;
} }
async function buildCommentFooter({ async function buildCommentFooter({
payload, agent,
octokit, octokit,
customParts, customParts,
}: BuildCommentFooterParams): Promise<string> { }: BuildCommentFooterParams): Promise<string> {
const repoContext = parseRepoContext(); const repoContext = parseRepoContext();
const runId = process.env.GITHUB_RUN_ID; const runId = process.env.GITHUB_RUN_ID;
const agentName = payload.agent;
const agentInfo = agentName ? agentsManifest[agentName] : null;
let workflowRunHtmlUrl: string | undefined; let workflowRunHtmlUrl: string | undefined;
if (runId && octokit) { if (runId && octokit) {
try { try {
@@ -56,8 +48,8 @@ async function buildCommentFooter({
const footerParams = { const footerParams = {
triggeredBy: true, triggeredBy: true,
agent: { agent: {
displayName: agentInfo?.displayName || "Unknown agent", displayName: agent?.displayName || "Unknown agent",
url: agentInfo?.url || "https://pullfrog.com", url: agent?.url || "https://pullfrog.com",
}, },
workflowRun: runId workflowRun: runId
? { ? {
@@ -88,13 +80,14 @@ function buildImplementPlanLink(
return `[Implement plan ➔](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`; return `[Implement plan ➔](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`;
} }
async function addFooter( interface AddFooterCtx {
body: string, agent?: Agent | undefined;
payload: Payload, octokit?: OctokitWithPlugins | undefined;
octokit?: OctokitWithPlugins }
): Promise<string> {
async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
const bodyWithoutFooter = stripExistingFooter(body); const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({ payload, octokit }); const footer = await buildCommentFooter({ agent: ctx.agent, octokit: ctx.octokit });
return `${bodyWithoutFooter}${footer}`; return `${bodyWithoutFooter}${footer}`;
} }
@@ -110,7 +103,7 @@ export function CreateCommentTool(ctx: ToolContext) {
"Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.", "Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.",
parameters: Comment, parameters: Comment,
execute: execute(async ({ issueNumber, body }) => { execute: execute(async ({ issueNumber, body }) => {
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit); const bodyWithFooter = await addFooter(ctx, body);
const result = await ctx.octokit.rest.issues.createComment({ const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.owner, owner: ctx.owner,
@@ -140,7 +133,7 @@ export function EditCommentTool(ctx: ToolContext) {
description: "Edit a GitHub issue comment by its ID", description: "Edit a GitHub issue comment by its ID",
parameters: EditComment, parameters: EditComment,
execute: execute(async ({ commentId, body }) => { execute: execute(async ({ commentId, body }) => {
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit); const bodyWithFooter = await addFooter(ctx, body);
const result = await ctx.octokit.rest.issues.updateComment({ const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.owner, owner: ctx.owner,
@@ -218,8 +211,7 @@ export async function reportProgress(
| undefined | undefined
> { > {
const existingCommentId = getProgressCommentId(); const existingCommentId = getProgressCommentId();
const issueNumber = const issueNumber = ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.event.issue_number;
ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
const isPlanMode = ctx.toolState.selectedMode === "Plan"; const isPlanMode = ctx.toolState.selectedMode === "Plan";
// if we already have a progress comment, update it // if we already have a progress comment, update it
@@ -231,7 +223,7 @@ export async function reportProgress(
const bodyWithoutFooter = stripExistingFooter(body); const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({ const footer = await buildCommentFooter({
payload: ctx.payload, agent: ctx.agent,
octokit: ctx.octokit, octokit: ctx.octokit,
customParts, customParts,
}); });
@@ -264,7 +256,7 @@ export async function reportProgress(
} }
// for new comments, we need to create first, then update with Plan link if in Plan mode // for new comments, we need to create first, then update with Plan link if in Plan mode
const initialBody = await addFooter(body, ctx.payload, ctx.octokit); const initialBody = await addFooter(ctx, body);
const result = await ctx.octokit.rest.issues.createComment({ const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.owner, owner: ctx.owner,
@@ -282,7 +274,7 @@ export async function reportProgress(
const customParts = [buildImplementPlanLink(ctx.owner, ctx.name, issueNumber, result.data.id)]; const customParts = [buildImplementPlanLink(ctx.owner, ctx.name, issueNumber, result.data.id)];
const bodyWithoutFooter = stripExistingFooter(body); const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({ const footer = await buildCommentFooter({
payload: ctx.payload, agent: ctx.agent,
octokit: ctx.octokit, octokit: ctx.octokit,
customParts, customParts,
}); });
@@ -382,6 +374,10 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
return true; return true;
} }
interface EnsureProgressCommentUpdatedParams {
disableProgressComment: boolean;
}
/** /**
* Ensure the progress comment is updated with a generic error message if it was never updated. * Ensure the progress comment is updated with a generic error message if it was never updated.
* This should be called after agent execution completes to handle cases where the agent * This should be called after agent execution completes to handle cases where the agent
@@ -390,12 +386,19 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
* Works even if MCP context is not initialized (e.g., if error occurs before MCP server starts). * Works even if MCP context is not initialized (e.g., if error occurs before MCP server starts).
* Will fetch comment ID from database if not available in environment variable. * Will fetch comment ID from database if not available in environment variable.
*/ */
export async function ensureProgressCommentUpdated(payload?: Payload): Promise<void> { export async function ensureProgressCommentUpdated(
params: EnsureProgressCommentUpdatedParams
): Promise<void> {
// skip if comment was already updated during execution // skip if comment was already updated during execution
if (progressComment.wasUpdated) { if (progressComment.wasUpdated) {
return; return;
} }
// skip if progress comments are disabled
if (params.disableProgressComment) {
return;
}
// try to get comment ID from env var first, then from database if needed // try to get comment ID from env var first, then from database if needed
let existingCommentId = getProgressCommentId(); let existingCommentId = getProgressCommentId();
@@ -453,8 +456,8 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise<v
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`; The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
// add footer if we have payload, otherwise use plain message // add footer without agent info (we don't have context here)
const body = payload ? await addFooter(errorMessage, payload, octokit) : errorMessage; const body = await addFooter({ octokit }, errorMessage);
await octokit.rest.issues.updateComment({ await octokit.rest.issues.updateComment({
owner: repoContext.owner, owner: repoContext.owner,
@@ -479,7 +482,7 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
"Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).", "Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).",
parameters: ReplyToReviewComment, parameters: ReplyToReviewComment,
execute: execute(async ({ pull_number, comment_id, body }) => { execute: execute(async ({ pull_number, comment_id, body }) => {
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit); const bodyWithFooter = await addFooter(ctx, body);
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({ const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
owner: ctx.owner, owner: ctx.owner,
-19
View File
@@ -1,19 +0,0 @@
/**
* Simple MCP configuration helper for adding our minimal GitHub comment server
*/
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { ghPullfrogMcpName } from "../external.ts";
export type McpName = typeof ghPullfrogMcpName;
export type McpConfigs = Record<McpName, McpHttpServerConfig>;
export function createMcpConfigs(mcpServerUrl: string): McpConfigs {
return {
[ghPullfrogMcpName]: {
type: "http",
url: mcpServerUrl,
},
};
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import type { PrepResult } from "../prep/index.ts"; import type { PrepResult } from "../prep/index.ts";
import { runPrepPhase } from "../prep/index.ts"; import { runPrepPhase } from "../prep/index.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { containsSecrets } from "../utils/secrets.ts"; import { containsSecrets } from "../utils/secrets.ts";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.ts";
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const Issue = type({ export const Issue = type({
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const GetIssueComments = type({ export const GetIssueComments = type({
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const GetIssueEvents = type({ export const GetIssueEvents = type({
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const IssueInfo = type({ export const IssueInfo = type({
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const AddLabelsParams = type({ export const AddLabelsParams = type({
+2 -6
View File
@@ -1,6 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import { agentsManifest } from "../external.ts"; import type { ToolContext } from "./server.ts";
import type { ToolContext } from "../main.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { containsSecrets } from "../utils/secrets.ts"; import { containsSecrets } from "../utils/secrets.ts";
@@ -14,12 +13,9 @@ export const PullRequest = type({
}); });
function buildPrBodyWithFooter(ctx: ToolContext, body: string): string { function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
const agentName = ctx.payload.agent;
const agentInfo = agentName ? agentsManifest[agentName] : null;
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
triggeredBy: true, triggeredBy: true,
agent: agentInfo ? { displayName: agentInfo.displayName, url: agentInfo.url } : undefined, agent: { displayName: ctx.agent.displayName, url: ctx.agent.url },
workflowRun: ctx.runId workflowRun: ctx.runId
? { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId } ? { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId }
: undefined, : undefined,
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const PullRequestInfo = type({ export const PullRequestInfo = type({
+1 -1
View File
@@ -1,6 +1,6 @@
import type { RestEndpointMethodTypes } from "@octokit/rest"; import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { deleteProgressComment } from "./comment.ts"; import { deleteProgressComment } from "./comment.ts";
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
// graphql query to fetch all review threads with comments and replies // graphql query to fetch all review threads with comments and replies
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const SelectMode = type({ export const SelectMode = type({
+38 -4
View File
@@ -1,9 +1,44 @@
import "./arkConfig.ts"; import "./arkConfig.ts";
import { createServer } from "node:net"; import { createServer } from "node:net";
// this must be imported first // this must be imported first
import type { Octokit } from "@octokit/rest";
import { FastMCP, type Tool } from "fastmcp"; import { FastMCP, type Tool } from "fastmcp";
import { ghPullfrogMcpName } from "../external.ts"; import type { Agent } from "../agents/index.ts";
import type { ToolContext } from "../main.ts"; import { ghPullfrogMcpName, type PayloadEvent } from "../external.ts";
import type { Mode } from "../modes.ts";
import type { PrepResult } from "../prep/index.ts";
export interface ToolState {
prNumber?: number;
issueNumber?: number;
selectedMode?: string;
review?: {
id: number;
nodeId: string;
};
dependencyInstallation?: {
status: "not_started" | "in_progress" | "completed" | "failed";
promise: Promise<PrepResult[]> | undefined;
results: PrepResult[] | undefined;
};
}
export interface ToolContext {
owner: string;
name: string;
repo: { default_branch: string; private: boolean };
githubInstallationToken: string;
octokit: Octokit;
agent: Agent;
event: PayloadEvent;
disableProgressComment: boolean;
modes: Mode[];
toolState: ToolState;
runId: string;
jobId: string | undefined;
}
import { BashTool } from "./bash.ts";
import { CheckoutPrTool } from "./checkout.ts"; import { CheckoutPrTool } from "./checkout.ts";
import { GetCheckSuiteLogsTool } from "./checkSuite.ts"; import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
import { import {
@@ -29,7 +64,6 @@ import { CreatePullRequestReviewTool } from "./review.ts";
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts"; import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
import { SelectModeTool } from "./selectMode.ts"; import { SelectModeTool } from "./selectMode.ts";
import { addTools } from "./shared.ts"; import { addTools } from "./shared.ts";
import { BashTool } from "./bash.ts";
/** /**
* Find an available port starting from the given port * Find an available port starting from the given port
@@ -98,7 +132,7 @@ export async function startMcpHttpServer(
BashTool(ctx), BashTool(ctx),
]; ];
if (!ctx.payload.disableProgressComment) { if (!ctx.disableProgressComment) {
tools.push(ReportProgressTool(ctx)); tools.push(ReportProgressTool(ctx));
} }
+1 -1
View File
@@ -1,8 +1,8 @@
import type { StandardSchemaV1 } from "@standard-schema/spec"; import type { StandardSchemaV1 } from "@standard-schema/spec";
import { encode as toonEncode } from "@toon-format/toon"; import { encode as toonEncode } from "@toon-format/toon";
import type { FastMCP, Tool } from "fastmcp"; import type { FastMCP, Tool } from "fastmcp";
import type { ToolContext } from "../main.ts";
import { formatJsonValue, log } from "../utils/cli.ts"; import { formatJsonValue, log } from "../utils/cli.ts";
import type { ToolContext } from "./server.ts";
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef; export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
+6 -5
View File
@@ -14,15 +14,16 @@ export const ModeSchema = type({
prompt: "string", prompt: "string",
}); });
export interface GetModesParams { export interface ComputeModesParams {
disableProgressComment: true | undefined; disableProgressComment: boolean;
} }
const reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to share progress and results. Continue calling it as you make progress - it will update the same comment. Never create additional comments manually.`; const reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to share progress and results. Continue calling it as you make progress - it will update the same comment. Never create additional comments manually.`;
const dependencyInstallationStep = `If this task will require running tests, builds, linters, or CLI commands that need installed packages, call \`${ghPullfrogMcpName}/start_dependency_installation\` NOW. This is non-blocking and allows dependencies to install in the background while you continue. Later, call \`${ghPullfrogMcpName}/await_dependency_installation\` before running commands that need them. Skip this step if only reading code or answering questions.`; const dependencyInstallationStep = `If this task will require running tests, builds, linters, or CLI commands that need installed packages, call \`${ghPullfrogMcpName}/start_dependency_installation\` NOW. This is non-blocking and allows dependencies to install in the background while you continue. Later, call \`${ghPullfrogMcpName}/await_dependency_installation\` before running commands that need them. Skip this step if only reading code or answering questions.`;
export function getModes({ disableProgressComment }: GetModesParams): Mode[] { export function computeModes(ctx: ComputeModesParams): Mode[] {
const disableProgressComment = ctx.disableProgressComment;
return [ return [
{ {
name: "Build", name: "Build",
@@ -170,6 +171,6 @@ ${
]; ];
} }
export const modes: Mode[] = getModes({ export const modes: Mode[] = computeModes({
disableProgressComment: undefined, disableProgressComment: false,
}); });
+15 -1
View File
@@ -30,7 +30,21 @@ export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult>
const inputs: Inputs = const inputs: Inputs =
typeof inputsOrPrompt === "string" ? { prompt: inputsOrPrompt } : inputsOrPrompt; typeof inputsOrPrompt === "string" ? { prompt: inputsOrPrompt } : inputsOrPrompt;
const result = await main(inputs); // Mock core.getInput to simulate Github Actions input
const mockCore = {
getInput: (name: string, options?: { required?: boolean }): string => {
const value = inputs[name as keyof Inputs];
if (value === undefined || value === null) {
if (options?.required) {
throw new Error(`Input required and not supplied: ${name}`);
}
return "";
}
return String(value);
},
};
const result = await main(mockCore);
process.chdir(originalCwd); process.chdir(originalCwd);
+1 -1
View File
@@ -31,7 +31,7 @@ export async function runPrepPhase(): Promise<PrepResult[]> {
if (result.dependenciesInstalled) { if (result.dependenciesInstalled) {
log.debug(`» ${step.name}: dependencies installed`); log.debug(`» ${step.name}: dependencies installed`);
} else if (result.issues.length > 0) { } else if (result.issues.length > 0) {
log.warning(`⚠️ ${step.name}: ${result.issues[0]}`); log.warning(`» ${step.name}: ${result.issues[0]}`);
} }
} }
+7 -7
View File
@@ -56,7 +56,7 @@ async function installPackageManager(
installSpec: string installSpec: string
): Promise<string | null> { ): Promise<string | null> {
if (name === "npm") return null; // npm is always available if (name === "npm") return null; // npm is always available
log.info(`📦 installing ${installSpec}...`); log.info(`» installing ${installSpec}...`);
const [cmd, ...templateArgs] = nodePackageManagers[name]; const [cmd, ...templateArgs] = nodePackageManagers[name];
const args = templateArgs.map((arg) => (arg === "{version}" ? installSpec : arg)); const args = templateArgs.map((arg) => (arg === "{version}" ? installSpec : arg));
const result = await spawn({ const result = await spawn({
@@ -76,7 +76,7 @@ async function installPackageManager(
process.env.PATH = `${denoPath}:${process.env.PATH}`; process.env.PATH = `${denoPath}:${process.env.PATH}`;
} }
log.info(` installed ${name}`); log.info(`» installed ${name}`);
return null; return null;
} }
@@ -101,16 +101,16 @@ export const installNodeDependencies: PrepDefinition = {
const agent = detected?.agent || packageManager; const agent = detected?.agent || packageManager;
if (fromPackageJson) { if (fromPackageJson) {
log.info(`📦 using packageManager from package.json: ${fromPackageJson.installSpec}`); log.info(`» using packageManager from package.json: ${fromPackageJson.installSpec}`);
} else if (detected) { } else if (detected) {
log.info(`📦 detected package manager: ${packageManager} (${agent})`); log.info(`» detected package manager: ${packageManager} (${agent})`);
} else { } else {
log.info(`📦 no package manager detected, defaulting to npm`); log.info(`» no package manager detected, defaulting to npm`);
} }
// check if package manager is available, install if needed // check if package manager is available, install if needed
if (!(await isCommandAvailable(packageManager))) { if (!(await isCommandAvailable(packageManager))) {
log.info(`${packageManager} not found, attempting to install...`); log.info(`» ${packageManager} not found, attempting to install...`);
const installError = await installPackageManager(packageManager, installSpec); const installError = await installPackageManager(packageManager, installSpec);
if (installError) { if (installError) {
return { return {
@@ -134,7 +134,7 @@ export const installNodeDependencies: PrepDefinition = {
} }
const fullCommand = `${resolved.command} ${resolved.args.join(" ")}`; const fullCommand = `${resolved.command} ${resolved.args.join(" ")}`;
log.info(`running: ${fullCommand}`); log.info(`» running: ${fullCommand}`);
const result = await spawn({ const result = await spawn({
cmd: resolved.command, cmd: resolved.command,
args: resolved.args, args: resolved.args,
+5 -5
View File
@@ -66,7 +66,7 @@ async function installTool(name: string): Promise<string | null> {
return null; return null;
} }
log.info(`📦 installing ${name}...`); log.info(`» installing ${name}...`);
const [cmd, ...args] = installCmd; const [cmd, ...args] = installCmd;
const result = await spawn({ const result = await spawn({
cmd, cmd,
@@ -79,7 +79,7 @@ async function installTool(name: string): Promise<string | null> {
return result.stderr || `failed to install ${name}`; return result.stderr || `failed to install ${name}`;
} }
log.info(` installed ${name}`); log.info(`» installed ${name}`);
return null; return null;
} }
@@ -113,12 +113,12 @@ export const installPythonDependencies: PrepDefinition = {
}; };
} }
log.info(`🐍 detected python config: ${config.file} (using ${config.tool})`); log.info(`» detected python config: ${config.file} (using ${config.tool})`);
// check if the tool is available, install if needed // check if the tool is available, install if needed
const isAvailable = await isCommandAvailable(config.tool); const isAvailable = await isCommandAvailable(config.tool);
if (!isAvailable) { if (!isAvailable) {
log.info(`${config.tool} not found, attempting to install...`); log.info(`» ${config.tool} not found, attempting to install...`);
const installError = await installTool(config.tool); const installError = await installTool(config.tool);
if (installError) { if (installError) {
return { return {
@@ -133,7 +133,7 @@ export const installPythonDependencies: PrepDefinition = {
// run the install command // run the install command
const [cmd, ...args] = config.installCmd; const [cmd, ...args] = config.installCmd;
log.info(`running: ${cmd} ${args.join(" ")}`); log.info(`» running: ${cmd} ${args.join(" ")}`);
const result = await spawn({ const result = await spawn({
cmd, cmd,
args, args,
-40
View File
@@ -1,40 +0,0 @@
# note: this file must remain identical to action/action.yml and action/run/action.yml
# future agents: keep both files in sync manually
name: "Pullfrog Action"
description: "Execute coding agents with a prompt"
author: "Pullfrog"
inputs:
prompt:
description: "Prompt to send to the agent"
required: true
effort:
description: "Effort level: mini (fast), auto (default), max (most capable)"
required: false
default: "auto"
agent:
description: "Agent to use: claude, codex, gemini, cursor, opencode"
required: false
cwd:
description: "Working directory for the agent (defaults to GITHUB_WORKSPACE)"
required: false
web:
description: "Web fetch permission: disabled or enabled (default: enabled)"
required: false
search:
description: "Web search permission: disabled or enabled (default: enabled)"
required: false
write:
description: "File write permission: disabled or enabled (default: enabled)"
required: false
bash:
description: "Bash permission: disabled, restricted (filters secrets from env vars), or enabled. Public repos default to restricted for security; private repos default to enabled."
required: false
runs:
using: "node24"
main: "entry"
branding:
icon: "code"
color: "green"
-138784
View File
File diff suppressed because one or more lines are too long
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env node
/**
* entry point for pullfrog/pullfrog/run - itemized inputs for external users
*/
import * as core from "@actions/core";
import { Inputs, main } from "../main.ts";
async function run(): Promise<void> {
try {
// granular tool permissions (empty string means not set, use default)
const web = core.getInput("web") || undefined;
const search = core.getInput("search") || undefined;
const write = core.getInput("write") || undefined;
const bash = core.getInput("bash") || undefined;
const inputs = Inputs.assert({
prompt: core.getInput("prompt", { required: true }),
effort: core.getInput("effort") || "auto",
agent: core.getInput("agent") || null,
cwd: core.getInput("cwd") || null,
web,
search,
write,
bash,
});
const result = await main(inputs);
if (!result.success) {
throw new Error(result.error || "Agent execution failed");
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
core.setFailed(`Action failed: ${errorMessage}`);
}
}
await run();
-133
View File
@@ -1,133 +0,0 @@
import type { AgentName, BashPermission, ToolPermission } from "../external.ts";
import type { RepoContext } from "./github.ts";
export interface Mode {
id: string;
name: string;
description: string;
prompt: string;
}
export interface RepoSettings {
defaultAgent: AgentName | null;
web: ToolPermission;
search: ToolPermission;
write: ToolPermission;
bash: BashPermission;
modes: Mode[];
}
export const DEFAULT_REPO_SETTINGS: RepoSettings = {
defaultAgent: null,
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
modes: [],
};
export interface WorkflowRunInfo {
progressCommentId: string | null;
issueNumber: number | null;
}
/**
* Fetch workflow run info from the Pullfrog API
* Returns the pre-created progress comment ID if one exists
*/
export async function fetchWorkflowRunInfo(runId: string): Promise<WorkflowRunInfo> {
const apiUrl = process.env.API_URL || "https://pullfrog.com";
// add timeout to prevent hanging (30 seconds)
const timeoutMs = 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${apiUrl}/api/workflow-run/${runId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return { progressCommentId: null, issueNumber: null };
}
const data = (await response.json()) as WorkflowRunInfo;
return data;
} catch {
clearTimeout(timeoutId);
return { progressCommentId: null, issueNumber: null };
}
}
/**
* Fetch repository settings from the Pullfrog API
* Returns defaults if repo doesn't exist or fetch fails
*/
export async function fetchRepoSettings({
token,
repoContext,
}: {
token: string;
repoContext: RepoContext;
}): Promise<RepoSettings> {
const settings = await getRepoSettings(token, repoContext);
return settings;
}
/**
* Fetch repository settings from the Pullfrog API with fallback to defaults
* Returns agent, permissions, and workflows (excludes triggers)
* Returns defaults if repo doesn't exist or fetch fails
*/
export async function getRepoSettings(
token: string,
repoContext: RepoContext
): Promise<RepoSettings> {
const apiUrl = process.env.API_URL || "https://pullfrog.com";
// Add timeout to prevent hanging (30 seconds)
const timeoutMs = 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(
`${apiUrl}/api/repo/${repoContext.owner}/${repoContext.name}/settings`,
{
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
signal: controller.signal,
}
);
clearTimeout(timeoutId);
if (!response.ok) {
// If API returns 404 or other error, fall back to defaults
return DEFAULT_REPO_SETTINGS;
}
const settings = (await response.json()) as RepoSettings | null;
// If API returns null (repo doesn't exist), return defaults
if (settings === null) {
return DEFAULT_REPO_SETTINGS;
}
return settings;
} catch {
clearTimeout(timeoutId);
// If fetch fails (network error, timeout, etc.), fall back to defaults
return DEFAULT_REPO_SETTINGS;
}
}
+81
View File
@@ -0,0 +1,81 @@
import type { Agent } from "../agents/index.ts";
export interface ApiKeySetup {
apiKey: string;
apiKeys: Record<string, string>;
}
/**
* 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`;
let secretNameList: string;
if (params.agent.apiKeyNames.length === 0) {
secretNameList =
"any API key (e.g., `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, etc.)";
} else {
const secretNames = params.agent.apiKeyNames.map((key) => `\`${key}\``);
secretNameList =
params.agent.apiKeyNames.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}`;
}
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;
}
}
// empty apiKeyNames means agent accepts any *API_KEY* env var
if (agent.apiKeyNames.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;
}
export function validateApiKey(params: { agent: Agent; owner: string; name: string }): ApiKeySetup {
const apiKeys = collectApiKeys(params.agent);
if (Object.keys(apiKeys).length === 0) {
throw new Error(
buildMissingApiKeyError({
agent: params.agent,
owner: params.owner,
name: params.name,
})
);
}
return {
apiKey: Object.values(apiKeys)[0],
apiKeys,
};
}
+4 -3
View File
@@ -1,5 +1,6 @@
import { fetchWorkflowRunInfo } from "./api.ts"; import { createOctokit, parseRepoContext } from "./github.ts";
import { createOctokit, getGitHubInstallationToken, parseRepoContext } from "./github.ts"; import { getGitHubInstallationToken } from "./token.ts";
import { fetchWorkflowRunInfo } from "./workflowRun.ts";
/** /**
* Get progress comment ID from environment variable or database. * Get progress comment ID from environment variable or database.
@@ -22,7 +23,7 @@ export async function reportErrorToComment({
error: string; error: string;
title?: string; title?: string;
}): Promise<void> { }): Promise<void> {
const formattedError = title ? `${title}\n\n${error}` : `${error}`; const formattedError = title ? `${title}\n\n${error}` : error;
// try to get comment ID from env var first, then from database if needed // try to get comment ID from env var first, then from database if needed
let commentId = getProgressCommentIdFromEnv(); let commentId = getProgressCommentIdFromEnv();
-53
View File
@@ -256,59 +256,6 @@ export async function acquireNewToken(opts?: { repos?: string[] }): Promise<stri
} }
} }
// Store token in memory instead of process.env
let githubInstallationToken: string | undefined;
/**
* Setup GitHub installation token for the action
*/
export async function setupGitHubInstallationToken() {
assert(!githubInstallationToken, "GitHub installation token is already set.");
// store original GITHUB_TOKEN before we overwrite it (used by filterEnv in bash.ts)
process.env.ORIGINAL_GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const acquiredToken = await acquireNewToken();
core.setSecret(acquiredToken);
githubInstallationToken = acquiredToken;
return {
token: acquiredToken,
[Symbol.asyncDispose]() {
githubInstallationToken = undefined;
return revokeGitHubInstallationToken(acquiredToken);
},
};
}
/**
* Get the GitHub installation token from memory
*/
export function getGitHubInstallationToken(): string {
assert(
githubInstallationToken,
"GitHub installation token not set. Call setupGitHubInstallationToken first."
);
return githubInstallationToken;
}
export async function revokeGitHubInstallationToken(token: string): Promise<void> {
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
try {
await fetch(`${apiUrl}/installation/token`, {
method: "DELETE",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
log.debug("» installation token revoked");
} catch (error) {
log.warning(
`Failed to revoke installation token: ${error instanceof Error ? error.message : String(error)}`
);
}
}
export interface RepoContext { export interface RepoContext {
owner: string; owner: string;
name: string; name: string;
+394
View File
@@ -0,0 +1,394 @@
import { spawnSync } from "node:child_process";
import { chmodSync, createWriteStream, existsSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import { log } from "./cli.ts";
export interface InstallFromNpmTarballParams {
packageName: string;
version: string;
executablePath: string;
installDependencies?: boolean;
}
export interface InstallFromCurlParams {
installUrl: string;
executableName: string;
}
export interface InstallFromGithubParams {
owner: string;
repo: string;
assetName?: string;
executablePath?: string;
githubInstallationToken?: string;
}
export interface InstallFromGithubTarballParams {
owner: string;
repo: string;
assetNamePattern: string;
executablePath: string;
githubInstallationToken?: string;
}
interface NpmRegistryData {
"dist-tags": { latest: string };
versions: Record<string, unknown>;
}
/**
* Install a CLI tool from an npm package tarball
* Downloads the tarball, extracts it to a temp directory, and returns the path to the CLI executable
* The temp directory will be cleaned up by the OS automatically
*/
export async function installFromNpmTarball(params: InstallFromNpmTarballParams): Promise<string> {
// Resolve version if it's a range or "latest"
let resolvedVersion = params.version;
if (
params.version.startsWith("^") ||
params.version.startsWith("~") ||
params.version === "latest"
) {
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
log.debug(`» resolving version for ${params.version}...`);
try {
const registryResponse = await fetch(`${npmRegistry}/${params.packageName}`);
if (!registryResponse.ok) {
throw new Error(`Failed to query registry: ${registryResponse.status}`);
}
const registryData = (await registryResponse.json()) as NpmRegistryData;
resolvedVersion = registryData["dist-tags"].latest;
log.debug(`» resolved to version ${resolvedVersion}`);
} catch (error) {
log.warning(
`Failed to resolve version from registry: ${error instanceof Error ? error.message : String(error)}`
);
throw error;
}
}
log.debug(`» installing ${params.packageName}@${resolvedVersion}...`);
const tempDir = process.env.PULLFROG_TEMP_DIR!;
const tarballPath = join(tempDir, "package.tgz");
// Download tarball from npm
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
// Handle scoped packages (e.g., @scope/package -> @scope%2Fpackage/-/package-version.tgz)
let tarballUrl: string;
if (params.packageName.startsWith("@")) {
const [scope, name] = params.packageName.slice(1).split("/");
const scopedPackageName = `@${scope}%2F${name}`;
tarballUrl = `${npmRegistry}/${scopedPackageName}/-/${name}-${resolvedVersion}.tgz`;
} else {
tarballUrl = `${npmRegistry}/${params.packageName}/-/${params.packageName}-${resolvedVersion}.tgz`;
}
log.debug(`» downloading from ${tarballUrl}...`);
const response = await fetch(tarballUrl);
if (!response.ok) {
throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`);
}
// Write tarball to file
if (!response.body) throw new Error("Response body is null");
const fileStream = createWriteStream(tarballPath);
await pipeline(response.body, fileStream);
log.debug(`» downloaded tarball to ${tarballPath}`);
// Extract tarball
log.debug(`» extracting tarball...`);
const extractResult = spawnSync("tar", ["-xzf", tarballPath, "-C", tempDir], {
stdio: "pipe",
encoding: "utf-8",
});
if (extractResult.status !== 0) {
throw new Error(
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
);
}
// Find executable in the extracted package
const extractedDir = join(tempDir, "package");
const cliPath = join(extractedDir, params.executablePath);
if (!existsSync(cliPath)) {
throw new Error(`Executable not found in extracted package at ${cliPath}`);
}
// Install dependencies if requested
if (params.installDependencies) {
log.debug(`» installing dependencies for ${params.packageName}...`);
const installResult = spawnSync("npm", ["install", "--production"], {
cwd: extractedDir,
stdio: "pipe",
encoding: "utf-8",
});
if (installResult.status !== 0) {
throw new Error(
`Failed to install dependencies: ${installResult.stderr || installResult.stdout || "Unknown error"}`
);
}
log.debug(`» dependencies installed`);
}
// Make the file executable
chmodSync(cliPath, 0o755);
log.debug(`» ${params.packageName} installed at ${cliPath}`);
return cliPath;
}
/**
* Fetch with retry logic if Retry-After header is present
*/
async function fetchWithRetry(
url: string,
headers: Record<string, string>,
errorMessage: string
): Promise<Response> {
const response = await fetch(url, { headers });
if (!response.ok) {
const retryAfter = response.headers.get("Retry-After") || response.headers.get("retry-after");
if (retryAfter) {
const waitSeconds = parseInt(retryAfter, 10);
if (!Number.isNaN(waitSeconds) && waitSeconds > 0) {
log.info(`» rate limited, waiting ${waitSeconds} seconds before retry...`);
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
const retryResponse = await fetch(url, { headers });
if (!retryResponse.ok) {
throw new Error(
`${errorMessage}: ${retryResponse.status} ${retryResponse.statusText} (retry failed)`
);
}
return retryResponse;
}
}
throw new Error(`${errorMessage}: ${response.status} ${response.statusText}`);
}
return response;
}
/**
* Install a CLI tool from GitHub releases
* Downloads the latest release asset from GitHub and returns the path to the executable
* The temp directory will be cleaned up by the OS automatically
*/
export async function installFromGithub(params: InstallFromGithubParams): Promise<string> {
log.info(`» installing ${params.owner}/${params.repo} from GitHub releases...`);
// fetch release from GitHub API (latest)
const releaseUrl = `https://api.github.com/repos/${params.owner}/${params.repo}/releases/latest`;
log.debug(`» fetching release from ${releaseUrl}...`);
const headers: Record<string, string> = {};
if (params.githubInstallationToken) {
headers.Authorization = `Bearer ${params.githubInstallationToken}`;
}
const releaseResponse = await fetchWithRetry(releaseUrl, headers, "Failed to fetch release");
const releaseData = (await releaseResponse.json()) as {
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
}>;
};
log.debug(`» found release ${releaseData.tag_name}`);
const asset = releaseData.assets.find((a) => a.name === params.assetName);
if (!asset) {
throw new Error(`Asset '${params.assetName}' not found in release ${releaseData.tag_name}`);
}
const assetUrl = asset.browser_download_url;
log.debug(`» downloading asset from ${assetUrl}...`);
// create temp directory
const tempDirPrefix = `${params.owner}-${params.repo}-github-`;
const tempDirPath = await mkdtemp(join(tmpdir(), tempDirPrefix));
// determine file extension and download path
const urlPath = new URL(assetUrl).pathname;
const fileName = urlPath.split("/").pop() || "asset";
const downloadPath = join(tempDirPath, fileName);
// download the asset
const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset");
if (!assetResponse.body) throw new Error("Response body is null");
const fileStream = createWriteStream(downloadPath);
await pipeline(assetResponse.body, fileStream);
log.debug(`» downloaded asset to ${downloadPath}`);
// determine the executable path
let cliPath: string;
if (params.executablePath) {
cliPath = join(tempDirPath, params.executablePath);
} else {
// no executablePath, assume the downloaded file is the executable
cliPath = downloadPath;
}
if (!existsSync(cliPath)) {
throw new Error(`Executable not found at ${cliPath}`);
}
chmodSync(cliPath, 0o755);
log.info(`» installed from GitHub release at ${cliPath}`);
return cliPath;
}
/**
* Install a CLI tool from a GitHub release tarball
* Downloads the tar.gz from GitHub releases, extracts it, and returns the path to the CLI executable
* The temp directory will be cleaned up by the OS automatically
*/
export async function installFromGithubTarball(
params: InstallFromGithubTarballParams
): Promise<string> {
log.info(`» installing ${params.owner}/${params.repo} from GitHub releases...`);
// determine platform-specific asset name
const os = process.platform === "darwin" ? "darwin" : "linux";
const arch = process.arch === "arm64" ? "arm64" : "x64";
const assetName = params.assetNamePattern.replace("{os}", os).replace("{arch}", arch);
// fetch release from GitHub API (latest)
const releaseUrl = `https://api.github.com/repos/${params.owner}/${params.repo}/releases/latest`;
log.info(`» fetching release from ${releaseUrl}...`);
const headers: Record<string, string> = {};
if (params.githubInstallationToken) {
headers.Authorization = `Bearer ${params.githubInstallationToken}`;
}
const releaseResponse = await fetchWithRetry(releaseUrl, headers, "Failed to fetch release");
const releaseData = (await releaseResponse.json()) as {
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
}>;
};
log.debug(`» found release: ${releaseData.tag_name}`);
const asset = releaseData.assets.find((a) => a.name === assetName);
if (!asset) {
throw new Error(`Asset '${assetName}' not found in release ${releaseData.tag_name}`);
}
const assetUrl = asset.browser_download_url;
log.debug(`» downloading asset from ${assetUrl}...`);
const tempDir = process.env.PULLFROG_TEMP_DIR!;
const tarballPath = join(tempDir, assetName);
// download the asset
const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset");
if (!assetResponse.body) throw new Error("Response body is null");
const fileStream = createWriteStream(tarballPath);
await pipeline(assetResponse.body, fileStream);
log.debug(`» downloaded tarball to ${tarballPath}`);
// extract tar.gz
log.debug(`» extracting tarball...`);
const extractResult = spawnSync("tar", ["-xzf", tarballPath, "-C", tempDir], {
stdio: "pipe",
encoding: "utf-8",
});
if (extractResult.status !== 0) {
throw new Error(
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
);
}
// find executable in the extracted tarball
const cliPath = join(tempDir, params.executablePath);
if (!existsSync(cliPath)) {
throw new Error(`Executable not found in extracted tarball at ${cliPath}`);
}
// make the file executable
chmodSync(cliPath, 0o755);
log.info(`» ${params.owner}/${params.repo} installed at ${cliPath}`);
return cliPath;
}
/**
* Install a CLI tool from a curl-based install script
* Downloads the install script, runs it with HOME set to temp directory, and returns the path to the CLI executable
* The temp directory will be cleaned up by the OS automatically
*/
export async function installFromCurl(params: InstallFromCurlParams): Promise<string> {
log.info(`» installing ${params.executableName}...`);
const tempDir = process.env.PULLFROG_TEMP_DIR!;
const installScriptPath = join(tempDir, "install.sh");
// Download the install script
log.debug(`» downloading install script from ${params.installUrl}...`);
const installScriptResponse = await fetch(params.installUrl);
if (!installScriptResponse.ok) {
throw new Error(`Failed to download install script: ${installScriptResponse.status}`);
}
if (!installScriptResponse.body) throw new Error("Response body is null");
const fileStream = createWriteStream(installScriptPath);
await pipeline(installScriptResponse.body, fileStream);
log.debug(`» downloaded install script to ${installScriptPath}`);
// Make install script executable
chmodSync(installScriptPath, 0o755);
log.debug(`» installing to temp directory at ${tempDir}...`);
const installResult = spawnSync("bash", [installScriptPath], {
cwd: tempDir,
env: {
// Run the install script with HOME set to temp directory
// ensuring a fresh install for each run
HOME: tempDir,
// XDG_CONFIG_HOME must match HOME so CLI tools find config in the right place
XDG_CONFIG_HOME: join(tempDir, ".config"),
SHELL: process.env.SHELL,
USER: process.env.USER,
},
stdio: "pipe",
encoding: "utf-8",
});
if (installResult.status !== 0) {
const errorOutput = installResult.stderr || installResult.stdout || "No output";
throw new Error(
`Failed to install ${params.executableName}. Install script exited with code ${installResult.status}. Output: ${errorOutput}`
);
}
// The Cursor install script creates a symlink at $HOME/.local/bin/{executableName}
// Since we set HOME=tempDir, the deterministic path is:
const cliPath = join(tempDir, ".local", "bin", params.executableName);
if (!existsSync(cliPath)) {
throw new Error(`Executable not found at ${cliPath}`);
}
// Ensure binary is executable
chmodSync(cliPath, 0o755);
log.info(`» ${params.executableName} installed at ${cliPath}`);
return cliPath;
}
@@ -1,21 +1,25 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { encode as toonEncode } from "@toon-format/toon"; import { encode as toonEncode } from "@toon-format/toon";
import type { Payload } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts"; import { ghPullfrogMcpName } from "../external.ts";
import { getModes } from "../modes.ts"; import { computeModes, type Mode } from "../modes.ts";
import type { RepoInfo, ToolPermissions } from "./shared.ts"; import type { RepoData } from "./repoData.ts";
/** type BashPermission = "disabled" | "restricted" | "enabled";
* Build runtime context string with git status, repo data, and GitHub Actions variables
*/ interface InstructionsInput {
function buildRuntimeContext(repo: RepoInfo): string { prompt: string;
event: { trigger: string; [key: string]: unknown };
repoData: RepoData;
modes: Mode[];
bash: BashPermission;
}
function buildRuntimeContext(input: InstructionsInput): string {
const lines: string[] = []; const lines: string[] = [];
// working directory
lines.push(`working_directory: ${process.cwd()}`); lines.push(`working_directory: ${process.cwd()}`);
lines.push(`log_level: ${process.env.LOG_LEVEL}`); lines.push(`log_level: ${process.env.LOG_LEVEL}`);
// git status (try to get it, but don't fail if git isn't available)
try { try {
const gitStatus = execSync("git status --short", { encoding: "utf-8", stdio: "pipe" }).trim(); const gitStatus = execSync("git status --short", { encoding: "utf-8", stdio: "pipe" }).trim();
lines.push(`git_status: ${gitStatus || "(clean)"}`); lines.push(`git_status: ${gitStatus || "(clean)"}`);
@@ -23,11 +27,9 @@ function buildRuntimeContext(repo: RepoInfo): string {
// git not available or not in a repo // git not available or not in a repo
} }
// repo data lines.push(`repo: ${input.repoData.owner}/${input.repoData.name}`);
lines.push(`repo: ${repo.owner}/${repo.name}`); lines.push(`default_branch: ${input.repoData.repo.default_branch}`);
lines.push(`default_branch: ${repo.defaultBranch}`);
// GitHub Actions variables (when running in CI)
const ghVars: Record<string, string | undefined> = { const ghVars: Record<string, string | undefined> = {
github_event_name: process.env.GITHUB_EVENT_NAME, github_event_name: process.env.GITHUB_EVENT_NAME,
github_ref: process.env.GITHUB_REF, github_ref: process.env.GITHUB_REF,
@@ -45,16 +47,7 @@ function buildRuntimeContext(repo: RepoInfo): string {
return lines.join("\n"); return lines.join("\n");
} }
interface AddInstructionsCtx { function getShellInstructions(bash: BashPermission): string {
payload: Payload;
repo: RepoInfo;
tools: ToolPermissions;
}
/**
* Generate shell instructions based on bash permission level.
*/
function getShellInstructions(bash: ToolPermissions["bash"]): string {
switch (bash) { switch (bash) {
case "disabled": case "disabled":
return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`; return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`;
@@ -69,19 +62,17 @@ function getShellInstructions(bash: ToolPermissions["bash"]): string {
} }
} }
export const addInstructions = (ctx: AddInstructionsCtx) => { export function resolveInstructions(input: InstructionsInput): string {
let encodedEvent = ""; let encodedEvent = "";
const eventKeys = Object.keys(ctx.payload.event); const eventKeys = Object.keys(input.event);
if (eventKeys.length === 1 && eventKeys[0] === "trigger") { if (eventKeys.length === 1 && eventKeys[0] === "trigger") {
// no meaningful event data to encode // no meaningful event data to encode
} else { } else {
// extract only essential fields to reduce token usage encodedEvent = toonEncode(input.event);
// const essentialEvent = ctx.payload.event;
encodedEvent = toonEncode(ctx.payload.event);
} }
const runtimeContext = buildRuntimeContext(ctx.repo); const runtimeContext = buildRuntimeContext(input);
return ( return (
` `
@@ -126,33 +117,11 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g
**Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author. **Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author.
` +
// **Available git MCP tools**:
// - \`${ghPullfrogMcpName}/checkout_pr\` - Checkout an existing PR branch locally (handles fork PRs automatically)
// - \`${ghPullfrogMcpName}/create_branch\` - Create a new branch from a base branch
// - \`${ghPullfrogMcpName}/commit_files\` - Stage and commit files with proper authentication
// - \`${ghPullfrogMcpName}/push_branch\` - Push a branch to the remote (automatically uses correct remote for fork PRs)
// - \`${ghPullfrogMcpName}/create_pull_request\` - Create a PR from the current branch
// **Workflow for working on an existing PR**:
// 1. Use \`${ghPullfrogMcpName}/checkout_pr\` to checkout the PR branch
// 2. Make your changes using file operations
// 3. Use \`${ghPullfrogMcpName}/commit_files\` to commit your changes
// 4. Use \`${ghPullfrogMcpName}/push_branch\` to push (automatically pushes to fork for fork PRs)
// **Workflow for creating new changes**:
// 1. Use \`${ghPullfrogMcpName}/create_branch\` to create a new branch
// 2. Make your changes using file operations
// 3. Use \`${ghPullfrogMcpName}/commit_files\` to commit your changes
// 4. Use \`${ghPullfrogMcpName}/push_branch\` to push the branch
// 5. Use \`${ghPullfrogMcpName}/create_pull_request\` to create a PR
`
**Do not attempt to configure git credentials manually** - the ${ghPullfrogMcpName} server handles all authentication internally. **Do not attempt to configure git credentials manually** - the ${ghPullfrogMcpName} server handles all authentication internally.
**Efficiency**: Trust the tools - do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error. **Efficiency**: Trust the tools - do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error.
${getShellInstructions(ctx.tools.bash)} ${getShellInstructions(input.bash)}
**Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished. **Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished.
@@ -173,7 +142,7 @@ ${getShellInstructions(ctx.tools.bash)}
### Available modes ### Available modes
${[...getModes({ disableProgressComment: ctx.payload.disableProgressComment }), ...ctx.payload.modes].map((w) => ` - "${w.name}": ${w.description}`).join("\n")} ${[...computeModes({ disableProgressComment: false }), ...input.modes].map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
### Following the mode instructions ### Following the mode instructions
@@ -183,7 +152,7 @@ Eagerly inspect the MCP tools available to you via the \`${ghPullfrogMcpName}\`
************* USER PROMPT ************* ************* USER PROMPT *************
${ctx.payload.prompt ${input.prompt
.split("\n") .split("\n")
.map((line) => `> ${line}`) .map((line) => `> ${line}`)
.join("\n")} .join("\n")}
@@ -202,4 +171,4 @@ ${encodedEvent}`
${runtimeContext}` ${runtimeContext}`
); );
}; }
+1 -1
View File
@@ -216,7 +216,7 @@ export const log = {
/** Print success message */ /** Print success message */
success: (...args: unknown[]): void => { success: (...args: unknown[]): void => {
core.info(` ${formatArgs(args)}`); core.info(`» ${formatArgs(args)}`);
}, },
/** Print debug message (only if LOG_LEVEL=debug) */ /** Print debug message (only if LOG_LEVEL=debug) */
+126
View File
@@ -0,0 +1,126 @@
import { isAbsolute, resolve } from "node:path";
import { type } from "arktype";
import {
AgentName,
type AgentName as AgentNameType,
Effort,
type PayloadEvent,
} from "../external.ts";
// tool permission enum types for inputs
const ToolPermissionInput = type.enumerated("disabled", "enabled");
const BashPermissionInput = type.enumerated("disabled", "restricted", "enabled");
// schema for JSON payload passed via prompt (internal dispatch invocation)
const JsonPayload = type({
"~pullfrog": "true",
"agent?": AgentName.or("null"),
"prompt?": "string",
"event?": "object",
"effort?": Effort,
"web?": ToolPermissionInput,
"search?": ToolPermissionInput,
"write?": ToolPermissionInput,
"bash?": BashPermissionInput,
"disableProgressComment?": "true",
"comment_id?": "number|null",
"issue_id?": "number|null",
"pr_id?": "number|null",
});
// inputs schema - action inputs from core.getInput()
export const Inputs = type({
prompt: "string",
"effort?": Effort,
"agent?": AgentName.or("null"),
"web?": ToolPermissionInput,
"search?": ToolPermissionInput,
"write?": ToolPermissionInput,
"bash?": BashPermissionInput,
"cwd?": "string|null",
});
export type Inputs = typeof Inputs.infer;
function isAgentName(value: unknown): value is AgentNameType {
return typeof value === "string" && AgentName(value) instanceof type.errors === false;
}
function isPayloadEvent(value: unknown): value is PayloadEvent {
return typeof value === "object" && value !== null && "trigger" in value;
}
function resolveCwd(cwd: string | null | undefined): string | null {
const workspace = process.env.GITHUB_WORKSPACE;
if (!cwd) return workspace ?? null;
if (isAbsolute(cwd)) return cwd;
return workspace ? resolve(workspace, cwd) : cwd;
}
export function resolvePayload(core: {
getInput: (name: string, options?: { required?: boolean }) => string;
}) {
const inputs = Inputs.assert({
prompt: core.getInput("prompt", { required: true }),
effort: core.getInput("effort") || "auto",
agent: core.getInput("agent") || null,
cwd: core.getInput("cwd") || null,
web: core.getInput("web") || undefined,
search: core.getInput("search") || undefined,
write: core.getInput("write") || undefined,
bash: core.getInput("bash") || undefined,
});
// convert "null" string to null, validate agent name
const agent: AgentNameType | null =
inputs.agent !== undefined && inputs.agent !== "null" && isAgentName(inputs.agent)
? inputs.agent
: null;
// try to parse prompt as JSON payload (internal invocation)
let jsonPayload: typeof JsonPayload.infer | null = null;
try {
const parsed = JSON.parse(inputs.prompt);
// if it looks like a pullfrog payload but fails validation, that's an error
if (parsed && typeof parsed === "object" && "~pullfrog" in parsed) {
jsonPayload = JsonPayload.assert(parsed);
}
} catch (error) {
// JSON parse error is fine (plain text prompt), but validation error should propagate
if (error instanceof type.errors) {
throw new Error(`invalid pullfrog payload: ${error.summary}`);
}
// not JSON, treat as plain string prompt
}
// resolve event - use type guard for jsonPayload.event, fallback to unknown trigger
const rawEvent = jsonPayload?.event;
const event: PayloadEvent = isPayloadEvent(rawEvent) ? rawEvent : { trigger: "unknown" };
// resolve agent from jsonPayload with type guard
const jsonAgent = jsonPayload?.agent;
const resolvedAgent: AgentNameType | null =
agent ??
(jsonAgent !== undefined && jsonAgent !== "null" && isAgentName(jsonAgent) ? jsonAgent : null);
// build payload - precedence: inputs > jsonPayload > defaults
// note: modes are NOT in payload - they come from repoSettings in main()
return {
"~pullfrog": true as const,
agent: resolvedAgent,
prompt: inputs.prompt ?? jsonPayload?.prompt,
event,
effort: inputs.effort ?? jsonPayload?.effort ?? "auto",
web: inputs.web ?? jsonPayload?.web,
search: inputs.search ?? jsonPayload?.search,
write: inputs.write ?? jsonPayload?.write,
bash: inputs.bash ?? jsonPayload?.bash,
disableProgressComment: jsonPayload?.disableProgressComment === true,
comment_id: jsonPayload?.comment_id ?? null,
issue_id: jsonPayload?.issue_id ?? null,
pr_id: jsonPayload?.pr_id ?? null,
cwd: resolveCwd(inputs.cwd),
};
}
export type ResolvedPayload = ReturnType<typeof resolvePayload>;
+38
View File
@@ -0,0 +1,38 @@
import type { Octokit } from "@octokit/rest";
import packageJson from "../package.json" with { type: "json" };
import { log } from "./cli.ts";
import { createOctokit, parseRepoContext } from "./github.ts";
import { fetchRepoSettings, type RepoSettings } from "./repoSettings.ts";
export interface RepoData {
owner: string;
name: string;
octokit: Octokit;
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
repoSettings: RepoSettings;
}
/**
* Initialize GitHub connection: token, octokit, repo data, settings
*/
export async function resolveRepoData(token: string): Promise<RepoData> {
log.info(`» running Pullfrog v${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,
};
}
+71
View File
@@ -0,0 +1,71 @@
import type { AgentName, BashPermission, ToolPermission } from "../external.ts";
import type { RepoContext } from "./github.ts";
export interface Mode {
id: string;
name: string;
description: string;
prompt: string;
}
export interface RepoSettings {
defaultAgent: AgentName | null;
web: ToolPermission;
search: ToolPermission;
write: ToolPermission;
bash: BashPermission;
modes: Mode[];
}
export const DEFAULT_REPO_SETTINGS: RepoSettings = {
defaultAgent: null,
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
modes: [],
};
/**
* Fetch repository settings from the Pullfrog API
* Returns defaults if repo doesn't exist or fetch fails
*/
export async function fetchRepoSettings(params: {
token: string;
repoContext: RepoContext;
}): Promise<RepoSettings> {
const apiUrl = process.env.API_URL || "https://pullfrog.com";
const timeoutMs = 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(
`${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/settings`,
{
method: "GET",
headers: {
Authorization: `Bearer ${params.token}`,
"Content-Type": "application/json",
},
signal: controller.signal,
}
);
clearTimeout(timeoutId);
if (!response.ok) {
return DEFAULT_REPO_SETTINGS;
}
const settings = (await response.json()) as RepoSettings | null;
if (settings === null) {
return DEFAULT_REPO_SETTINGS;
}
return settings;
} catch {
clearTimeout(timeoutId);
return DEFAULT_REPO_SETTINGS;
}
}
+70
View File
@@ -0,0 +1,70 @@
import { type Agent, agents } from "../agents/index.ts";
import type { AgentName } from "../external.ts";
import { log } from "./cli.ts";
import type { ResolvedPayload } from "./payload.ts";
import type { RepoSettings } from "./repoSettings.ts";
/**
* Check if an agent has API keys available (from process.env)
*/
function agentHasApiKeys(agent: Agent): boolean {
// empty apiKeyNames means agent accepts any *API_KEY* env var
if (agent.apiKeyNames.length === 0) {
return Object.keys(process.env).some((key) => key.includes("API_KEY") && process.env[key]);
}
return agent.apiKeyNames.some((envKey) => !!process.env[envKey]);
}
function getAvailableAgents(): Agent[] {
return Object.values(agents).filter((agent) => agentHasApiKeys(agent));
}
export function resolveAgent(params: {
payload: ResolvedPayload;
repoSettings: RepoSettings;
}): Agent {
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
log.debug(
`» determineAgent: agentOverride=${agentOverride}, payload.agent=${params.payload.agent}, repoSettings.defaultAgent=${params.repoSettings.defaultAgent}`
);
const configuredAgentName =
agentOverride || params.payload.agent || params.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 || params.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;
}
+37
View File
@@ -0,0 +1,37 @@
import type { AgentResult, ToolPermissions } from "../agents/shared.ts";
import type { MainResult } from "../main.ts";
import { log } from "./cli.ts";
import type { ResolvedPayload } from "./payload.ts";
/**
* Compute tool permissions from inputs.
* For run action, bash defaults to restricted for public repos when unset.
*/
export function resolvePermissions(params: {
payload: ResolvedPayload;
isPublicRepo: boolean;
}): ToolPermissions {
return {
web: params.payload.web ?? "enabled",
search: params.payload.search ?? "enabled",
write: params.payload.write ?? "enabled",
bash: params.payload.bash ?? (params.isPublicRepo ? "restricted" : "enabled"),
};
}
export 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 || "",
};
}
+1 -1
View File
@@ -4,7 +4,7 @@
*/ */
import { agentsManifest } from "../external.ts"; import { agentsManifest } from "../external.ts";
import { getGitHubInstallationToken } from "./github.ts"; import { getGitHubInstallationToken } from "./token.ts";
function getAllSecrets(): string[] { function getAllSecrets(): string[] {
const secrets: string[] = []; const secrets: string[] = [];
+39 -30
View File
@@ -1,8 +1,11 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs"; import { existsSync, rmSync } from "node:fs";
import type { Payload } from "../external.ts"; import { mkdtemp } from "node:fs/promises";
import type { ToolState } from "../main.ts"; import { tmpdir } from "node:os";
import { join } from "node:path";
import type { PayloadEvent } from "../external.ts";
import { checkoutPrBranch } from "../mcp/checkout.ts"; import { checkoutPrBranch } from "../mcp/checkout.ts";
import type { ToolState } from "../mcp/server.ts";
import { log } from "./cli.ts"; import { log } from "./cli.ts";
import type { OctokitWithPlugins } from "./github.ts"; import type { OctokitWithPlugins } from "./github.ts";
import { $ } from "./shell.ts"; import { $ } from "./shell.ts";
@@ -11,6 +14,16 @@ export interface SetupOptions {
tempDir: string; tempDir: string;
} }
/**
* Create a shared temp directory for the action
*/
export async function createTempDirectory(): Promise<string> {
const sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-"));
process.env.PULLFROG_TEMP_DIR = sharedTempDir;
log.info(`» created temp dir at ${sharedTempDir}`);
return sharedTempDir;
}
/** /**
* Setup the test repository for running actions * Setup the test repository for running actions
*/ */
@@ -26,13 +39,30 @@ export function setupTestRepo(options: SetupOptions): void {
$("git", ["clone", `git@github.com:${repo}.git`, tempDir]); $("git", ["clone", `git@github.com:${repo}.git`, tempDir]);
} }
interface SetupGitParams {
token: string;
owner: string;
name: string;
event: PayloadEvent;
octokit: OctokitWithPlugins;
toolState: ToolState;
}
/** /**
* Setup git configuration to avoid identity errors * Setup git configuration and authentication for the repository.
* Uses --local flag to scope config to the current repo only * - Configures git identity (user.email, user.name)
* Only sets defaults if not already configured (respects workflow config) * - Sets up authentication via token
* - For PR events, checks out the PR branch using shared helper
*
* FORK PR ARCHITECTURE:
* - origin: always points to BASE REPO (where PR targets)
* - checkoutPrBranch sets per-branch pushRemote config for fork PRs
* - checkout_pr returns the PR diff via GitHub API (authoritative source)
*/ */
export function setupGitConfig(): void { export async function setupGit(params: SetupGitParams): Promise<void> {
const repoDir = process.cwd(); const repoDir = process.cwd();
// 1. configure git identity
log.info("» setting up git configuration..."); log.info("» setting up git configuration...");
try { try {
// check current config - only set defaults if not configured or using generic bot // check current config - only set defaults if not configured or using generic bot
@@ -79,29 +109,8 @@ export function setupGitConfig(): void {
`Failed to set git config: ${error instanceof Error ? error.message : String(error)}` `Failed to set git config: ${error instanceof Error ? error.message : String(error)}`
); );
} }
}
interface SetupGitAuthParams {
token: string;
owner: string;
name: string;
payload: Payload;
octokit: OctokitWithPlugins;
toolState: ToolState;
}
/**
* Setup git authentication for the repository.
* For PR events, uses the shared checkoutPrBranch helper (also used by checkout_pr MCP tool).
*
* FORK PR ARCHITECTURE:
* - origin: always points to BASE REPO (where PR targets)
* - checkoutPrBranch sets per-branch pushRemote config for fork PRs
* - checkout_pr returns the PR diff via GitHub API (authoritative source)
*/
export async function setupGitAuth(params: SetupGitAuthParams): Promise<void> {
const repoDir = process.cwd();
// 2. setup authentication
log.info("» setting up git authentication..."); log.info("» setting up git authentication...");
// remove existing git auth headers that actions/checkout might have set // remove existing git auth headers that actions/checkout might have set
@@ -116,7 +125,7 @@ export async function setupGitAuth(params: SetupGitAuthParams): Promise<void> {
} }
// non-PR events: set up origin with token, stay on default branch // non-PR events: set up origin with token, stay on default branch
if (params.payload.event.is_pr !== true || !params.payload.event.issue_number) { if (params.event.is_pr !== true || !params.event.issue_number) {
const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`; const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`;
$("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir }); $("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
log.info("» updated origin URL with authentication token"); log.info("» updated origin URL with authentication token");
@@ -124,7 +133,7 @@ export async function setupGitAuth(params: SetupGitAuthParams): Promise<void> {
} }
// PR event: checkout PR branch using shared helper // PR event: checkout PR branch using shared helper
const prNumber = params.payload.event.issue_number; const prNumber = params.event.issue_number;
// ensure origin is configured with auth token before checkout // ensure origin is configured with auth token before checkout
const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`; const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`;
+1 -1
View File
@@ -3,7 +3,7 @@ import { spawn as nodeSpawn } from "node:child_process";
export interface SpawnOptions { export interface SpawnOptions {
cmd: string; cmd: string;
args: string[]; args: string[];
env?: Record<string, string>; env?: NodeJS.ProcessEnv;
input?: string; input?: string;
timeout?: number; timeout?: number;
cwd?: string; cwd?: string;
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import * as core from "@actions/core";
import { log } from "./cli.ts";
import { acquireNewToken } from "./github.ts";
// re-export for get-installation-token action
export { acquireNewToken as acquireInstallationToken };
export { revokeGitHubInstallationToken as revokeInstallationToken };
// store token in memory instead of process.env
let githubInstallationToken: string | undefined;
/**
* Setup GitHub installation token for the action
*/
export async function resolveInstallationToken() {
assert(!githubInstallationToken, "GitHub installation token is already set.");
const acquiredToken = await acquireNewToken();
core.setSecret(acquiredToken);
githubInstallationToken = acquiredToken;
return {
token: acquiredToken,
[Symbol.asyncDispose]() {
githubInstallationToken = undefined;
return revokeGitHubInstallationToken(acquiredToken);
},
};
}
/**
* Get the GitHub installation token from memory
*/
export function getGitHubInstallationToken(): string {
assert(
githubInstallationToken,
"GitHub installation token not set. Call resolveInstallationToken first."
);
return githubInstallationToken;
}
export async function revokeGitHubInstallationToken(token: string): Promise<void> {
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
try {
await fetch(`${apiUrl}/installation/token`, {
method: "DELETE",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
log.debug("» installation token revoked");
} catch (error) {
log.warning(
`Failed to revoke installation token: ${error instanceof Error ? error.message : String(error)}`
);
}
}
+37
View File
@@ -0,0 +1,37 @@
import { log } from "./cli.ts";
import type { RepoData } from "./repoData.ts";
import { fetchWorkflowRunInfo } from "./workflowRun.ts";
/**
* Resolve GitHub Actions workflow run context (runId, jobId, progress comment)
*/
export async function resolveRunId(
repoData: RepoData
): Promise<{ runId: string; jobId: string | undefined }> {
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 repoData.octokit.rest.actions.listJobsForWorkflowRun({
owner: repoData.owner,
repo: repoData.name,
run_id: parseInt(runId, 10),
});
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
if (matchingJob) {
jobId = String(matchingJob.id);
log.debug(`» found job ID: ${jobId}`);
}
}
return { runId, jobId };
}
+37
View File
@@ -0,0 +1,37 @@
export interface WorkflowRunInfo {
progressCommentId: string | null;
issueNumber: number | null;
}
/**
* Fetch workflow run info from the Pullfrog API
* Returns the pre-created progress comment ID if one exists
*/
export async function fetchWorkflowRunInfo(runId: string): Promise<WorkflowRunInfo> {
const apiUrl = process.env.API_URL || "https://pullfrog.com";
const timeoutMs = 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${apiUrl}/api/workflow-run/${runId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return { progressCommentId: null, issueNumber: null };
}
const data = (await response.json()) as WorkflowRunInfo;
return data;
} catch {
clearTimeout(timeoutId);
return { progressCommentId: null, issueNumber: null };
}
}