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"
description: "Execute coding agents with a prompt"
author: "Pullfrog"
inputs:
prompt:
description: "Prompt to send to the agent"
description: "Prompt to send to the agent (string or JSON payload)"
required: true
effort:
description: "Effort level: mini (fast), auto (default), max (most capable)"
+21 -16
View File
@@ -1,9 +1,10 @@
import { type Options, query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import packageJson from "../package.json" with { type: "json" };
import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts";
import { type AgentConfig, agent, createAgentEnv, installFromNpmTarball } from "./shared.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { type AgentRunContext, agent } from "./shared.ts";
// Model selection based on effort level
// 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.
*/
function buildDisallowedTools(ctx: AgentConfig): string[] {
function buildDisallowedTools(ctx: AgentRunContext): string[] {
const disallowed: string[] = [];
if (ctx.tools.web === "disabled") disallowed.push("WebFetch");
if (ctx.tools.search === "disabled") disallowed.push("WebSearch");
@@ -33,31 +34,33 @@ function buildDisallowedTools(ctx: AgentConfig): string[] {
return disallowed;
}
export const claude = agent({
name: "claude",
install: async () => {
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({
name: "claude",
install: installClaude,
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
delete process.env.ANTHROPIC_API_KEY;
const prompt = addInstructions(ctx);
log.group("Full prompt", () => log.info(prompt));
// select model based on effort level
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
const disallowedTools = buildDisallowedTools(ctx);
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)
@@ -65,14 +68,16 @@ export const claude = agent({
const queryOptions: Options = {
permissionMode: "bypassPermissions" as const,
disallowedTools,
mcpServers: ctx.mcpServers,
mcpServers: {
[ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl },
},
model,
pathToClaudeCodeExecutable: ctx.cliPath,
env: createAgentEnv({ ANTHROPIC_API_KEY: ctx.apiKey }),
pathToClaudeCodeExecutable: cliPath,
env: process.env,
};
const queryInstance = query({
prompt,
prompt: ctx.instructions,
options: queryOptions,
});
+23 -32
View File
@@ -8,14 +8,10 @@ import {
type ThreadOptions,
} from "@openai/codex-sdk";
import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts";
import {
agent,
type AgentConfig,
installFromNpmTarball,
setupProcessAgentEnv,
} from "./shared.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { type AgentRunContext, agent } from "./shared.ts";
// model configuration based on effort level
const codexModel: Record<Effort, string> = {
@@ -34,19 +30,14 @@ const codexReasoningEffort: Record<Effort, ModelReasoningEffort | undefined> = {
max: "high",
};
function writeCodexConfig(ctx: AgentConfig): string {
const tempHome = process.env.PULLFROG_TEMP_DIR!;
const codexDir = join(tempHome, ".codex");
function writeCodexConfig(ctx: AgentRunContext): string {
const codexDir = join(ctx.tmpdir, ".codex");
mkdirSync(codexDir, { recursive: true });
const configPath = join(codexDir, "config.toml");
// build MCP servers section
const mcpServerSections: string[] = [];
for (const [name, config] of Object.entries(ctx.mcpServers)) {
if (config.type !== "http") continue;
log.info(`» adding MCP server '${name}' at ${config.url}`);
mcpServerSections.push(`[mcp_servers.${name}]\nurl = "${config.url}"`);
}
log.info(`» adding MCP server '${ghPullfrogMcpName}' at ${ctx.mcpServerUrl}`);
const mcpServerSections = [`[mcp_servers.${ghPullfrogMcpName}]\nurl = "${ctx.mcpServerUrl}"`];
// build features section for tool control
// disable native shell if bash is "disabled" or "restricted"
@@ -74,42 +65,42 @@ ${mcpServerSections.join("\n\n")}
return codexDir;
}
export const codex = agent({
name: "codex",
install: async () => {
async function installCodex(): Promise<string> {
return await installFromNpmTarball({
packageName: "@openai/codex",
version: "latest",
executablePath: "bin/codex.js",
});
},
}
export const codex = agent({
name: "codex",
install: installCodex,
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
const configDir = join(tempHome, ".config", "codex");
const configDir = join(ctx.tmpdir, ".config", "codex");
mkdirSync(configDir, { recursive: true });
const codexDir = writeCodexConfig(ctx);
setupProcessAgentEnv({
OPENAI_API_KEY: ctx.apiKey,
HOME: tempHome,
CODEX_HOME: codexDir, // point Codex to our config directory
});
process.env.HOME = ctx.tmpdir;
process.env.CODEX_HOME = codexDir;
// get model and reasoning effort based on effort level
const model = codexModel[ctx.effort];
const modelReasoningEffort = codexReasoningEffort[ctx.effort];
log.info(`Using model: ${model}`);
log.info(`» using model: ${model}`);
if (modelReasoningEffort) {
log.info(`Using modelReasoningEffort: ${modelReasoningEffort}`);
log.info(`» using modelReasoningEffort: ${modelReasoningEffort}`);
}
// Configure Codex
const codexOptions: CodexOptions = {
apiKey: ctx.apiKey,
codexPathOverride: ctx.cliPath,
codexPathOverride: cliPath,
};
const codex = new Codex(codexOptions);
@@ -128,13 +119,13 @@ export const codex = agent({
};
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);
try {
const streamedTurn = await thread.runStreamed(addInstructions(ctx));
const streamedTurn = await thread.runStreamed(ctx.instructions);
let finalOutput = "";
for await (const event of streamedTurn.events) {
+31 -37
View File
@@ -3,9 +3,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts";
import { type AgentConfig, agent, createAgentEnv, installFromCurl } from "./shared.ts";
import { installFromCurl } from "../utils/install.ts";
import { type AgentRunContext, agent } from "./shared.ts";
// effort configuration for Cursor
// only "max" overrides the model; mini/auto use default ("auto")
@@ -87,15 +88,20 @@ type CursorEvent =
| CursorToolCallEvent
| CursorResultEvent;
export const cursor = agent({
name: "cursor",
install: async () => {
async function installCursor(): Promise<string> {
return await installFromCurl({
installUrl: "https://cursor.com/install",
executableName: "cursor-agent",
});
},
}
export const cursor = agent({
name: "cursor",
install: installCursor,
run: async (ctx) => {
// install CLI at start of run
const cliPath = await installCursor();
configureCursorMcpServers(ctx);
configureCursorTools(ctx);
@@ -108,7 +114,7 @@ export const cursor = agent({
try {
const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8"));
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 {
modelOverride = cursorEffortModels[ctx.effort];
}
@@ -120,9 +126,9 @@ export const cursor = agent({
}
if (modelOverride) {
log.info(`Using model: ${modelOverride} (effort: ${ctx.effort})`);
log.info(`» using model: ${modelOverride}, effort=${ctx.effort}`);
} 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
@@ -196,11 +202,14 @@ export const cursor = agent({
};
try {
const fullPrompt = addInstructions(ctx);
log.group("Full prompt", () => log.info(fullPrompt));
// 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
if (modelOverride) {
@@ -210,16 +219,14 @@ export const cursor = agent({
// always use --force since permissions are controlled via cli-config.json
const cursorArgs = [...baseArgs, "--force"];
log.info("Running Cursor CLI...");
log.info("» running Cursor CLI...");
const startTime = Date.now();
return new Promise((resolve) => {
const child = spawn(ctx.cliPath, cursorArgs, {
const child = spawn(cliPath, cursorArgs, {
cwd: process.cwd(),
env: createAgentEnv({
CURSOR_API_KEY: ctx.apiKey,
}),
env: process.env,
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
// it was unable to find the macOS keychain and would fail
// temp solution is to stick with the actual $HOME
function configureCursorMcpServers(ctx: AgentConfig): void {
function configureCursorMcpServers(ctx: AgentRunContext): void {
const realHome = homedir();
const cursorConfigDir = join(realHome, ".cursor");
const mcpConfigPath = join(cursorConfigDir, "mcp.json");
mkdirSync(cursorConfigDir, { recursive: true });
// Convert to Cursor's expected format (HTTP config)
const cursorMcpServers: Record<string, { type: string; url: string }> = {};
for (const [serverName, serverConfig] of Object.entries(ctx.mcpServers)) {
if (serverConfig.type !== "http") {
throw new Error(
`Unsupported MCP server type for Cursor: ${(serverConfig as any).type || "unknown"}`
);
}
cursorMcpServers[serverName] = {
type: "http",
url: serverConfig.url,
const mcpServers = {
[ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl },
};
}
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers: cursorMcpServers }, null, 2), "utf-8");
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8");
log.info(`» MCP config written to ${mcpConfigPath}`);
}
@@ -350,10 +345,9 @@ interface CursorCliConfig {
/**
* Configure Cursor CLI tool permissions via cli-config.json.
*
* Config path: $XDG_CONFIG_HOME/cursor/ (not ~/.cursor/) because createAgentEnv
* sets XDG_CONFIG_HOME=$HOME/.config.
* Config path: $HOME/.config/cursor/ (not ~/.cursor/).
*/
function configureCursorTools(ctx: AgentConfig): void {
function configureCursorTools(ctx: AgentRunContext): void {
const realHome = homedir();
const cursorConfigDir = join(realHome, ".config", "cursor");
const cliConfigPath = join(cursorConfigDir, "cli-config.json");
+32 -27
View File
@@ -2,10 +2,12 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Effort } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import { log } from "../utils/cli.ts";
import { installFromGithub } from "../utils/install.ts";
import { spawn } from "../utils/subprocess.ts";
import { addInstructions } from "./instructions.ts";
import { type AgentConfig, agent, createAgentEnv, installFromGithub } from "./shared.ts";
import { getGitHubInstallationToken } from "../utils/token.ts";
import { type AgentRunContext, agent } from "./shared.ts";
// effort configuration: model + thinking level
// thinkingLevel is set via settings.json modelConfig.generateContentConfig.thinkingConfig
@@ -156,29 +158,38 @@ const messageHandlers = {
},
};
export const gemini = agent({
name: "gemini",
install: async (githubInstallationToken?: string) => {
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({
name: "gemini",
install: installGemini,
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);
if (!ctx.apiKey) {
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
// 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 stdoutBuffer = "";
@@ -186,8 +197,8 @@ export const gemini = agent({
try {
const result = await spawn({
cmd: "node",
args: [ctx.cliPath, ...args],
env: createAgentEnv({ GEMINI_API_KEY: ctx.apiKey }),
args: [cliPath, ...args],
env: process.env,
onStdout: async (chunk) => {
const text = chunk.toString();
finalOutput += text;
@@ -242,7 +253,7 @@ export const gemini = agent({
}
finalOutput = finalOutput || result.stdout || "Gemini CLI completed successfully.";
log.info(" Gemini CLI completed successfully");
log.info("» Gemini CLI completed successfully");
return {
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
*/
function configureGeminiSettings(ctx: AgentConfig): string {
function configureGeminiSettings(ctx: AgentRunContext): string {
const { model, thinkingLevel } = geminiEffortConfig[ctx.effort];
log.info(`Using model: ${model}, thinkingLevel: ${thinkingLevel}`);
log.info(`» using model: ${model}, thinkingLevel: ${thinkingLevel}`);
const realHome = homedir();
const geminiConfigDir = join(realHome, ".gemini");
@@ -299,19 +310,13 @@ function configureGeminiSettings(ctx: AgentConfig): string {
includeTools?: string[];
excludeTools?: string[];
}
const geminiMcpServers: Record<string, GeminiMcpServerConfig> = {};
for (const [serverName, serverConfig] of Object.entries(ctx.mcpServers)) {
if (serverConfig.type !== "http") {
throw new Error(
`Unsupported MCP server type for Gemini: ${(serverConfig as { type?: string }).type || "unknown"}`
);
}
geminiMcpServers[serverName] = {
httpUrl: serverConfig.url,
log.info(`» adding MCP server '${ghPullfrogMcpName}' at ${ctx.mcpServerUrl}...`);
const geminiMcpServers: Record<string, GeminiMcpServerConfig> = {
[ghPullfrogMcpName]: {
httpUrl: ctx.mcpServerUrl,
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)
const exclude: string[] = [];
@@ -340,7 +345,7 @@ function configureGeminiSettings(ctx: AgentConfig): string {
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
log.info(`» Gemini settings written to ${settingsPath}`);
if (exclude.length > 0) {
log.info(`🔒 excluded tools: ${exclude.join(", ")}`);
log.info(`» excluded tools: ${exclude.join(", ")}`);
}
return model;
+50 -77
View File
@@ -1,70 +1,59 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { ghPullfrogMcpName } from "../external.ts";
import { log } from "../utils/cli.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { spawn } from "../utils/subprocess.ts";
import { addInstructions } from "./instructions.ts";
import {
agent,
type AgentConfig,
createAgentEnv,
installFromNpmTarball,
setupProcessAgentEnv,
} from "./shared.ts";
import { type AgentRunContext, agent } from "./shared.ts";
export const opencode = agent({
name: "opencode",
install: async () => {
async function installOpencode(): Promise<string> {
return await installFromNpmTarball({
packageName: "opencode-ai",
version: "latest",
executablePath: "bin/opencode",
installDependencies: true,
});
},
}
export const opencode = agent({
name: "opencode",
install: installOpencode,
run: async (ctx) => {
// install CLI at start of run
const cliPath = await installOpencode();
// 1. configure home/config directory
const tempHome = process.env.PULLFROG_TEMP_DIR!;
const tempHome = ctx.tmpdir;
const configDir = join(tempHome, ".config", "opencode");
mkdirSync(configDir, { recursive: true });
configureOpenCode(ctx);
const prompt = addInstructions(ctx);
log.group("Full prompt", () => log.info(prompt));
// 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
setupProcessAgentEnv({ HOME: tempHome });
process.env.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,
// and OpenCode follows XDG spec (checks XDG_CONFIG_HOME before falling back to $HOME/.config)
const env: Record<string, string> = {
...createAgentEnv({ HOME: tempHome }),
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: tempHome,
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
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)
const repoDir = process.cwd();
log.info(`🚀 Starting OpenCode CLI: ${ctx.cliPath} ${args.join(" ")}`);
log.info(`📁 Working directory: ${repoDir}`);
log.debug(`🏠 HOME: ${env.HOME}`);
log.debug(`📋 XDG_CONFIG_HOME: ${env.XDG_CONFIG_HOME}`);
log.debug(`» starting OpenCode: ${cliPath} ${args.join(" ")}`);
log.debug(`» working directory: ${repoDir}`);
log.debug(`» HOME: ${env.HOME}`);
log.debug(`» XDG_CONFIG_HOME: ${env.XDG_CONFIG_HOME}`);
const startTime = Date.now();
let lastActivityTime = startTime;
@@ -73,7 +62,7 @@ export const opencode = agent({
let output = "";
let stdoutBuffer = ""; // buffer for incomplete lines across chunks
const result = await spawn({
cmd: ctx.cliPath,
cmd: cliPath,
args,
cwd: repoDir,
env,
@@ -111,7 +100,7 @@ export const opencode = agent({
? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})`
: " (OpenCode may be processing internally - LLM calls, planning, etc.)";
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();
@@ -121,7 +110,7 @@ export const opencode = agent({
} else {
// log unhandled event types for visibility
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 {
@@ -145,7 +134,7 @@ export const opencode = agent({
});
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)
if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) {
@@ -185,29 +174,15 @@ export const opencode = agent({
* Configure OpenCode via opencode.json config file.
* Builds complete config with MCP servers and permissions in a single write to avoid race conditions.
*/
function configureOpenCode(ctx: AgentConfig): void {
const tempHome = process.env.PULLFROG_TEMP_DIR!;
const configDir = join(tempHome, ".config", "opencode");
function configureOpenCode(ctx: AgentRunContext): void {
const configDir = join(ctx.tmpdir, ".config", "opencode");
mkdirSync(configDir, { recursive: true });
const configPath = join(configDir, "opencode.json");
// build MCP servers config
const opencodeMcpServers: Record<string, { type: "remote"; url: string }> = {};
for (const [serverName, serverConfig] of Object.entries(ctx.mcpServers)) {
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,
const opencodeMcpServers = {
[ghPullfrogMcpName]: { type: "remote" as const, url: ctx.mcpServerUrl },
};
}
// build permission object based on tool permissions
// 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 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}`);
}
@@ -396,10 +371,10 @@ let stepHistory: Array<{ stepId: string; stepType: string; toolCalls: string[] }
const messageHandlers = {
init: (event: OpenCodeInitEvent) => {
// initialization event - reset state
log.info(
`🔵 OpenCode init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}`
log.debug(
`» 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 = "";
accumulatedTokens = { input: 0, output: 0 };
tokensLogged = false;
@@ -410,20 +385,20 @@ const messageHandlers = {
if (message) {
if (event.delta) {
// delta messages are streaming thoughts/reasoning
log.info(
`💭 OpenCode thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}`
log.debug(
`» OpenCode thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}`
);
} else {
// complete messages
log.info(
`💬 OpenCode message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}`
log.debug(
`» OpenCode message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}`
);
finalOutput = message;
}
}
} else if (event.role === "user") {
log.info(
`💬 OpenCode message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}`
log.debug(
`» 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;
toolCallTimings.delete(toolId);
const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : "";
log.info(
`🔧 OpenCode tool_result${stepContext}: id=${toolId}, status=${status}, duration=${toolDuration}ms`
log.debug(
`» OpenCode tool_result${stepContext}: id=${toolId}, status=${status}, duration=${toolDuration}ms`
);
if (output) {
log.debug(` output: ${typeof output === "string" ? output : JSON.stringify(output)}`);
}
if (toolDuration > 5000) {
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") {
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) => {
@@ -526,19 +501,17 @@ const messageHandlers = {
const duration = event.stats?.duration_ms || 0;
const toolCalls = event.stats?.tool_calls || 0;
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") {
log.error(` OpenCode CLI failed: ${JSON.stringify(event)}`);
log.error(`» OpenCode CLI failed: ${JSON.stringify(event)}`);
} else {
// 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 outputTokens = event.stats?.output_tokens || accumulatedTokens.output || 0;
const totalTokens = event.stats?.total_tokens || inputTokens + outputTokens;
log.info(
`📊 OpenCode final stats: input=${inputTokens}, output=${outputTokens}, total=${totalTokens}, tool_calls=${toolCalls}, duration=${duration}ms`
);
log.info(`» run complete: tool_calls=${toolCalls}, duration=${duration}ms`);
if ((inputTokens > 0 || outputTokens > 0) && !tokensLogged) {
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 AgentManifest,
type AgentName,
agentsManifest,
type Effort,
type Payload,
} from "../external.ts";
import { type AgentManifest, type AgentName, agentsManifest, type Effort } from "../external.ts";
import { log } from "../utils/cli.ts";
import { getGitHubInstallationToken } from "../utils/github.ts";
/**
* Result returned by agent execution
@@ -26,16 +12,6 @@ export interface AgentResult {
metadata?: Record<string, unknown>;
}
/**
* Repo info for agent context
*/
export interface RepoInfo {
owner: string;
name: string;
defaultBranch: string;
isPublic: boolean;
}
/**
* Tool permission levels
*/
@@ -53,476 +29,37 @@ export interface ToolPermissions {
}
/**
* Configuration for agent creation
* Minimal context passed to agent.run()
*/
export interface AgentConfig {
apiKey: string;
apiKeys?: Record<string, string>; // all available keys for this agent
payload: Payload;
mcpServers: Record<string, McpHttpServerConfig>;
cliPath: string;
repo: RepoInfo;
export interface AgentRunContext {
effort: Effort;
tools: ToolPermissions;
}
/**
* Add agent-specific vars to a whitelisted environment object for agent subprocesses.
*
* @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;
mcpServerUrl: string;
tmpdir: string;
instructions: string;
apiKey: string;
apiKeys: Record<string, string>;
}
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 {
name: AgentName;
install: (token?: string) => Promise<string>;
run: (config: AgentConfig) => Promise<AgentResult>;
run: (ctx: AgentRunContext) => Promise<AgentResult>;
}
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();
+26669 -26814
View File
File diff suppressed because it is too large Load Diff
+3 -9
View File
@@ -1,21 +1,15 @@
#!/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 { Inputs, main } from "./main.ts";
import { main } from "./main.ts";
async function run(): Promise<void> {
try {
const inputs = Inputs.assert({
prompt: core.getInput("prompt", { required: true }),
effort: core.getInput("effort") || "auto",
cwd: core.getInput("cwd") || null,
});
const result = await main(inputs);
const result = await main(core);
if (!result.success) {
throw new Error(result.error || "Agent execution failed");
+1 -17
View File
@@ -75,20 +75,4 @@ await build({
plugins: [stripShebangPlugin],
});
// Build the run sub-action
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!");
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.
* Other files in action/ re-export from this file for backward compatibility.
*/
import { type } from "arktype";
import type { Mode } from "./modes.ts";
// mcp name constant
export const ghPullfrogMcpName = "gh_pullfrog";
export interface AgentManifest {
displayName: string;
/** empty array means accepts any *API_KEY* env var */
apiKeyNames: string[];
url: string;
}
@@ -40,7 +40,7 @@ export const agentsManifest = {
},
opencode: {
displayName: "OpenCode",
apiKeyNames: [], // empty array means OpenCode accepts any API_KEY from environment
apiKeyNames: [],
url: "https://opencode.ai",
},
} as const satisfies Record<string, AgentManifest>;
@@ -256,60 +256,35 @@ export type PayloadEvent =
| ImplementPlanEvent
| UnknownEvent;
// | undefined needed on optional props for exactOptionalPropertyTypes
export interface DispatchOptions {
/**
* When true, disables progress comment (no "leaping into action" comment, no report_progress tool)
*/
readonly disableProgressComment?: true;
/**
* Granular tool permissions set server-side for dispatch workflows.
*/
readonly web?: ToolPermission;
readonly search?: ToolPermission;
readonly write?: ToolPermission;
readonly bash?: BashPermission;
/** 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 */
web?: ToolPermission | undefined;
search?: ToolPermission | undefined;
write?: ToolPermission | undefined;
bash?: BashPermission | undefined;
}
export type MutableDispatchOptions = {
-readonly [K in keyof DispatchOptions]: DispatchOptions[K];
};
// payload type for agent execution
export interface Payload extends DispatchOptions {
// writeable payload type for building payloads
export interface WriteablePayload extends DispatchOptions {
"~pullfrog": true;
/**
* Agent slug identifier (e.g., "claude", "codex", "gemini")
*/
readonly agent: AgentName | null;
/**
* The prompt/instructions for the agent to execute
*/
readonly prompt: string;
/**
* Event data from webhook payload.
* Discriminated union based on trigger field.
*/
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;
/** agent slug identifier (e.g., "claude", "codex", "gemini") */
agent: AgentName | null;
/** the prompt/instructions for the agent to execute */
prompt: string;
/** event data from webhook payload - discriminated union based on trigger field */
event: PayloadEvent;
/** effort level for model selection (mini, auto, max) - defaults to "auto" */
effort?: Effort | undefined;
/** optional IDs of the issue, PR, or comment that the agent is working on */
comment_id?: number | null | undefined;
issue_id?: number | null | undefined;
pr_id?: number | null | undefined;
/** working directory for the agent */
cwd?: string | null | undefined;
}
// 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: {
trigger: "workflow_dispatch",
},
modes: [],
} satisfies Payload;
-1
View File
@@ -8,5 +8,4 @@ export default {
event: {
trigger: "workflow_dispatch",
},
modes: [],
} 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
var core3 = __toESM(require_core(), 1);
var core4 = __toESM(require_core(), 1);
// utils/github.ts
var core2 = __toESM(require_core(), 1);
import { createSign } from "node:crypto";
// utils/token.ts
var core3 = __toESM(require_core(), 1);
// utils/log.ts
var core = __toESM(require_core(), 1);
@@ -25635,7 +25634,7 @@ var log = {
},
/** Print success message */
success: (...args) => {
core.info(`\u2705 ${formatArgs(args)}`);
core.info(`\xBB ${formatArgs(args)}`);
},
/** Print debug message (only if LOG_LEVEL=debug) */
debug: (...args) => {
@@ -25668,6 +25667,10 @@ function formatJsonValue(value) {
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
var defaultShouldRetry = (error2) => {
if (!(error2 instanceof Error)) return false;
@@ -25844,6 +25847,19 @@ async function acquireNewToken(opts) {
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) {
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
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
var STATE_TOKEN = "token";
var STATE_IS_POST = "isPost";
async function main() {
core3.saveState(STATE_IS_POST, "true");
const reposInput = core3.getInput("repos");
core4.saveState(STATE_IS_POST, "true");
const reposInput = core4.getInput("repos");
const additionalRepos = reposInput ? reposInput.split(",").map((r) => r.trim()).filter(Boolean) : [];
const token = await acquireInstallationToken({ repos: additionalRepos });
core3.setSecret(token);
core3.saveState(STATE_TOKEN, token);
core3.setOutput("token", token);
const token = await acquireNewToken({ repos: additionalRepos });
core4.setSecret(token);
core4.saveState(STATE_TOKEN, token);
core4.setOutput("token", token);
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() {
const token = core3.getState(STATE_TOKEN);
const token = core4.getState(STATE_TOKEN);
if (!token) {
core3.debug("no token found in state, skipping revocation");
core4.debug("no token found in state, skipping revocation");
return;
}
await revokeInstallationToken(token);
core3.info("\xBB installation token revoked");
await revokeGitHubInstallationToken(token);
core4.info("\xBB installation token revoked");
}
async function run() {
try {
const isPost = core3.getState(STATE_IS_POST) === "true";
const isPost = core4.getState(STATE_IS_POST) === "true";
if (isPost) {
await post();
} else {
@@ -25915,7 +25912,7 @@ async function run() {
}
} catch (error2) {
const message = error2 instanceof Error ? error2.message : String(error2);
core3.setFailed(message);
core4.setFailed(message);
}
}
await run();
+1 -1
View File
@@ -6,7 +6,7 @@
*/
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_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
*/
export type { Agent, AgentConfig, AgentResult } from "./agents/shared.ts";
export type { Agent, AgentRunContext, AgentResult } from "./agents/shared.ts";
export {
type Inputs as ExecutionInputs,
type MainResult,
+76 -526
View File
@@ -1,49 +1,20 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import { flatMorph } from "@ark/util";
import type { Octokit } from "@octokit/rest";
import { encode as toonEncode } from "@toon-format/toon";
import { type } from "arktype";
import { type Agent, agents } from "./agents/index.ts";
import type { AgentResult, 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 { ensureProgressCommentUpdated } from "./mcp/comment.ts";
import { startMcpHttpServer, type ToolContext, type ToolState } from "./mcp/server.ts";
import { computeModes } from "./modes.ts";
import { validateApiKey } from "./utils/apiKeys.ts";
import { log } from "./utils/cli.ts";
import { reportErrorToComment } from "./utils/errorReport.ts";
import { createOctokit, parseRepoContext, setupGitHubInstallationToken } from "./utils/github.ts";
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts";
import { resolveInstructions } from "./utils/instructions.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 { resolveInstallationToken } from "./utils/token.ts";
import { resolveRunId } from "./utils/workflow.ts";
// tool permission enum types for inputs
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 { Inputs } from "./utils/payload.ts";
export interface MainResult {
success: boolean;
@@ -51,163 +22,93 @@ export interface MainResult {
error?: string | undefined;
}
// intermediate result types for deterministic context building
interface GitHubSetup {
owner: string;
name: string;
octokit: Octokit;
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
repoSettings: RepoSettings;
}
export async function main(core: {
getInput: (name: string, options?: { required?: boolean }) => string;
}): Promise<MainResult> {
// store original GITHUB_TOKEN
process.env.ORIGINAL_GITHUB_TOKEN = process.env.GITHUB_TOKEN;
type ApiKeySetup =
| { success: true; apiKey: string; apiKeys: Record<string, string> }
| { success: false; error: string };
export async function main(inputs: Inputs): Promise<MainResult> {
// change to cwd input or GITHUB_WORKSPACE (where actions/checkout puts the repo)
// JavaScript actions run from the action's directory, not the checked out repo
let cwd = inputs.cwd || process.env.GITHUB_WORKSPACE;
if (inputs.cwd && !isAbsolute(inputs.cwd) && process.env.GITHUB_WORKSPACE) {
cwd = resolve(process.env.GITHUB_WORKSPACE, inputs.cwd);
}
if (cwd && process.cwd() !== cwd) {
log.debug(`changing to working directory: ${cwd}`);
process.chdir(cwd);
const payload = resolvePayload(core);
if (payload.cwd && process.cwd() !== payload.cwd) {
process.chdir(payload.cwd);
}
const timer = new Timer();
// `await using` ensures the token is automatically revoked when the function exits
await using tokenRef = await setupGitHubInstallationToken();
let payload: Payload | undefined;
await using tokenRef = await resolveInstallationToken();
process.env.GITHUB_TOKEN = tokenRef.token;
try {
// phase 1: parse and validate inputs
payload = parsePayload(inputs);
Inputs.assert(inputs);
setupGitConfig();
const repoData = await resolveRepoData(tokenRef.token);
const tmpdir = await createTempDirectory();
timer.checkpoint("repoData");
// phase 2: fast setup (github + temp dir)
const [githubSetup, sharedTempDir] = await Promise.all([
initializeGitHub(tokenRef.token),
createTempDirectory(),
]);
timer.checkpoint("githubSetup");
const agent = resolveAgent({ payload, repoSettings: repoData.repoSettings });
// phase 3: resolve agent (needs repo settings)
const agent = resolveAgent({
const { apiKey, apiKeys } = validateApiKey({
agent,
owner: repoData.owner,
name: repoData.name,
});
// compute tool permissions early
const tools = resolvePermissions({
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 [cliPath] = await Promise.all([
installAgentCli({ agent, token: tokenRef.token }),
setupGitAuth({
await setupGit({
token: tokenRef.token,
owner: githubSetup.owner,
name: githubSetup.name,
payload: resolvedPayload,
octokit: githubSetup.octokit,
owner: repoData.owner,
name: repoData.name,
event: payload.event,
octokit: repoData.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}`);
}
}
timer.checkpoint("git");
const modes = [
...computeModes({ disableProgressComment: payload.disableProgressComment }),
...repoData.repoSettings.modes,
];
const { runId, jobId } = await resolveRunId(repoData);
// phase 8: build tool context and start MCP server
const toolContext: ToolContext = {
owner: githubSetup.owner,
name: githubSetup.name,
owner: repoData.owner,
name: repoData.name,
repo: { default_branch: repoData.repo.default_branch, private: repoData.repo.private },
githubInstallationToken: tokenRef.token,
octokit: githubSetup.octokit,
payload: resolvedPayload,
repo: githubSetup.repo,
repoSettings: githubSetup.repoSettings,
modes: computedModes,
toolState,
octokit: repoData.octokit,
agent,
sharedTempDir,
event: payload.event,
disableProgressComment: payload.disableProgressComment,
modes,
toolState,
runId,
jobId,
};
await using mcpHttpServer = await startMcpHttpServer(toolContext);
log.info(`🚀 MCP server started at ${mcpHttpServer.url}`);
const mcpServers = createMcpConfigs(mcpHttpServer.url);
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
log.info(`» MCP server started at ${mcpHttpServer.url}`);
timer.checkpoint("mcpServer");
// BUILD FINAL IMMUTABLE CONTEXT
const ctx: AgentContext = {
...toolContext,
inputs,
const instructions = resolveInstructions({
prompt: payload.prompt,
event: payload.event,
repoData,
modes,
bash: tools.bash,
});
const result = await agent.run({
effort: payload.effort,
tools,
mcpServerUrl: mcpHttpServer.url,
mcpServers,
cliPath,
apiKey: apiKeySetup.apiKey,
apiKeys: apiKeySetup.apiKeys,
};
// check for empty comment_ids in fix_review trigger - report and exit early
if (
ctx.payload.event.trigger === "fix_review" &&
Array.isArray(ctx.payload.event.comment_ids) &&
ctx.payload.event.comment_ids.length === 0
) {
const noThumbsMessage = `👍 **No approved comments found**\n\nTo use "Fix 👍s", add a 👍 reaction to one or more inline review comments you want fixed.`;
log.error(noThumbsMessage);
await reportProgress(ctx, { body: noThumbsMessage });
return { success: true };
}
const result = await runAgent(ctx);
tmpdir,
instructions,
apiKey,
apiKeys,
});
const mainResult = await handleAgentResult(result);
return mainResult;
} 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
// do this before revoking the token so we can still make API calls
try {
await ensureProgressCommentUpdated(payload);
await ensureProgressCommentUpdated({
disableProgressComment: payload.disableProgressComment,
});
} catch {
// error updating comment, but don't let it mask the original error
}
}
}
/**
* Check if an agent has API keys available (from process.env)
*/
function agentHasApiKeys(agent: Agent): boolean {
if (agent.name === "opencode") {
// opencode accepts any API_KEY from environment
return Object.keys(process.env).some((key) => key.includes("API_KEY") && process.env[key]);
}
// check if any of the agent's expected keys are in environment
return agent.apiKeyNames.some((envKey) => !!process.env[envKey]);
}
function getAvailableAgents(): Agent[] {
return Object.values(agents).filter((agent) => agentHasApiKeys(agent));
}
/**
* Get all possible API key names from agentsManifest using flatMorph
*/
function getAllPossibleKeyNames(): string[] {
return Object.keys(
flatMorph(agentsManifest, (_, manifest) =>
manifest.apiKeyNames.map((keyName) => [keyName, true] as const)
)
);
}
/**
* Build a helpful error message for missing API key with links to repo settings
*/
function buildMissingApiKeyError(params: { agent: Agent; owner: string; name: string }): string {
const apiUrl = process.env.API_URL || "https://pullfrog.com";
const settingsUrl = `${apiUrl}/console/${params.owner}/${params.name}`;
const githubRepoUrl = `https://github.com/${params.owner}/${params.name}`;
const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
// for OpenCode, use a generic message since it accepts any API key
const isOpenCode = params.agent.name === "opencode";
let secretNameList: string;
if (isOpenCode) {
secretNameList =
"any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)";
} else {
const inputKeys =
params.agent.apiKeyNames.length > 0 ? params.agent.apiKeyNames : getAllPossibleKeyNames();
const secretNames = inputKeys.map((key) => `\`${key}\``);
secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
}
return `Pullfrog is configured to use ${params.agent.displayName}, but the associated API key was not provided.
To fix this, add the required secret to your GitHub repository:
1. Go to: ${githubSecretsUrl}
2. Click "New repository secret"
3. Set the name to ${secretNameList}
4. Set the value to your API key
5. Click "Add secret"
Alternatively, configure Pullfrog to use a different agent at ${settingsUrl}`;
}
// tool context - subset of Context needed by MCP tools
export interface ToolContext {
owner: string;
name: string;
githubInstallationToken: string;
octokit: Octokit;
payload: Payload;
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
repoSettings: RepoSettings;
modes: Mode[];
toolState: ToolState;
agent: Agent;
sharedTempDir: string;
runId: string;
jobId: string | undefined;
}
export interface AgentContext extends Readonly<ToolContext> {
readonly inputs: Inputs;
readonly mcpServerUrl: string;
readonly mcpServers: ReturnType<typeof createMcpConfigs>;
readonly cliPath: string;
readonly apiKey: string;
readonly apiKeys: Record<string, string>;
}
export interface DependencyInstallationState {
status: "not_started" | "in_progress" | "completed" | "failed";
promise: Promise<PrepResult[]> | undefined;
results: PrepResult[] | undefined;
}
export interface ToolState {
prNumber?: number;
issueNumber?: number;
selectedMode?: string;
review?: {
id: number; // REST API database ID (for fix URLs)
nodeId: string; // GraphQL node ID (for mutations)
};
dependencyInstallation?: DependencyInstallationState;
}
/**
* Initialize GitHub connection: token, octokit, repo data, settings
*/
async function initializeGitHub(token: string): Promise<GitHubSetup> {
log.info(`🐸 Running pullfrog/pullfrog@${packageJson.version}...`);
const { owner, name } = parseRepoContext();
const octokit = createOctokit(token);
// fetch repo data and settings in parallel
const [repoResponse, repoSettings] = await Promise.all([
octokit.repos.get({ owner, repo: name }),
fetchRepoSettings({ token, repoContext: { owner, name } }),
]);
return {
owner,
name,
octokit,
repo: repoResponse.data,
repoSettings,
};
}
function resolveAgent({
payload,
repoSettings,
}: {
payload: Payload;
repoSettings: RepoSettings;
}): Agent {
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
log.debug(
`» determineAgent: agentOverride=${agentOverride}, payload.agent=${payload.agent}, repoSettings.defaultAgent=${repoSettings.defaultAgent}`
);
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
if (configuredAgentName) {
const agent = agents[configuredAgentName];
if (!agent) {
throw new Error(`invalid agent name: ${configuredAgentName}`);
}
// if explicitly configured (via override or payload), respect it even without matching keys
// this allows users to force an agent selection (will fail later with clear error if no keys)
const isExplicitOverride = agentOverride !== undefined || payload.agent !== null;
if (isExplicitOverride) {
log.info(`Selected configured agent: ${agent.name}`);
return agent;
}
// for repo-level defaults, check if agent has matching keys before selecting
if (agentHasApiKeys(agent)) {
log.info(`Selected configured agent: ${agent.name}`);
return agent;
}
// fall through to auto-selection
const availableAgents = getAvailableAgents();
log.warning(
`Repo default agent ${agent.name} has no matching API keys. Available: ${
availableAgents.map((a) => a.name).join(", ") || "none"
}`
);
}
const availableAgents = getAvailableAgents();
if (availableAgents.length === 0) {
throw new Error("no agents available - missing API keys");
}
const agent = availableAgents[0];
log.info(`No agent configured, defaulting to first available agent: ${agent.name}`);
return agent;
}
async function createTempDirectory(): Promise<string> {
const sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-"));
process.env.PULLFROG_TEMP_DIR = sharedTempDir;
log.info(`📂 PULLFROG_TEMP_DIR has been created at ${sharedTempDir}`);
return sharedTempDir;
}
function parsePayload(inputs: Inputs): Payload {
// helper to convert "null" string to null, with proper type narrowing
const agent =
inputs.agent === undefined || inputs.agent === "null" ? null : (inputs.agent as AgentName);
// dispatch action provides structured inputs directly (event, modes, etc.)
if (inputs.event) {
return {
"~pullfrog": true,
agent,
prompt: inputs.prompt,
event: inputs.event as Payload["event"],
modes: inputs.modes ?? modes,
effort: inputs.effort ?? "auto",
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 } from "arktype";
import type { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const BashParams = type({
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const GetCheckSuiteLogs = type({
+10 -10
View File
@@ -2,9 +2,9 @@ import { writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import { log } from "../utils/cli.ts";
import { $ } from "../utils/shell.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
@@ -118,7 +118,7 @@ export async function checkoutPrBranch(
params: CheckoutPrBranchParams
): Promise<CheckoutPrBranchResult> {
const { octokit, owner, name, token, pullNumber } = params;
log.info(`🔀 checking out PR #${pullNumber}...`);
log.info(`» checking out PR #${pullNumber}...`);
// fetch PR metadata
const pr = await octokit.rest.pulls.get({
@@ -149,7 +149,7 @@ export async function checkoutPrBranch(
log.debug(`already on PR branch ${localBranch}, skipping checkout`);
} else {
// 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]);
// 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}`]);
// 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}`]);
// checkout the branch
$("git", ["checkout", localBranch]);
log.debug(` checked out PR #${pullNumber}`);
log.debug(`» checked out PR #${pullNumber}`);
}
// ensure base branch is fetched (needed for diff operations)
// fetch if we skipped checkout (already on branch) - otherwise already fetched above
if (alreadyOnBranch) {
log.debug(`📥 fetching base branch (${baseBranch})...`);
log.debug(`» fetching base branch (${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)
try {
$("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 {
// remote already exists, update its URL
$("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
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName]);
// set merge ref so git knows the remote branch name (may differ from local)
$("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)
if (!pr.data.maintainer_can_modify) {
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.`
);
}
+37 -34
View File
@@ -1,16 +1,11 @@
import { type } from "arktype";
import type { Payload } from "../external.ts";
import { agentsManifest } from "../external.ts";
import type { ToolContext } from "../main.ts";
import { fetchWorkflowRunInfo } from "../utils/api.ts";
import type { Agent } from "../agents/index.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { writeSummary } from "../utils/cli.ts";
import {
createOctokit,
getGitHubInstallationToken,
type OctokitWithPlugins,
parseRepoContext,
} from "../utils/github.ts";
import { createOctokit, type OctokitWithPlugins, parseRepoContext } from "../utils/github.ts";
import { getGitHubInstallationToken } from "../utils/token.ts";
import { fetchWorkflowRunInfo } from "../utils/workflowRun.ts";
import type { ToolContext } from "./server.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";
interface BuildCommentFooterParams {
payload: Payload;
agent: Agent | undefined;
octokit?: OctokitWithPlugins | undefined;
customParts?: string[] | undefined;
}
async function buildCommentFooter({
payload,
agent,
octokit,
customParts,
}: BuildCommentFooterParams): Promise<string> {
const repoContext = parseRepoContext();
const runId = process.env.GITHUB_RUN_ID;
const agentName = payload.agent;
const agentInfo = agentName ? agentsManifest[agentName] : null;
let workflowRunHtmlUrl: string | undefined;
if (runId && octokit) {
try {
@@ -56,8 +48,8 @@ async function buildCommentFooter({
const footerParams = {
triggeredBy: true,
agent: {
displayName: agentInfo?.displayName || "Unknown agent",
url: agentInfo?.url || "https://pullfrog.com",
displayName: agent?.displayName || "Unknown agent",
url: agent?.url || "https://pullfrog.com",
},
workflowRun: runId
? {
@@ -88,13 +80,14 @@ function buildImplementPlanLink(
return `[Implement plan ➔](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`;
}
async function addFooter(
body: string,
payload: Payload,
octokit?: OctokitWithPlugins
): Promise<string> {
interface AddFooterCtx {
agent?: Agent | undefined;
octokit?: OctokitWithPlugins | undefined;
}
async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({ payload, octokit });
const footer = await buildCommentFooter({ agent: ctx.agent, octokit: ctx.octokit });
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.",
parameters: Comment,
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({
owner: ctx.owner,
@@ -140,7 +133,7 @@ export function EditCommentTool(ctx: ToolContext) {
description: "Edit a GitHub issue comment by its ID",
parameters: EditComment,
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({
owner: ctx.owner,
@@ -218,8 +211,7 @@ export async function reportProgress(
| undefined
> {
const existingCommentId = getProgressCommentId();
const issueNumber =
ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
const issueNumber = ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.event.issue_number;
const isPlanMode = ctx.toolState.selectedMode === "Plan";
// if we already have a progress comment, update it
@@ -231,7 +223,7 @@ export async function reportProgress(
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({
payload: ctx.payload,
agent: ctx.agent,
octokit: ctx.octokit,
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
const initialBody = await addFooter(body, ctx.payload, ctx.octokit);
const initialBody = await addFooter(ctx, body);
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.owner,
@@ -282,7 +274,7 @@ export async function reportProgress(
const customParts = [buildImplementPlanLink(ctx.owner, ctx.name, issueNumber, result.data.id)];
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({
payload: ctx.payload,
agent: ctx.agent,
octokit: ctx.octokit,
customParts,
});
@@ -382,6 +374,10 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
return true;
}
interface EnsureProgressCommentUpdatedParams {
disableProgressComment: boolean;
}
/**
* 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
@@ -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).
* 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
if (progressComment.wasUpdated) {
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
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.`;
// add footer if we have payload, otherwise use plain message
const body = payload ? await addFooter(errorMessage, payload, octokit) : errorMessage;
// add footer without agent info (we don't have context here)
const body = await addFooter({ octokit }, errorMessage);
await octokit.rest.issues.updateComment({
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).",
parameters: ReplyToReviewComment,
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({
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 { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { $ } from "../utils/shell.ts";
import { execute, tool } from "./shared.ts";
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import type { PrepResult } from "../prep/index.ts";
import { runPrepPhase } from "../prep/index.ts";
import { execute, tool } from "./shared.ts";
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { log } from "../utils/cli.ts";
import { containsSecrets } from "../utils/secrets.ts";
import { $ } from "../utils/shell.ts";
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const Issue = type({
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const GetIssueComments = type({
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const GetIssueEvents = type({
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const IssueInfo = type({
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const AddLabelsParams = type({
+2 -6
View File
@@ -1,6 +1,5 @@
import { type } from "arktype";
import { agentsManifest } from "../external.ts";
import type { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { containsSecrets } from "../utils/secrets.ts";
@@ -14,12 +13,9 @@ export const PullRequest = type({
});
function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
const agentName = ctx.payload.agent;
const agentInfo = agentName ? agentsManifest[agentName] : null;
const footer = buildPullfrogFooter({
triggeredBy: true,
agent: agentInfo ? { displayName: agentInfo.displayName, url: agentInfo.url } : undefined,
agent: { displayName: ctx.agent.displayName, url: ctx.agent.url },
workflowRun: ctx.runId
? { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId }
: undefined,
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const PullRequestInfo = type({
+1 -1
View File
@@ -1,6 +1,6 @@
import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { deleteProgressComment } from "./comment.ts";
+1 -1
View File
@@ -1,5 +1,5 @@
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
// 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 { ToolContext } from "../main.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export const SelectMode = type({
+38 -4
View File
@@ -1,9 +1,44 @@
import "./arkConfig.ts";
import { createServer } from "node:net";
// this must be imported first
import type { Octokit } from "@octokit/rest";
import { FastMCP, type Tool } from "fastmcp";
import { ghPullfrogMcpName } from "../external.ts";
import type { ToolContext } from "../main.ts";
import type { Agent } from "../agents/index.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 { GetCheckSuiteLogsTool } from "./checkSuite.ts";
import {
@@ -29,7 +64,6 @@ import { CreatePullRequestReviewTool } from "./review.ts";
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
import { SelectModeTool } from "./selectMode.ts";
import { addTools } from "./shared.ts";
import { BashTool } from "./bash.ts";
/**
* Find an available port starting from the given port
@@ -98,7 +132,7 @@ export async function startMcpHttpServer(
BashTool(ctx),
];
if (!ctx.payload.disableProgressComment) {
if (!ctx.disableProgressComment) {
tools.push(ReportProgressTool(ctx));
}
+1 -1
View File
@@ -1,8 +1,8 @@
import type { StandardSchemaV1 } from "@standard-schema/spec";
import { encode as toonEncode } from "@toon-format/toon";
import type { FastMCP, Tool } from "fastmcp";
import type { ToolContext } from "../main.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;
+6 -5
View File
@@ -14,15 +14,16 @@ export const ModeSchema = type({
prompt: "string",
});
export interface GetModesParams {
disableProgressComment: true | undefined;
export interface ComputeModesParams {
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 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 [
{
name: "Build",
@@ -170,6 +171,6 @@ ${
];
}
export const modes: Mode[] = getModes({
disableProgressComment: undefined,
export const modes: Mode[] = computeModes({
disableProgressComment: false,
});
+15 -1
View File
@@ -30,7 +30,21 @@ export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult>
const inputs: Inputs =
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);
+1 -1
View File
@@ -31,7 +31,7 @@ export async function runPrepPhase(): Promise<PrepResult[]> {
if (result.dependenciesInstalled) {
log.debug(`» ${step.name}: dependencies installed`);
} 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
): Promise<string | null> {
if (name === "npm") return null; // npm is always available
log.info(`📦 installing ${installSpec}...`);
log.info(`» installing ${installSpec}...`);
const [cmd, ...templateArgs] = nodePackageManagers[name];
const args = templateArgs.map((arg) => (arg === "{version}" ? installSpec : arg));
const result = await spawn({
@@ -76,7 +76,7 @@ async function installPackageManager(
process.env.PATH = `${denoPath}:${process.env.PATH}`;
}
log.info(` installed ${name}`);
log.info(`» installed ${name}`);
return null;
}
@@ -101,16 +101,16 @@ export const installNodeDependencies: PrepDefinition = {
const agent = detected?.agent || packageManager;
if (fromPackageJson) {
log.info(`📦 using packageManager from package.json: ${fromPackageJson.installSpec}`);
log.info(`» using packageManager from package.json: ${fromPackageJson.installSpec}`);
} else if (detected) {
log.info(`📦 detected package manager: ${packageManager} (${agent})`);
log.info(`» detected package manager: ${packageManager} (${agent})`);
} 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
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);
if (installError) {
return {
@@ -134,7 +134,7 @@ export const installNodeDependencies: PrepDefinition = {
}
const fullCommand = `${resolved.command} ${resolved.args.join(" ")}`;
log.info(`running: ${fullCommand}`);
log.info(`» running: ${fullCommand}`);
const result = await spawn({
cmd: resolved.command,
args: resolved.args,
+5 -5
View File
@@ -66,7 +66,7 @@ async function installTool(name: string): Promise<string | null> {
return null;
}
log.info(`📦 installing ${name}...`);
log.info(`» installing ${name}...`);
const [cmd, ...args] = installCmd;
const result = await spawn({
cmd,
@@ -79,7 +79,7 @@ async function installTool(name: string): Promise<string | null> {
return result.stderr || `failed to install ${name}`;
}
log.info(` installed ${name}`);
log.info(`» installed ${name}`);
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
const isAvailable = await isCommandAvailable(config.tool);
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);
if (installError) {
return {
@@ -133,7 +133,7 @@ export const installPythonDependencies: PrepDefinition = {
// run the install command
const [cmd, ...args] = config.installCmd;
log.info(`running: ${cmd} ${args.join(" ")}`);
log.info(`» running: ${cmd} ${args.join(" ")}`);
const result = await spawn({
cmd,
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, getGitHubInstallationToken, parseRepoContext } from "./github.ts";
import { createOctokit, parseRepoContext } from "./github.ts";
import { getGitHubInstallationToken } from "./token.ts";
import { fetchWorkflowRunInfo } from "./workflowRun.ts";
/**
* Get progress comment ID from environment variable or database.
@@ -22,7 +23,7 @@ export async function reportErrorToComment({
error: string;
title?: string;
}): 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
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 {
owner: 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 { encode as toonEncode } from "@toon-format/toon";
import type { Payload } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import { getModes } from "../modes.ts";
import type { RepoInfo, ToolPermissions } from "./shared.ts";
import { computeModes, type Mode } from "../modes.ts";
import type { RepoData } from "./repoData.ts";
/**
* Build runtime context string with git status, repo data, and GitHub Actions variables
*/
function buildRuntimeContext(repo: RepoInfo): string {
type BashPermission = "disabled" | "restricted" | "enabled";
interface InstructionsInput {
prompt: string;
event: { trigger: string; [key: string]: unknown };
repoData: RepoData;
modes: Mode[];
bash: BashPermission;
}
function buildRuntimeContext(input: InstructionsInput): string {
const lines: string[] = [];
// working directory
lines.push(`working_directory: ${process.cwd()}`);
lines.push(`log_level: ${process.env.LOG_LEVEL}`);
// git status (try to get it, but don't fail if git isn't available)
try {
const gitStatus = execSync("git status --short", { encoding: "utf-8", stdio: "pipe" }).trim();
lines.push(`git_status: ${gitStatus || "(clean)"}`);
@@ -23,11 +27,9 @@ function buildRuntimeContext(repo: RepoInfo): string {
// git not available or not in a repo
}
// repo data
lines.push(`repo: ${repo.owner}/${repo.name}`);
lines.push(`default_branch: ${repo.defaultBranch}`);
lines.push(`repo: ${input.repoData.owner}/${input.repoData.name}`);
lines.push(`default_branch: ${input.repoData.repo.default_branch}`);
// GitHub Actions variables (when running in CI)
const ghVars: Record<string, string | undefined> = {
github_event_name: process.env.GITHUB_EVENT_NAME,
github_ref: process.env.GITHUB_REF,
@@ -45,16 +47,7 @@ function buildRuntimeContext(repo: RepoInfo): string {
return lines.join("\n");
}
interface AddInstructionsCtx {
payload: Payload;
repo: RepoInfo;
tools: ToolPermissions;
}
/**
* Generate shell instructions based on bash permission level.
*/
function getShellInstructions(bash: ToolPermissions["bash"]): string {
function getShellInstructions(bash: BashPermission): string {
switch (bash) {
case "disabled":
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 = "";
const eventKeys = Object.keys(ctx.payload.event);
const eventKeys = Object.keys(input.event);
if (eventKeys.length === 1 && eventKeys[0] === "trigger") {
// no meaningful event data to encode
} else {
// extract only essential fields to reduce token usage
// const essentialEvent = ctx.payload.event;
encodedEvent = toonEncode(ctx.payload.event);
encodedEvent = toonEncode(input.event);
}
const runtimeContext = buildRuntimeContext(ctx.repo);
const runtimeContext = buildRuntimeContext(input);
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.
` +
// **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.
**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.
@@ -173,7 +142,7 @@ ${getShellInstructions(ctx.tools.bash)}
### 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
@@ -183,7 +152,7 @@ Eagerly inspect the MCP tools available to you via the \`${ghPullfrogMcpName}\`
************* USER PROMPT *************
${ctx.payload.prompt
${input.prompt
.split("\n")
.map((line) => `> ${line}`)
.join("\n")}
@@ -202,4 +171,4 @@ ${encodedEvent}`
${runtimeContext}`
);
};
}
+1 -1
View File
@@ -216,7 +216,7 @@ export const log = {
/** Print success message */
success: (...args: unknown[]): void => {
core.info(` ${formatArgs(args)}`);
core.info(`» ${formatArgs(args)}`);
},
/** 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 { getGitHubInstallationToken } from "./github.ts";
import { getGitHubInstallationToken } from "./token.ts";
function getAllSecrets(): string[] {
const secrets: string[] = [];
+39 -30
View File
@@ -1,8 +1,11 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import type { Payload } from "../external.ts";
import type { ToolState } from "../main.ts";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { PayloadEvent } from "../external.ts";
import { checkoutPrBranch } from "../mcp/checkout.ts";
import type { ToolState } from "../mcp/server.ts";
import { log } from "./cli.ts";
import type { OctokitWithPlugins } from "./github.ts";
import { $ } from "./shell.ts";
@@ -11,6 +14,16 @@ export interface SetupOptions {
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
*/
@@ -26,13 +39,30 @@ export function setupTestRepo(options: SetupOptions): void {
$("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
* Uses --local flag to scope config to the current repo only
* Only sets defaults if not already configured (respects workflow config)
* Setup git configuration and authentication for the repository.
* - Configures git identity (user.email, user.name)
* - 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();
// 1. configure git identity
log.info("» setting up git configuration...");
try {
// 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)}`
);
}
}
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...");
// 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
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`;
$("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
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
const prNumber = params.payload.event.issue_number;
const prNumber = params.event.issue_number;
// ensure origin is configured with auth token before checkout
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 {
cmd: string;
args: string[];
env?: Record<string, string>;
env?: NodeJS.ProcessEnv;
input?: string;
timeout?: number;
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 };
}
}