From 955751a0e136617a3d6b0cb342bfa5241126ecae Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 26 Nov 2025 12:18:10 -0800 Subject: [PATCH 01/17] fix formatting --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 205ac77..5df2eda 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@

- - - Pullfrog logo - -

Pullfrog

+ +

+ + + Pullfrog logo +
+ Pullfrog +

GitHub Action for running AI agents via Pullfrog to automate development workflows

From 29e5a4a698cfe206f64590b2fb130365fd4ae8bc Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 26 Nov 2025 12:19:28 -0800 Subject: [PATCH 02/17] tweak --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5df2eda..3609efc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Pullfrog

- GitHub Action for running AI agents via Pullfrog to automate development workflows + Bring your favorite coding agent into GitHub

From f2571d07a41d017474fdc8276b22f74ce747915e Mon Sep 17 00:00:00 2001 From: Pullfrog Date: Wed, 26 Nov 2025 15:47:04 -0500 Subject: [PATCH 03/17] Add `pullfrog.yml` workflow --- .github/workflows/pullfrog.yml | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/pullfrog.yml diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml new file mode 100644 index 0000000..29dccdd --- /dev/null +++ b/.github/workflows/pullfrog.yml @@ -0,0 +1,37 @@ +name: Pullfrog +on: + workflow_dispatch: + inputs: + prompt: + type: string + description: 'Agent prompt' + workflow_call: + inputs: + prompt: + description: 'Agent prompt' + type: string + +permissions: + id-token: write + contents: read + +jobs: + pullfrog: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Run agent + uses: pullfrog/action@v0 + with: + prompt: ${{ github.event.inputs.prompt }} + + # feel free to comment out any you won't use + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + openai_api_key: ${{ secrets.OPENAI_API_KEY }} + google_api_key: ${{ secrets.GOOGLE_API_KEY }} + gemini_api_key: ${{ secrets.GEMINI_API_KEY }} + cursor_api_key: ${{ secrets.CURSOR_API_KEY }} + From 611e7e80ce257bcb9ac5eaada8de6132c34d5365 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 26 Nov 2025 12:51:27 -0800 Subject: [PATCH 04/17] remove workflow --- .github/workflows/pullfrog.yml | 37 ---------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/pullfrog.yml diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml deleted file mode 100644 index 29dccdd..0000000 --- a/.github/workflows/pullfrog.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Pullfrog -on: - workflow_dispatch: - inputs: - prompt: - type: string - description: 'Agent prompt' - workflow_call: - inputs: - prompt: - description: 'Agent prompt' - type: string - -permissions: - id-token: write - contents: read - -jobs: - pullfrog: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - name: Run agent - uses: pullfrog/action@v0 - with: - prompt: ${{ github.event.inputs.prompt }} - - # feel free to comment out any you won't use - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - openai_api_key: ${{ secrets.OPENAI_API_KEY }} - google_api_key: ${{ secrets.GOOGLE_API_KEY }} - gemini_api_key: ${{ secrets.GEMINI_API_KEY }} - cursor_api_key: ${{ secrets.CURSOR_API_KEY }} - From 7853f9ef56bf5fb77cfd2c8473421148116a3c03 Mon Sep 17 00:00:00 2001 From: Pullfrog Date: Wed, 26 Nov 2025 15:56:05 -0500 Subject: [PATCH 05/17] Add `pullfrog.yml` workflow --- .github/workflows/pullfrog.yml | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/pullfrog.yml diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml new file mode 100644 index 0000000..29dccdd --- /dev/null +++ b/.github/workflows/pullfrog.yml @@ -0,0 +1,37 @@ +name: Pullfrog +on: + workflow_dispatch: + inputs: + prompt: + type: string + description: 'Agent prompt' + workflow_call: + inputs: + prompt: + description: 'Agent prompt' + type: string + +permissions: + id-token: write + contents: read + +jobs: + pullfrog: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Run agent + uses: pullfrog/action@v0 + with: + prompt: ${{ github.event.inputs.prompt }} + + # feel free to comment out any you won't use + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + openai_api_key: ${{ secrets.OPENAI_API_KEY }} + google_api_key: ${{ secrets.GOOGLE_API_KEY }} + gemini_api_key: ${{ secrets.GEMINI_API_KEY }} + cursor_api_key: ${{ secrets.CURSOR_API_KEY }} + From 1a882a11b8ddd2e526608e1ae6aba460506eb35f Mon Sep 17 00:00:00 2001 From: David Blass Date: Wed, 26 Nov 2025 16:33:02 -0500 Subject: [PATCH 06/17] centralize env management via createAgentEnv --- agents/claude.ts | 4 +-- agents/codex.ts | 24 ++++++------- agents/cursor.ts | 23 +++++-------- agents/gemini.ts | 23 +++++-------- agents/shared.ts | 44 +++++++++++++++++++----- main.ts | 39 ++------------------- mcp/shared.ts | 17 ++++----- utils/cli.ts | 89 +----------------------------------------------- utils/github.ts | 24 ++++++++++--- 9 files changed, 97 insertions(+), 190 deletions(-) diff --git a/agents/claude.ts b/agents/claude.ts index 59d7079..ffe4f95 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -2,7 +2,7 @@ import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import packageJson from "../package.json" with { type: "json" }; import { log } from "../utils/cli.ts"; import { addInstructions } from "./instructions.ts"; -import { agent, installFromNpmTarball } from "./shared.ts"; +import { agent, installFromNpmTarball, setupProcessAgentEnv } from "./shared.ts"; export const claude = agent({ name: "claude", @@ -15,7 +15,7 @@ export const claude = agent({ }); }, run: async ({ payload, mcpServers, apiKey, cliPath }) => { - process.env.ANTHROPIC_API_KEY = apiKey; + setupProcessAgentEnv({ ANTHROPIC_API_KEY: apiKey }); const prompt = addInstructions(payload); console.log(prompt); diff --git a/agents/codex.ts b/agents/codex.ts index cadd537..83938fa 100644 --- a/agents/codex.ts +++ b/agents/codex.ts @@ -4,7 +4,12 @@ import { join } from "node:path"; import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk"; import { log } from "../utils/cli.ts"; import { addInstructions } from "./instructions.ts"; -import { agent, type ConfigureMcpServersParams, installFromNpmTarball } from "./shared.ts"; +import { + agent, + type ConfigureMcpServersParams, + installFromNpmTarball, + setupProcessAgentEnv, +} from "./shared.ts"; export const codex = agent({ name: "codex", @@ -15,16 +20,16 @@ export const codex = agent({ executablePath: "bin/codex.js", }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, githubInstallationToken }) => { - process.env.OPENAI_API_KEY = apiKey; - process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken; - + run: async ({ payload, mcpServers, apiKey, cliPath }) => { // create config directory for codex before setting HOME const tempHome = process.env.PULLFROG_TEMP_DIR!; const configDir = join(tempHome, ".config", "codex"); mkdirSync(configDir, { recursive: true }); - process.env.HOME = tempHome; + setupProcessAgentEnv({ + OPENAI_API_KEY: apiKey, + HOME: tempHome, + }); configureCodexMcpServers({ mcpServers, cliPath }); @@ -35,10 +40,6 @@ export const codex = agent({ }; const codex = new Codex(codexOptions); - // Configure thread options to match Claude's permissions (bypassPermissions) - // approvalPolicy: "never" = no approval needed (equivalent to bypassPermissions) - // sandboxMode: "workspace-write" = allow file writes - // networkAccessEnabled: true = allow network access (needed for GitHub API calls) const thread = codex.startThread({ approvalPolicy: "never", sandboxMode: "workspace-write", @@ -46,10 +47,8 @@ export const codex = agent({ }); try { - // Use runStreamed to get streaming events similar to claude.ts const streamedTurn = await thread.runStreamed(addInstructions(payload)); - // Stream events and handle them let finalOutput = ""; for await (const event of streamedTurn.events) { const handler = messageHandlers[event.type]; @@ -58,7 +57,6 @@ export const codex = agent({ handler(event as never); } - // Capture final response from agent messages if (event.type === "item.completed" && event.item.type === "agent_message") { finalOutput = event.item.text; } diff --git a/agents/cursor.ts b/agents/cursor.ts index df5213c..687070b 100644 --- a/agents/cursor.ts +++ b/agents/cursor.ts @@ -4,7 +4,12 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { log } from "../utils/cli.ts"; import { addInstructions } from "./instructions.ts"; -import { agent, type ConfigureMcpServersParams, installFromCurl } from "./shared.ts"; +import { + agent, + type ConfigureMcpServersParams, + createAgentEnv, + installFromCurl, +} from "./shared.ts"; // cursor cli event types inferred from stream-json output interface CursorSystemEvent { @@ -138,10 +143,7 @@ export const cursor = agent({ executableName: "cursor-agent", }); }, - run: async ({ payload, apiKey, cliPath, githubInstallationToken, mcpServers }) => { - process.env.CURSOR_API_KEY = apiKey; - process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken; - + run: async ({ payload, apiKey, cliPath, mcpServers }) => { configureCursorMcpServers({ mcpServers, cliPath }); try { @@ -165,16 +167,9 @@ export const cursor = agent({ ], { cwd: process.cwd(), - env: { + env: createAgentEnv({ CURSOR_API_KEY: apiKey, - GITHUB_INSTALLATION_TOKEN: githubInstallationToken, - LOG_LEVEL: process.env.LOG_LEVEL, - NODE_ENV: process.env.NODE_ENV, - HOME: process.env.HOME, - PATH: process.env.PATH, - // Don't override HOME - Cursor CLI needs access to macOS keychain - // MCP config is written to tempDir/.cursor/mcp.json which Cursor will find - }, + }), stdio: ["ignore", "pipe", "pipe"], // Ignore stdin, pipe stdout/stderr } ); diff --git a/agents/gemini.ts b/agents/gemini.ts index 948a5d8..827af04 100644 --- a/agents/gemini.ts +++ b/agents/gemini.ts @@ -2,7 +2,12 @@ import { spawnSync } from "node:child_process"; import { log } from "../utils/cli.ts"; import { spawn } from "../utils/subprocess.ts"; import { addInstructions } from "./instructions.ts"; -import { agent, type ConfigureMcpServersParams, installFromGithub } from "./shared.ts"; +import { + agent, + type ConfigureMcpServersParams, + createAgentEnv, + installFromGithub, +} from "./shared.ts"; // gemini cli event types inferred from stream-json output (NDJSON format) interface GeminiInitEvent { @@ -152,16 +157,12 @@ export const gemini = agent({ assetName: "gemini.js", }); }, - run: async ({ payload, apiKey, mcpServers, githubInstallationToken, cliPath }) => { + run: async ({ payload, apiKey, mcpServers, cliPath }) => { configureGeminiMcpServers({ mcpServers, cliPath }); if (!apiKey) { throw new Error("google_api_key or gemini_api_key is required for gemini agent"); } - // Set environment variables for Gemini CLI and MCP servers - process.env.GEMINI_API_KEY = apiKey; - process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken; - const sessionPrompt = addInstructions(payload); log.info(`Starting Gemini CLI with prompt: ${payload.prompt.substring(0, 100)}...`); @@ -170,15 +171,9 @@ export const gemini = agent({ const result = await spawn({ cmd: "node", args: [cliPath, "--yolo", "--output-format=stream-json", "-p", sessionPrompt], - env: { - PATH: process.env.PATH || "", - HOME: process.env.HOME || "", - TMPDIR: process.env.TMPDIR || "/tmp", + env: createAgentEnv({ GEMINI_API_KEY: apiKey, - GITHUB_INSTALLATION_TOKEN: githubInstallationToken, - LOG_LEVEL: process.env.LOG_LEVEL!, - NODE_ENV: process.env.NODE_ENV!, - }, + }), timeout: 600000, // 10 minutes onStdout: async (chunk) => { const text = chunk.toString(); diff --git a/agents/shared.ts b/agents/shared.ts index c173acf..e38a9d9 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -8,6 +8,7 @@ import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk"; import type { show } from "@ark/util"; import { type AgentManifest, type AgentName, agentsManifest, type Payload } from "../external.ts"; import { log } from "../utils/cli.ts"; +import { getGitHubInstallationToken } from "../utils/github.ts"; /** * Result returned by agent execution @@ -24,7 +25,6 @@ export interface AgentResult { */ export interface AgentConfig { apiKey: string; - githubInstallationToken: string; payload: Payload; mcpServers: Record; cliPath: string; @@ -38,6 +38,35 @@ export interface ConfigureMcpServersParams { cliPath: string; } +/** + * 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): Record { + return { + PATH: process.env.PATH, + HOME: process.env.HOME, + 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): void { + Object.assign(process.env, createAgentEnv(agentSpecificVars)); +} + /** * Parameters for installing from npm tarball */ @@ -296,17 +325,14 @@ export async function installFromCurl({ log.info(`Installing to temp directory at ${tempDir}...`); - // Run the install script with HOME set to temp directory - // The Cursor install script installs to $HOME/.local/bin/{executableName} - // By setting HOME=tempDir, we ensure it installs to tempDir/.local/bin/{executableName} const installResult = spawnSync("bash", [installScriptPath], { cwd: tempDir, env: { - HOME: tempDir, // Cursor install script uses HOME for installation path - PATH: process.env.PATH || "", - SHELL: process.env.SHELL || "/bin/bash", - USER: process.env.USER || "", - TMPDIR: process.env.TMPDIR || "/tmp", + // Run the install script with HOME set to temp directory + // ensuring a fresh install for each run + HOME: tempDir, + SHELL: process.env.SHELL, + USER: process.env.USER, }, stdio: "pipe", encoding: "utf-8", diff --git a/main.ts b/main.ts index 3b3c2d5..0c44afa 100644 --- a/main.ts +++ b/main.ts @@ -1,5 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; -import { mkdtemp, writeFile } from "node:fs/promises"; +import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { flatMorph } from "@ark/util"; @@ -18,7 +17,7 @@ import { log } from "./utils/cli.ts"; import { parseRepoContext, type RepoContext, - revokeInstallationToken, + revokeGitHubInstallationToken, setupGitHubInstallationToken, } from "./utils/github.ts"; import { setupGitAuth, setupGitBranch, setupGitConfig } from "./utils/setup.ts"; @@ -49,9 +48,7 @@ export interface MainResult { } export async function main(inputs: Inputs): Promise { - let pollInterval: NodeJS.Timeout | null = null; let mcpServerClose: (() => Promise) | undefined; - let githubInstallationToken: string | undefined; try { // parse payload early to extract agent @@ -59,15 +56,12 @@ export async function main(inputs: Inputs): Promise { const partialCtx = await initializeContext(inputs, payload); const ctx = partialCtx as MainContext; - githubInstallationToken = ctx.githubInstallationToken; setupGitAuth({ githubInstallationToken: ctx.githubInstallationToken, repoContext: ctx.repoContext, }); await setupTempDirectory(ctx); - setupMcpLogPolling(ctx); - pollInterval = ctx.pollInterval; setupGitBranch(ctx.payload); await startMcpServer(ctx); @@ -87,15 +81,10 @@ export async function main(inputs: Inputs): Promise { error: errorMessage, }; } finally { - if (pollInterval) { - clearInterval(pollInterval); - } if (mcpServerClose) { await mcpServerClose(); } - if (githubInstallationToken) { - await revokeInstallationToken(githubInstallationToken); - } + await revokeGitHubInstallationToken(); } } @@ -170,8 +159,6 @@ interface MainContext { agentName: AgentNameType; agent: (typeof agents)[AgentNameType]; sharedTempDir: string; - mcpLogPath: string; - pollInterval: NodeJS.Timeout | null; payload: Payload; mcpServerUrl: string; mcpServerClose: () => Promise; @@ -210,8 +197,6 @@ async function initializeContext( agent, payload: resolvedPayload, sharedTempDir: "", - mcpLogPath: "", - pollInterval: null, }; } @@ -262,25 +247,9 @@ async function setupTempDirectory( ): Promise { ctx.sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-")); process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir; - ctx.mcpLogPath = join(ctx.sharedTempDir, "mcpLog.txt"); - await writeFile(ctx.mcpLogPath, "", "utf-8"); log.info(`📂 PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`); } -function setupMcpLogPolling(ctx: MainContext): void { - let lastSize = 0; - ctx.pollInterval = setInterval(() => { - if (existsSync(ctx.mcpLogPath)) { - const content = readFileSync(ctx.mcpLogPath, "utf-8"); - if (content.length > lastSize) { - const newContent = content.slice(lastSize); - process.stdout.write(newContent); - lastSize = content.length; - } - } - }, 100); -} - function parsePayload(inputs: Inputs): Payload { try { const parsedPrompt = JSON.parse(inputs.prompt); @@ -307,7 +276,6 @@ async function startMcpServer(ctx: MainContext): Promise { const githubRepository = `${repoContext.owner}/${repoContext.name}`; const allModes = [...modes, ...(ctx.payload.modes || [])]; - process.env.GITHUB_INSTALLATION_TOKEN = ctx.githubInstallationToken; process.env.GITHUB_REPOSITORY = githubRepository; process.env.PULLFROG_MODES = JSON.stringify(allModes); process.env.PULLFROG_PAYLOAD = JSON.stringify(ctx.payload); @@ -352,7 +320,6 @@ async function runAgent(ctx: MainContext): Promise { return ctx.agent.run({ payload: ctx.payload, mcpServers: ctx.mcpServers, - githubInstallationToken: ctx.githubInstallationToken, apiKey: ctx.apiKey, cliPath: ctx.cliPath, }); diff --git a/mcp/shared.ts b/mcp/shared.ts index d92ad17..c6bb750 100644 --- a/mcp/shared.ts +++ b/mcp/shared.ts @@ -2,7 +2,7 @@ import { Octokit } from "@octokit/rest"; import type { StandardSchemaV1 } from "@standard-schema/spec"; import type { FastMCP, Tool } from "fastmcp"; import type { Payload } from "../external.ts"; -import { parseRepoContext, type RepoContext } from "../utils/github.ts"; +import { getGitHubInstallationToken, parseRepoContext, type RepoContext } from "../utils/github.ts"; export interface ToolResult { content: { @@ -28,15 +28,10 @@ export function getPayload(): Payload { } export function getMcpContext(): McpContext { - const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN; - if (!githubInstallationToken) { - throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required"); - } - return { ...parseRepoContext(), octokit: new Octokit({ - auth: githubInstallationToken, + auth: getGitHubInstallationToken(), }), payload: getPayload(), }; @@ -56,9 +51,10 @@ export const addTools = (server: FastMCP, tools: Tool[]) => { return server; }; -export const contextualize = - (executor: (params: T, ctx: McpContext) => Promise>) => - async (params: T): Promise => { +export const contextualize = ( + executor: (params: T, ctx: McpContext) => Promise> +) => { + return async (params: T): Promise => { try { const ctx = getMcpContext(); const result = await executor(params, ctx); @@ -67,6 +63,7 @@ export const contextualize = return handleToolError(error); } }; +}; const handleToolSuccess = (data: Record): ToolResult => { return { diff --git a/utils/cli.ts b/utils/cli.ts index c5736c7..13febec 100644 --- a/utils/cli.ts +++ b/utils/cli.ts @@ -3,8 +3,7 @@ */ import { spawnSync } from "node:child_process"; -import { appendFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync } from "node:fs"; import * as core from "@actions/core"; import { table } from "table"; @@ -325,41 +324,8 @@ export const log = { log.info(output.trimEnd()); }, - - /** - * Log MCP tool call information to mcpLog.txt in the temp directory - */ - toolCallToFile: ({ - toolName, - request, - result, - error, - }: { - toolName: string; - request: unknown; - result?: string; - error?: string; - }): void => { - const logPath = getMcpLogPath(); - const params: Parameters[0] = { toolName, request }; - if (error) { - params.error = error; - } else if (result) { - params.result = result; - } - const logEntry = formatToolCall(params); - appendFileSync(logPath, logEntry, "utf-8"); - }, }; -/** - * Get the path to the MCP log file in the temp directory - */ -function getMcpLogPath(): string { - const tempDir = process.env.PULLFROG_TEMP_DIR!; - return join(tempDir, "mcpLog.txt"); -} - /** * Format a value as JSON, using compact format for simple values and pretty-printed for complex ones */ @@ -385,59 +351,6 @@ export function formatIndentedField(label: string, content: string): string { return formatted; } -/** - * Format the input field for a tool call - */ -function formatToolInput(request: unknown): string { - const requestFormatted = formatJsonValue(request); - if (requestFormatted === "{}") { - return ""; - } - return formatIndentedField("input", requestFormatted); -} - -/** - * Format the result field for a tool call, parsing JSON if possible - */ -function formatToolResult(result: string): string { - try { - const parsed = JSON.parse(result); - const formatted = formatJsonValue(parsed); - return formatIndentedField("result", formatted); - } catch { - // Not JSON, display as-is - return formatIndentedField("result", result); - } -} - -/** - * Format a complete tool call entry with tool name, input, result, and error - */ -function formatToolCall({ - toolName, - request, - result, - error, -}: { - toolName: string; - request: unknown; - result?: string; - error?: string; -}): string { - let logEntry = `→ ${toolName}\n`; - - logEntry += formatToolInput(request); - - if (error) { - logEntry += formatIndentedField("error", error); - } else if (result) { - logEntry += formatToolResult(result); - } - - logEntry += "\n"; - return logEntry; -} - /** * Finds a CLI executable path by checking if it's installed globally * @param name The name of the CLI executable to find diff --git a/utils/github.ts b/utils/github.ts index f1649a5..c1f9156 100644 --- a/utils/github.ts +++ b/utils/github.ts @@ -241,21 +241,37 @@ async function acquireNewToken(): Promise { } } +// Store token in memory instead of process.env +let githubInstallationToken: string | undefined; + /** * Setup GitHub installation token for the action */ export async function setupGitHubInstallationToken(): Promise { const acquiredToken = await acquireNewToken(); core.setSecret(acquiredToken); - process.env.GITHUB_INSTALLATION_TOKEN = acquiredToken; - + githubInstallationToken = acquiredToken; return acquiredToken; } /** - * Revoke GitHub installation token + * Get the GitHub installation token from memory */ -export async function revokeInstallationToken(token: string): Promise { +export function getGitHubInstallationToken(): string { + if (!githubInstallationToken) { + throw new Error("GitHub installation token not set. Call setupGitHubInstallationToken first."); + } + return githubInstallationToken; +} + +export async function revokeGitHubInstallationToken(): Promise { + if (!githubInstallationToken) { + return; + } + + const token = githubInstallationToken; + githubInstallationToken = undefined; + const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com"; try { From 8f3828cb82891d987bc38e27b96bbc71df49762b Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 26 Nov 2025 14:07:03 -0800 Subject: [PATCH 07/17] add to github --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3609efc..7d19a7c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,13 @@ Pullfrog is a GitHub bot that brings the full power of your favorite coding agents into GitHub. It's open source and powered by GitHub Actions. -Once configured, you can start triggering agent runs. +

+ + Add to GitHub + +

+ +Once added, you can start triggering agent runs. - **Tag `@pullfrog`** — Tag `@pullfrog` in a comment anywhere in your repo. It will pull in any relevant context using the action's internal MCP server and perform the appropriate task. - **Prompt from the web** — Trigger arbitrary tasks from the Pullfrog dashboard @@ -47,7 +53,6 @@ Install the Pullfrog GitHub App on your personal or organization account. During [Add to GitHub ➜](https://github.com/apps/pullfrog/installations/new) -
Manual setup instructions From cd930fef8e389833e50edbac2acd14f1720cf2a1 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 26 Nov 2025 14:17:14 -0800 Subject: [PATCH 08/17] format button --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7d19a7c..3244581 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,9 @@ Pullfrog is a GitHub bot that brings the full power of your favorite coding agents into GitHub. It's open source and powered by GitHub Actions. -

- - Add to GitHub - -

+ + Add to GitHub + Once added, you can start triggering agent runs. From b2b75bacc0966e35588e9899972ae1989c55e206 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 26 Nov 2025 14:17:53 -0800 Subject: [PATCH 09/17] format button --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3244581..ecad080 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Pullfrog is a GitHub bot that brings the full power of your favorite coding agents into GitHub. It's open source and powered by GitHub Actions. - Add to GitHub + Add to GitHub Once added, you can start triggering agent runs. From 55a51650661442dd9ab4ec616341d46c56e1c5d7 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 26 Nov 2025 14:20:07 -0800 Subject: [PATCH 10/17] format button --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index ecad080..0f5406b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Pullfrog is a GitHub bot that brings the full power of your favorite coding agen Add to GitHub +
+ Once added, you can start triggering agent runs. - **Tag `@pullfrog`** — Tag `@pullfrog` in a comment anywhere in your repo. It will pull in any relevant context using the action's internal MCP server and perform the appropriate task. From f1626f9aa7ba7f23d7f43cd7aaa4b4c982d55ebf Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 26 Nov 2025 14:23:16 -0800 Subject: [PATCH 11/17] format button --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0f5406b..2574b52 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Pullfrog is a GitHub bot that brings the full power of your favorite coding agen Add to GitHub -
+

Once added, you can start triggering agent runs. From e54e7f1353a609b39fcf1a1f70c4d6ddac9be10e Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 26 Nov 2025 14:23:40 -0800 Subject: [PATCH 12/17] format button --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2574b52..0f5406b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Pullfrog is a GitHub bot that brings the full power of your favorite coding agen Add to GitHub -

+
Once added, you can start triggering agent runs. From 007bc8a6114040731024cbdc92f8749acd0448fe Mon Sep 17 00:00:00 2001 From: David Blass Date: Wed, 26 Nov 2025 17:24:37 -0500 Subject: [PATCH 13/17] add get_issue tools --- fixtures/basic.txt | 2 +- mcp/issueComments.ts | 35 +++++++++++++++++ mcp/issueEvents.ts | 93 ++++++++++++++++++++++++++++++++++++++++++++ mcp/issueInfo.ts | 55 ++++++++++++++++++++++++++ mcp/server.ts | 6 +++ 5 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 mcp/issueComments.ts create mode 100644 mcp/issueEvents.ts create mode 100644 mcp/issueInfo.ts diff --git a/fixtures/basic.txt b/fixtures/basic.txt index b0ea008..bc823bf 100644 --- a/fixtures/basic.txt +++ b/fixtures/basic.txt @@ -1 +1 @@ -use debug_shell_command tool to run 'git status' and explain the result \ No newline at end of file +summarize https://github.com/pullfrogai/scratch/issues/56, its status, and pertinent discussion on the issue \ No newline at end of file diff --git a/mcp/issueComments.ts b/mcp/issueComments.ts new file mode 100644 index 0000000..6c2acbd --- /dev/null +++ b/mcp/issueComments.ts @@ -0,0 +1,35 @@ +import { type } from "arktype"; +import { contextualize, tool } from "./shared.ts"; + +export const GetIssueComments = type({ + issue_number: type.number.describe("The issue number to get comments for"), +}); + +export const GetIssueCommentsTool = tool({ + name: "get_issue_comments", + description: "Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.", + parameters: GetIssueComments, + execute: contextualize(async ({ issue_number }, ctx) => { + const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, { + owner: ctx.owner, + repo: ctx.name, + issue_number, + }); + + return { + issue_number, + comments: comments.map((comment) => ({ + id: comment.id, + body: comment.body, + user: comment.user?.login, + created_at: comment.created_at, + updated_at: comment.updated_at, + html_url: comment.html_url, + author_association: comment.author_association, + reactions: comment.reactions, + })), + count: comments.length, + }; + }), +}); + diff --git a/mcp/issueEvents.ts b/mcp/issueEvents.ts new file mode 100644 index 0000000..c4e5d32 --- /dev/null +++ b/mcp/issueEvents.ts @@ -0,0 +1,93 @@ +import { type } from "arktype"; +import { contextualize, tool } from "./shared.ts"; + +export const GetIssueEvents = type({ + issue_number: type.number.describe("The issue number to get events for"), +}); + +export const GetIssueEventsTool = tool({ + name: "get_issue_events", + description: + "Get timeline events for a GitHub issue that aren't reflected in the current state. Returns cross-references to other issues/PRs and commit references. Note: current labels, assignees, state, and milestone are already available via get_issue.", + parameters: GetIssueEvents, + execute: contextualize(async ({ issue_number }, ctx) => { + const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, { + owner: ctx.owner, + repo: ctx.name, + issue_number, + }); + + // Only include events not reflected in current issue state (get_issue already has labels, assignees, state, etc.) + // Keep only relationship/reference events that show connections to other issues/PRs/commits + const relevantEventTypes = new Set(["cross_referenced", "referenced"]); + + const parsedEvents = events.flatMap((event) => { + // Filter to only events with an 'event' property and relevant types + if (!("event" in event) || !relevantEventTypes.has(event.event)) { + return []; + } + + const baseEvent: Record = { + event: event.event, + }; + + // Common fields + if ("id" in event) { + baseEvent.id = event.id; + } + if ("actor" in event && event.actor) { + baseEvent.actor = event.actor.login; + } else if ("user" in event && event.user) { + baseEvent.actor = event.user.login; + } + if ("created_at" in event) { + baseEvent.created_at = event.created_at; + } + + // Event-specific data + if (event.event === "cross_referenced") { + if ("source" in event && event.source) { + const source = event.source as { + type?: string; + issue?: { number: number; title: string; html_url: string }; + pull_request?: { number: number; title: string; html_url: string }; + }; + baseEvent.source = { + type: source.type, + issue: source.issue + ? { + number: source.issue.number, + title: source.issue.title, + html_url: source.issue.html_url, + } + : null, + pull_request: source.pull_request + ? { + number: source.pull_request.number, + title: source.pull_request.title, + html_url: source.pull_request.html_url, + } + : null, + }; + } + } + + if (event.event === "referenced") { + if ("commit_id" in event) { + baseEvent.commit_id = event.commit_id; + } + if ("commit_url" in event) { + baseEvent.commit_url = event.commit_url; + } + } + + return [baseEvent]; + }); + + return { + issue_number, + events: parsedEvents, + count: parsedEvents.length, + }; + }), +}); diff --git a/mcp/issueInfo.ts b/mcp/issueInfo.ts new file mode 100644 index 0000000..e8a6f5a --- /dev/null +++ b/mcp/issueInfo.ts @@ -0,0 +1,55 @@ +import { type } from "arktype"; +import { contextualize, tool } from "./shared.ts"; + +export const IssueInfo = type({ + issue_number: type.number.describe("The issue number to fetch"), +}); + +export const IssueInfoTool = tool({ + name: "get_issue", + description: "Retrieve GitHub issue information by issue number", + parameters: IssueInfo, + execute: contextualize(async ({ issue_number }, ctx) => { + const issue = await ctx.octokit.rest.issues.get({ + owner: ctx.owner, + repo: ctx.name, + issue_number, + }); + + const data = issue.data; + + const hints: string[] = []; + if (data.comments > 0) { + hints.push("use get_issue_comments to retrieve all comments for this issue"); + } + hints.push( + "use get_issue_events to retrieve cross-references and commit references (relationships not reflected in current state)" + ); + + return { + number: data.number, + url: data.html_url, + title: data.title, + body: data.body, + state: data.state, + locked: data.locked, + labels: data.labels?.map((label) => (typeof label === "string" ? label : label.name)), + assignees: data.assignees?.map((assignee) => assignee.login), + user: data.user?.login, + created_at: data.created_at, + updated_at: data.updated_at, + closed_at: data.closed_at, + comments: data.comments, + milestone: data.milestone?.title, + pull_request: data.pull_request + ? { + url: data.pull_request.url, + html_url: data.pull_request.html_url, + diff_url: data.pull_request.diff_url, + patch_url: data.pull_request.patch_url, + } + : null, + hints, + }; + }), +}); diff --git a/mcp/server.ts b/mcp/server.ts index e116ddc..7fad9c0 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -12,6 +12,9 @@ import { } from "./comment.ts"; import { DebugShellCommandTool } from "./debug.ts"; import { IssueTool } from "./issue.ts"; +import { GetIssueCommentsTool } from "./issueComments.ts"; +import { GetIssueEventsTool } from "./issueEvents.ts"; +import { IssueInfoTool } from "./issueInfo.ts"; import { PullRequestTool } from "./pr.ts"; import { PullRequestInfoTool } from "./prInfo.ts"; import { ReviewTool } from "./review.ts"; @@ -64,6 +67,9 @@ export async function startMcpHttpServer(): Promise<{ url: string; close: () => CreateWorkingCommentTool, UpdateWorkingCommentTool, IssueTool, + IssueInfoTool, + GetIssueCommentsTool, + GetIssueEventsTool, PullRequestTool, ReviewTool, PullRequestInfoTool, From fd5e9c283881024a163e768bb4430787000515b0 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 26 Nov 2025 16:33:29 -0800 Subject: [PATCH 14/17] update action w setup instructions --- .github/workflows/pullfrog.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml index 29dccdd..22a5a6b 100644 --- a/.github/workflows/pullfrog.yml +++ b/.github/workflows/pullfrog.yml @@ -23,6 +23,11 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 1 + + # optionally, setup your repo here + # the agent can figure this out itself, but pre-setup is more efficient + # - uses: actions/setup-node@v6 + - name: Run agent uses: pullfrog/action@v0 with: From bddadfa70fbf6350a65ee03331ace903b6deca50 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 26 Nov 2025 19:18:35 -0800 Subject: [PATCH 15/17] update img hrefs --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0f5406b..8bea6ee 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- Pullfrog logo + Pullfrog logo
Pullfrog

From 2ed4d445f736c5fc343206939647c911fa8fcceb Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 26 Nov 2025 23:03:09 -0800 Subject: [PATCH 16/17] make codex yolo --- agents/codex.ts | 3 +- entry | 765 ++++++++++++++++++++++++++---------------------- 2 files changed, 421 insertions(+), 347 deletions(-) diff --git a/agents/codex.ts b/agents/codex.ts index 83938fa..b2386eb 100644 --- a/agents/codex.ts +++ b/agents/codex.ts @@ -42,7 +42,8 @@ export const codex = agent({ const codex = new Codex(codexOptions); const thread = codex.startThread({ approvalPolicy: "never", - sandboxMode: "workspace-write", + // use danger-full-access to allow git operations (workspace-write blocks .git directory writes) + sandboxMode: "danger-full-access", networkAccessEnabled: true, }); diff --git a/entry b/entry index 5dc8835..099c90b 100755 --- a/entry +++ b/entry @@ -18153,7 +18153,7 @@ var require_summary = __commonJS({ exports.summary = exports.markdownSummary = exports.SUMMARY_DOCS_URL = exports.SUMMARY_ENV_VAR = void 0; var os_1 = __require("os"); var fs_1 = __require("fs"); - var { access, appendFile, writeFile: writeFile2 } = fs_1.promises; + var { access, appendFile, writeFile } = fs_1.promises; exports.SUMMARY_ENV_VAR = "GITHUB_STEP_SUMMARY"; exports.SUMMARY_DOCS_URL = "https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary"; var Summary = class { @@ -18211,7 +18211,7 @@ var require_summary = __commonJS({ return __awaiter(this, void 0, void 0, function* () { const overwrite = !!(options === null || options === void 0 ? void 0 : options.overwrite); const filePath = yield this.filePath(); - const writeFunc = overwrite ? writeFile2 : appendFile; + const writeFunc = overwrite ? writeFile : appendFile; yield writeFunc(filePath, this._buffer, { encoding: "utf8" }); return this.emptyBuffer(); }); @@ -47775,7 +47775,7 @@ var require_snapshot_utils = __commonJS({ var require_snapshot_recorder = __commonJS({ "../node_modules/.pnpm/undici@7.16.0/node_modules/undici/lib/mock/snapshot-recorder.js"(exports, module) { "use strict"; - var { writeFile: writeFile2, readFile, mkdir } = __require("node:fs/promises"); + var { writeFile, readFile, mkdir } = __require("node:fs/promises"); var { dirname: dirname2, resolve } = __require("node:path"); var { setTimeout: setTimeout2, clearTimeout: clearTimeout2 } = __require("node:timers"); var { InvalidArgumentError, UndiciError } = require_errors2(); @@ -48005,7 +48005,7 @@ var require_snapshot_recorder = __commonJS({ hash, snapshot: snapshot2 })); - await writeFile2(resolvedPath, JSON.stringify(data, null, 2), { flush: true }); + await writeFile(resolvedPath, JSON.stringify(data, null, 2), { flush: true }); } /** * Clears all recorded snapshots @@ -83934,8 +83934,6 @@ var package_default = { // utils/cli.ts var core = __toESM(require_core(), 1); var import_table = __toESM(require_src(), 1); -import { appendFileSync as appendFileSync2, existsSync as existsSync2 } from "node:fs"; -import { join as join4 } from "node:path"; var isGitHubActions = !!process.env.GITHUB_ACTIONS; var isDebugEnabled = () => process.env.LOG_LEVEL === "debug"; function startGroup2(name) { @@ -84179,31 +84177,8 @@ var log = { output += formatIndentedField("input", inputFormatted); } log.info(output.trimEnd()); - }, - /** - * Log MCP tool call information to mcpLog.txt in the temp directory - */ - toolCallToFile: ({ - toolName, - request: request2, - result, - error: error41 - }) => { - const logPath = getMcpLogPath(); - const params = { toolName, request: request2 }; - if (error41) { - params.error = error41; - } else if (result) { - params.result = result; - } - const logEntry = formatToolCall(params); - appendFileSync2(logPath, logEntry, "utf-8"); } }; -function getMcpLogPath() { - const tempDir = process.env.PULLFROG_TEMP_DIR; - return join4(tempDir, "mcpLog.txt"); -} function formatJsonValue(value2) { const compact = JSON.stringify(value2); return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value2, null, 2) : compact; @@ -84222,39 +84197,6 @@ function formatIndentedField(label, content) { } return formatted; } -function formatToolInput(request2) { - const requestFormatted = formatJsonValue(request2); - if (requestFormatted === "{}") { - return ""; - } - return formatIndentedField("input", requestFormatted); -} -function formatToolResult(result) { - try { - const parsed2 = JSON.parse(result); - const formatted = formatJsonValue(parsed2); - return formatIndentedField("result", formatted); - } catch { - return formatIndentedField("result", result); - } -} -function formatToolCall({ - toolName, - request: request2, - result, - error: error41 -}) { - let logEntry = `\u2192 ${toolName} -`; - logEntry += formatToolInput(request2); - if (error41) { - logEntry += formatIndentedField("error", error41); - } else if (result) { - logEntry += formatToolResult(result); - } - logEntry += "\n"; - return logEntry; -} // ../node_modules/.pnpm/@toon-format+toon@1.0.0/node_modules/@toon-format/toon/dist/index.js var LIST_ITEM_MARKER = "-"; @@ -92415,11 +92357,216 @@ ${encode(payload.event)}`; // agents/shared.ts import { spawnSync } from "node:child_process"; -import { chmodSync, createWriteStream as createWriteStream2, existsSync as existsSync3 } from "node:fs"; +import { chmodSync, createWriteStream as createWriteStream2, existsSync as existsSync2 } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join as join5 } from "node:path"; +import { join as join4 } from "node:path"; import { pipeline } from "node:stream/promises"; + +// utils/github.ts +var core2 = __toESM(require_core(), 1); +import { createSign } from "node:crypto"; +function isGitHubActionsEnvironment() { + return Boolean(process.env.GITHUB_ACTIONS); +} +async function acquireTokenViaOIDC() { + log.info("Generating OIDC token..."); + const oidcToken = await core2.getIDToken("pullfrog-api"); + log.info("OIDC token generated successfully"); + const apiUrl = process.env.API_URL || "https://pullfrog.ai"; + log.info("Exchanging OIDC token for installation token..."); + const timeoutMs = 5e3; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, { + method: "POST", + headers: { + Authorization: `Bearer ${oidcToken}`, + "Content-Type": "application/json" + }, + signal: controller.signal + }); + clearTimeout(timeoutId); + if (!tokenResponse.ok) { + throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`); + } + const tokenData = await tokenResponse.json(); + log.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`); + return tokenData.token; + } catch (error41) { + clearTimeout(timeoutId); + if (error41 instanceof Error && error41.name === "AbortError") { + throw new Error(`Token exchange timed out after ${timeoutMs}ms`); + } + throw error41; + } +} +var base64UrlEncode = (str) => { + return Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +}; +var generateJWT = (appId, privateKey) => { + const now = Math.floor(Date.now() / 1e3); + const payload = { + iat: now - 60, + exp: now + 5 * 60, + iss: appId + }; + const header = { + alg: "RS256", + typ: "JWT" + }; + const encodedHeader = base64UrlEncode(JSON.stringify(header)); + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); + const signaturePart = `${encodedHeader}.${encodedPayload}`; + const signature = createSign("RSA-SHA256").update(signaturePart).sign(privateKey, "base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + return `${signaturePart}.${signature}`; +}; +var githubRequest = async (path3, options = {}) => { + const { method = "GET", headers = {}, body } = options; + const url2 = `https://api.github.com${path3}`; + const requestHeaders = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Pullfrog-Installation-Token-Generator/1.0", + ...headers + }; + const response = await fetch(url2, { + method, + headers: requestHeaders, + ...body && { body } + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `GitHub API request failed: ${response.status} ${response.statusText} +${errorText}` + ); + } + return response.json(); +}; +var checkRepositoryAccess = async (token, repoOwner, repoName) => { + try { + const response = await githubRequest("/installation/repositories", { + headers: { Authorization: `token ${token}` } + }); + return response.repositories.some( + (repo) => repo.owner.login === repoOwner && repo.name === repoName + ); + } catch { + return false; + } +}; +var createInstallationToken = async (jwt, installationId) => { + const response = await githubRequest( + `/app/installations/${installationId}/access_tokens`, + { + method: "POST", + headers: { Authorization: `Bearer ${jwt}` } + } + ); + return response.token; +}; +var findInstallationId = async (jwt, repoOwner, repoName) => { + const installations = await githubRequest("/app/installations", { + headers: { Authorization: `Bearer ${jwt}` } + }); + for (const installation of installations) { + try { + const tempToken = await createInstallationToken(jwt, installation.id); + const hasAccess = await checkRepositoryAccess(tempToken, repoOwner, repoName); + if (hasAccess) { + return installation.id; + } + } catch { + } + } + throw new Error( + `No installation found with access to ${repoOwner}/${repoName}. Ensure the GitHub App is installed on the target repository.` + ); +}; +async function acquireTokenViaGitHubApp() { + const repoContext = parseRepoContext(); + const config2 = { + appId: process.env.GITHUB_APP_ID, + privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n"), + repoOwner: repoContext.owner, + repoName: repoContext.name + }; + const jwt = generateJWT(config2.appId, config2.privateKey); + const installationId = await findInstallationId(jwt, config2.repoOwner, config2.repoName); + const token = await createInstallationToken(jwt, installationId); + return token; +} +async function acquireNewToken() { + if (isGitHubActionsEnvironment()) { + return await acquireTokenViaOIDC(); + } else { + return await acquireTokenViaGitHubApp(); + } +} +var githubInstallationToken; +async function setupGitHubInstallationToken() { + const acquiredToken = await acquireNewToken(); + core2.setSecret(acquiredToken); + githubInstallationToken = acquiredToken; + return acquiredToken; +} +function getGitHubInstallationToken() { + if (!githubInstallationToken) { + throw new Error("GitHub installation token not set. Call setupGitHubInstallationToken first."); + } + return githubInstallationToken; +} +async function revokeGitHubInstallationToken() { + if (!githubInstallationToken) { + return; + } + const token = githubInstallationToken; + githubInstallationToken = void 0; + 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.info("Installation token revoked"); + } catch (error41) { + log.warning( + `Failed to revoke installation token: ${error41 instanceof Error ? error41.message : String(error41)}` + ); + } +} +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 }; +} + +// agents/shared.ts +function createAgentEnv(agentSpecificVars) { + return { + PATH: process.env.PATH, + HOME: process.env.HOME, + LOG_LEVEL: process.env.LOG_LEVEL, + NODE_ENV: process.env.NODE_ENV, + GITHUB_TOKEN: getGitHubInstallationToken(), + ...agentSpecificVars + // values could be undefined but will be ignored + }; +} +function setupProcessAgentEnv(agentSpecificVars) { + Object.assign(process.env, createAgentEnv(agentSpecificVars)); +} async function installFromNpmTarball({ packageName, version: version2, @@ -92447,7 +92594,7 @@ async function installFromNpmTarball({ } log.info(`\u{1F4E6} Installing ${packageName}@${resolvedVersion}...`); const tempDir = process.env.PULLFROG_TEMP_DIR; - const tarballPath = join5(tempDir, "package.tgz"); + const tarballPath = join4(tempDir, "package.tgz"); const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org"; let tarballUrl; if (packageName.startsWith("@")) { @@ -92476,9 +92623,9 @@ async function installFromNpmTarball({ `Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}` ); } - const extractedDir = join5(tempDir, "package"); - const cliPath = join5(extractedDir, executablePath); - if (!existsSync3(cliPath)) { + const extractedDir = join4(tempDir, "package"); + const cliPath = join4(extractedDir, executablePath); + if (!existsSync2(cliPath)) { throw new Error(`Executable not found in extracted package at ${cliPath}`); } if (installDependencies) { @@ -92524,10 +92671,10 @@ async function installFromGithub({ const assetUrl = asset.browser_download_url; log.info(`Downloading asset from ${assetUrl}...`); const tempDirPrefix = `${owner}-${repo}-github-`; - const tempDir = await mkdtemp(join5(tmpdir(), tempDirPrefix)); + const tempDir = await mkdtemp(join4(tmpdir(), tempDirPrefix)); const urlPath = new URL(assetUrl).pathname; const fileName2 = urlPath.split("/").pop() || "asset"; - const downloadPath = join5(tempDir, fileName2); + const downloadPath = join4(tempDir, fileName2); const assetResponse = await fetch(assetUrl); if (!assetResponse.ok) { throw new Error( @@ -92540,11 +92687,11 @@ async function installFromGithub({ log.info(`Downloaded asset to ${downloadPath}`); let cliPath; if (executablePath) { - cliPath = join5(tempDir, executablePath); + cliPath = join4(tempDir, executablePath); } else { cliPath = downloadPath; } - if (!existsSync3(cliPath)) { + if (!existsSync2(cliPath)) { throw new Error(`Executable not found at ${cliPath}`); } chmodSync(cliPath, 493); @@ -92557,7 +92704,7 @@ async function installFromCurl({ }) { log.info(`\u{1F4E6} Installing ${executableName}...`); const tempDir = process.env.PULLFROG_TEMP_DIR; - const installScriptPath = join5(tempDir, "install.sh"); + const installScriptPath = join4(tempDir, "install.sh"); log.info(`Downloading install script from ${installUrl}...`); const installScriptResponse = await fetch(installUrl); if (!installScriptResponse.ok) { @@ -92572,12 +92719,11 @@ async function installFromCurl({ 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, - // Cursor install script uses HOME for installation path - PATH: process.env.PATH || "", - SHELL: process.env.SHELL || "/bin/bash", - USER: process.env.USER || "", - TMPDIR: process.env.TMPDIR || "/tmp" + SHELL: process.env.SHELL, + USER: process.env.USER }, stdio: "pipe", encoding: "utf-8" @@ -92588,8 +92734,8 @@ async function installFromCurl({ `Failed to install ${executableName}. Install script exited with code ${installResult.status}. Output: ${errorOutput}` ); } - const cliPath = join5(tempDir, ".local", "bin", executableName); - if (!existsSync3(cliPath)) { + const cliPath = join4(tempDir, ".local", "bin", executableName); + if (!existsSync2(cliPath)) { throw new Error(`Executable not found at ${cliPath}`); } chmodSync(cliPath, 493); @@ -92612,7 +92758,7 @@ var claude = agent({ }); }, run: async ({ payload, mcpServers, apiKey, cliPath }) => { - process.env.ANTHROPIC_API_KEY = apiKey; + setupProcessAgentEnv({ ANTHROPIC_API_KEY: apiKey }); const prompt = addInstructions(payload); console.log(prompt); const queryInstance = query({ @@ -92711,7 +92857,7 @@ var messageHandlers = { // agents/codex.ts import { spawnSync as spawnSync2 } from "node:child_process"; import { mkdirSync as mkdirSync2 } from "node:fs"; -import { join as join6 } from "node:path"; +import { join as join5 } from "node:path"; // ../node_modules/.pnpm/@openai+codex-sdk@0.58.0/node_modules/@openai/codex-sdk/dist/index.js import { promises as fs2 } from "fs"; @@ -93046,13 +93192,14 @@ var codex = agent({ executablePath: "bin/codex.js" }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, githubInstallationToken }) => { - process.env.OPENAI_API_KEY = apiKey; - process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken; + run: async ({ payload, mcpServers, apiKey, cliPath }) => { const tempHome = process.env.PULLFROG_TEMP_DIR; - const configDir = join6(tempHome, ".config", "codex"); + const configDir = join5(tempHome, ".config", "codex"); mkdirSync2(configDir, { recursive: true }); - process.env.HOME = tempHome; + setupProcessAgentEnv({ + OPENAI_API_KEY: apiKey, + HOME: tempHome + }); configureCodexMcpServers({ mcpServers, cliPath }); const codexOptions = { apiKey, @@ -93061,7 +93208,8 @@ var codex = agent({ const codex2 = new Codex(codexOptions); const thread = codex2.startThread({ approvalPolicy: "never", - sandboxMode: "workspace-write", + // use danger-full-access to allow git operations (workspace-write blocks .git directory writes) + sandboxMode: "danger-full-access", networkAccessEnabled: true }); try { @@ -93198,7 +93346,7 @@ function configureCodexMcpServers({ mcpServers, cliPath }) { import { spawn as spawn3 } from "node:child_process"; import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir as homedir2 } from "node:os"; -import { join as join7 } from "node:path"; +import { join as join6 } from "node:path"; var messageHandlers3 = { system: (_event) => { }, @@ -93251,9 +93399,7 @@ var cursor = agent({ executableName: "cursor-agent" }); }, - run: async ({ payload, apiKey, cliPath, githubInstallationToken, mcpServers }) => { - process.env.CURSOR_API_KEY = apiKey; - process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken; + run: async ({ payload, apiKey, cliPath, mcpServers }) => { configureCursorMcpServers({ mcpServers, cliPath }); try { const fullPrompt = addInstructions(payload); @@ -93273,16 +93419,9 @@ var cursor = agent({ ], { cwd: process.cwd(), - env: { - CURSOR_API_KEY: apiKey, - GITHUB_INSTALLATION_TOKEN: githubInstallationToken, - LOG_LEVEL: process.env.LOG_LEVEL, - NODE_ENV: process.env.NODE_ENV, - HOME: process.env.HOME, - PATH: process.env.PATH - // Don't override HOME - Cursor CLI needs access to macOS keychain - // MCP config is written to tempDir/.cursor/mcp.json which Cursor will find - }, + env: createAgentEnv({ + CURSOR_API_KEY: apiKey + }), stdio: ["ignore", "pipe", "pipe"] // Ignore stdin, pipe stdout/stderr } @@ -93356,8 +93495,8 @@ var cursor = agent({ }); function configureCursorMcpServers({ mcpServers }) { const realHome = homedir2(); - const cursorConfigDir = join7(realHome, ".cursor"); - const mcpConfigPath = join7(cursorConfigDir, "mcp.json"); + const cursorConfigDir = join6(realHome, ".cursor"); + const mcpConfigPath = join6(cursorConfigDir, "mcp.json"); mkdirSync3(cursorConfigDir, { recursive: true }); const cursorMcpServers = {}; for (const [serverName, serverConfig] of Object.entries(mcpServers)) { @@ -93537,13 +93676,11 @@ var gemini = agent({ assetName: "gemini.js" }); }, - run: async ({ payload, apiKey, mcpServers, githubInstallationToken, cliPath }) => { + run: async ({ payload, apiKey, mcpServers, cliPath }) => { configureGeminiMcpServers({ mcpServers, cliPath }); if (!apiKey) { throw new Error("google_api_key or gemini_api_key is required for gemini agent"); } - process.env.GEMINI_API_KEY = apiKey; - process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken; const sessionPrompt = addInstructions(payload); log.info(`Starting Gemini CLI with prompt: ${payload.prompt.substring(0, 100)}...`); let finalOutput = ""; @@ -93551,15 +93688,9 @@ var gemini = agent({ const result = await spawn4({ cmd: "node", args: [cliPath, "--yolo", "--output-format=stream-json", "-p", sessionPrompt], - env: { - PATH: process.env.PATH || "", - HOME: process.env.HOME || "", - TMPDIR: process.env.TMPDIR || "/tmp", - GEMINI_API_KEY: apiKey, - GITHUB_INSTALLATION_TOKEN: githubInstallationToken, - LOG_LEVEL: process.env.LOG_LEVEL, - NODE_ENV: process.env.NODE_ENV - }, + env: createAgentEnv({ + GEMINI_API_KEY: apiKey + }), timeout: 6e5, // 10 minutes onStdout: async (chunk) => { @@ -93648,10 +93779,9 @@ var agents = { }; // main.ts -import { existsSync as existsSync4, readFileSync as readFileSync2 } from "node:fs"; -import { mkdtemp as mkdtemp2, writeFile } from "node:fs/promises"; +import { mkdtemp as mkdtemp2 } from "node:fs/promises"; import { tmpdir as tmpdir2 } from "node:os"; -import { join as join8 } from "node:path"; +import { join as join7 } from "node:path"; // mcp/config.ts function createMcpConfigs(mcpServerUrl) { @@ -121360,183 +121490,6 @@ var Octokit2 = Octokit.plugin(requestLog, legacyRestEndpointMethods, paginateRes } ); -// utils/github.ts -var core2 = __toESM(require_core(), 1); -import { createSign } from "node:crypto"; -function isGitHubActionsEnvironment() { - return Boolean(process.env.GITHUB_ACTIONS); -} -async function acquireTokenViaOIDC() { - log.info("Generating OIDC token..."); - const oidcToken = await core2.getIDToken("pullfrog-api"); - log.info("OIDC token generated successfully"); - const apiUrl = process.env.API_URL || "https://pullfrog.ai"; - log.info("Exchanging OIDC token for installation token..."); - const timeoutMs = 5e3; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - try { - const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, { - method: "POST", - headers: { - Authorization: `Bearer ${oidcToken}`, - "Content-Type": "application/json" - }, - signal: controller.signal - }); - clearTimeout(timeoutId); - if (!tokenResponse.ok) { - throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`); - } - const tokenData = await tokenResponse.json(); - log.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`); - return tokenData.token; - } catch (error41) { - clearTimeout(timeoutId); - if (error41 instanceof Error && error41.name === "AbortError") { - throw new Error(`Token exchange timed out after ${timeoutMs}ms`); - } - throw error41; - } -} -var base64UrlEncode = (str) => { - return Buffer.from(str).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); -}; -var generateJWT = (appId, privateKey) => { - const now = Math.floor(Date.now() / 1e3); - const payload = { - iat: now - 60, - exp: now + 5 * 60, - iss: appId - }; - const header = { - alg: "RS256", - typ: "JWT" - }; - const encodedHeader = base64UrlEncode(JSON.stringify(header)); - const encodedPayload = base64UrlEncode(JSON.stringify(payload)); - const signaturePart = `${encodedHeader}.${encodedPayload}`; - const signature = createSign("RSA-SHA256").update(signaturePart).sign(privateKey, "base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); - return `${signaturePart}.${signature}`; -}; -var githubRequest = async (path3, options = {}) => { - const { method = "GET", headers = {}, body } = options; - const url2 = `https://api.github.com${path3}`; - const requestHeaders = { - Accept: "application/vnd.github.v3+json", - "User-Agent": "Pullfrog-Installation-Token-Generator/1.0", - ...headers - }; - const response = await fetch(url2, { - method, - headers: requestHeaders, - ...body && { body } - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `GitHub API request failed: ${response.status} ${response.statusText} -${errorText}` - ); - } - return response.json(); -}; -var checkRepositoryAccess = async (token, repoOwner, repoName) => { - try { - const response = await githubRequest("/installation/repositories", { - headers: { Authorization: `token ${token}` } - }); - return response.repositories.some( - (repo) => repo.owner.login === repoOwner && repo.name === repoName - ); - } catch { - return false; - } -}; -var createInstallationToken = async (jwt, installationId) => { - const response = await githubRequest( - `/app/installations/${installationId}/access_tokens`, - { - method: "POST", - headers: { Authorization: `Bearer ${jwt}` } - } - ); - return response.token; -}; -var findInstallationId = async (jwt, repoOwner, repoName) => { - const installations = await githubRequest("/app/installations", { - headers: { Authorization: `Bearer ${jwt}` } - }); - for (const installation of installations) { - try { - const tempToken = await createInstallationToken(jwt, installation.id); - const hasAccess = await checkRepositoryAccess(tempToken, repoOwner, repoName); - if (hasAccess) { - return installation.id; - } - } catch { - } - } - throw new Error( - `No installation found with access to ${repoOwner}/${repoName}. Ensure the GitHub App is installed on the target repository.` - ); -}; -async function acquireTokenViaGitHubApp() { - const repoContext = parseRepoContext(); - const config2 = { - appId: process.env.GITHUB_APP_ID, - privateKey: process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n"), - repoOwner: repoContext.owner, - repoName: repoContext.name - }; - const jwt = generateJWT(config2.appId, config2.privateKey); - const installationId = await findInstallationId(jwt, config2.repoOwner, config2.repoName); - const token = await createInstallationToken(jwt, installationId); - return token; -} -async function acquireNewToken() { - if (isGitHubActionsEnvironment()) { - return await acquireTokenViaOIDC(); - } else { - return await acquireTokenViaGitHubApp(); - } -} -async function setupGitHubInstallationToken() { - const acquiredToken = await acquireNewToken(); - core2.setSecret(acquiredToken); - process.env.GITHUB_INSTALLATION_TOKEN = acquiredToken; - return acquiredToken; -} -async function revokeInstallationToken(token) { - 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.info("Installation token revoked"); - } catch (error41) { - log.warning( - `Failed to revoke installation token: ${error41 instanceof Error ? error41.message : String(error41)}` - ); - } -} -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 }; -} - // mcp/shared.ts function getPayload() { const payloadEnv = process.env.PULLFROG_PAYLOAD; @@ -121552,14 +121505,10 @@ function getPayload() { } } function getMcpContext() { - const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN; - if (!githubInstallationToken) { - throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required"); - } return { ...parseRepoContext(), octokit: new Octokit2({ - auth: githubInstallationToken + auth: getGitHubInstallationToken() }), payload: getPayload() }; @@ -121571,14 +121520,16 @@ var addTools = (server, tools) => { } return server; }; -var contextualize = (executor) => async (params) => { - try { - const ctx = getMcpContext(); - const result = await executor(params, ctx); - return handleToolSuccess(result); - } catch (error41) { - return handleToolError(error41); - } +var contextualize = (executor) => { + return async (params) => { + try { + const ctx = getMcpContext(); + const result = await executor(params, ctx); + return handleToolSuccess(result); + } catch (error41) { + return handleToolError(error41); + } + }; }; var handleToolSuccess = (data) => { return { @@ -121933,6 +121884,154 @@ var IssueTool = tool({ }) }); +// mcp/issueComments.ts +var GetIssueComments = type({ + issue_number: type.number.describe("The issue number to get comments for") +}); +var GetIssueCommentsTool = tool({ + name: "get_issue_comments", + description: "Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.", + parameters: GetIssueComments, + execute: contextualize(async ({ issue_number }, ctx) => { + const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, { + owner: ctx.owner, + repo: ctx.name, + issue_number + }); + return { + issue_number, + comments: comments.map((comment) => ({ + id: comment.id, + body: comment.body, + user: comment.user?.login, + created_at: comment.created_at, + updated_at: comment.updated_at, + html_url: comment.html_url, + author_association: comment.author_association, + reactions: comment.reactions + })), + count: comments.length + }; + }) +}); + +// mcp/issueEvents.ts +var GetIssueEvents = type({ + issue_number: type.number.describe("The issue number to get events for") +}); +var GetIssueEventsTool = tool({ + name: "get_issue_events", + description: "Get timeline events for a GitHub issue that aren't reflected in the current state. Returns cross-references to other issues/PRs and commit references. Note: current labels, assignees, state, and milestone are already available via get_issue.", + parameters: GetIssueEvents, + execute: contextualize(async ({ issue_number }, ctx) => { + const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, { + owner: ctx.owner, + repo: ctx.name, + issue_number + }); + const relevantEventTypes = /* @__PURE__ */ new Set(["cross_referenced", "referenced"]); + const parsedEvents = events.flatMap((event) => { + if (!("event" in event) || !relevantEventTypes.has(event.event)) { + return []; + } + const baseEvent = { + event: event.event + }; + if ("id" in event) { + baseEvent.id = event.id; + } + if ("actor" in event && event.actor) { + baseEvent.actor = event.actor.login; + } else if ("user" in event && event.user) { + baseEvent.actor = event.user.login; + } + if ("created_at" in event) { + baseEvent.created_at = event.created_at; + } + if (event.event === "cross_referenced") { + if ("source" in event && event.source) { + const source = event.source; + baseEvent.source = { + type: source.type, + issue: source.issue ? { + number: source.issue.number, + title: source.issue.title, + html_url: source.issue.html_url + } : null, + pull_request: source.pull_request ? { + number: source.pull_request.number, + title: source.pull_request.title, + html_url: source.pull_request.html_url + } : null + }; + } + } + if (event.event === "referenced") { + if ("commit_id" in event) { + baseEvent.commit_id = event.commit_id; + } + if ("commit_url" in event) { + baseEvent.commit_url = event.commit_url; + } + } + return [baseEvent]; + }); + return { + issue_number, + events: parsedEvents, + count: parsedEvents.length + }; + }) +}); + +// mcp/issueInfo.ts +var IssueInfo = type({ + issue_number: type.number.describe("The issue number to fetch") +}); +var IssueInfoTool = tool({ + name: "get_issue", + description: "Retrieve GitHub issue information by issue number", + parameters: IssueInfo, + execute: contextualize(async ({ issue_number }, ctx) => { + const issue2 = await ctx.octokit.rest.issues.get({ + owner: ctx.owner, + repo: ctx.name, + issue_number + }); + const data = issue2.data; + const hints = []; + if (data.comments > 0) { + hints.push("use get_issue_comments to retrieve all comments for this issue"); + } + hints.push( + "use get_issue_events to retrieve cross-references and commit references (relationships not reflected in current state)" + ); + return { + number: data.number, + url: data.html_url, + title: data.title, + body: data.body, + state: data.state, + locked: data.locked, + labels: data.labels?.map((label) => typeof label === "string" ? label : label.name), + assignees: data.assignees?.map((assignee) => assignee.login), + user: data.user?.login, + created_at: data.created_at, + updated_at: data.updated_at, + closed_at: data.closed_at, + comments: data.comments, + milestone: data.milestone?.title, + pull_request: data.pull_request ? { + url: data.pull_request.url, + html_url: data.pull_request.html_url, + diff_url: data.pull_request.diff_url, + patch_url: data.pull_request.patch_url + } : null, + hints + }; + }) +}); + // mcp/pr.ts var PullRequest = type({ title: type.string.describe("the title of the pull request"), @@ -122222,6 +122321,9 @@ async function startMcpHttpServer() { CreateWorkingCommentTool, UpdateWorkingCommentTool, IssueTool, + IssueInfoTool, + GetIssueCommentsTool, + GetIssueEventsTool, PullRequestTool, ReviewTool, PullRequestInfoTool, @@ -122374,21 +122476,16 @@ var Inputs = type({ ...keyInputDefs }); async function main(inputs) { - let pollInterval = null; let mcpServerClose; - let githubInstallationToken; try { const payload = parsePayload(inputs); const partialCtx = await initializeContext(inputs, payload); const ctx = partialCtx; - githubInstallationToken = ctx.githubInstallationToken; setupGitAuth({ githubInstallationToken: ctx.githubInstallationToken, repoContext: ctx.repoContext }); await setupTempDirectory(ctx); - setupMcpLogPolling(ctx); - pollInterval = ctx.pollInterval; setupGitBranch(ctx.payload); await startMcpServer(ctx); mcpServerClose = ctx.mcpServerClose; @@ -122406,15 +122503,10 @@ async function main(inputs) { error: errorMessage }; } finally { - if (pollInterval) { - clearInterval(pollInterval); - } if (mcpServerClose) { await mcpServerClose(); } - if (githubInstallationToken) { - await revokeInstallationToken(githubInstallationToken); - } + await revokeGitHubInstallationToken(); } } function getAvailableAgents(inputs) { @@ -122462,30 +122554,28 @@ async function initializeContext(inputs, payload) { log.info(`\u{1F438} Running pullfrog/action@${package_default.version}...`); Inputs.assert(inputs); setupGitConfig(); - const githubInstallationToken = await setupGitHubInstallationToken(); + const githubInstallationToken2 = await setupGitHubInstallationToken(); const repoContext = parseRepoContext(); const { agentName, agent: agent2 } = await resolveAgent( inputs, payload, - githubInstallationToken, + githubInstallationToken2, repoContext ); const resolvedPayload = { ...payload, agent: agentName }; return { inputs, - githubInstallationToken, + githubInstallationToken: githubInstallationToken2, repoContext, agentName, agent: agent2, payload: resolvedPayload, - sharedTempDir: "", - mcpLogPath: "", - pollInterval: null + sharedTempDir: "" }; } -async function resolveAgent(inputs, payload, githubInstallationToken, repoContext) { +async function resolveAgent(inputs, payload, githubInstallationToken2, repoContext) { const repoSettings = await fetchRepoSettings({ - token: githubInstallationToken, + token: githubInstallationToken2, repoContext }); const agentOverride = process.env.AGENT_OVERRIDE; @@ -122515,25 +122605,10 @@ async function resolveAgent(inputs, payload, githubInstallationToken, repoContex return { agentName, agent: agent2 }; } async function setupTempDirectory(ctx) { - ctx.sharedTempDir = await mkdtemp2(join8(tmpdir2(), "pullfrog-")); + ctx.sharedTempDir = await mkdtemp2(join7(tmpdir2(), "pullfrog-")); process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir; - ctx.mcpLogPath = join8(ctx.sharedTempDir, "mcpLog.txt"); - await writeFile(ctx.mcpLogPath, "", "utf-8"); log.info(`\u{1F4C2} PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`); } -function setupMcpLogPolling(ctx) { - let lastSize = 0; - ctx.pollInterval = setInterval(() => { - if (existsSync4(ctx.mcpLogPath)) { - const content = readFileSync2(ctx.mcpLogPath, "utf-8"); - if (content.length > lastSize) { - const newContent = content.slice(lastSize); - process.stdout.write(newContent); - lastSize = content.length; - } - } - }, 100); -} function parsePayload(inputs) { try { const parsedPrompt = JSON.parse(inputs.prompt); @@ -122557,7 +122632,6 @@ async function startMcpServer(ctx) { const repoContext = parseRepoContext(); const githubRepository = `${repoContext.owner}/${repoContext.name}`; const allModes = [...modes, ...ctx.payload.modes || []]; - process.env.GITHUB_INSTALLATION_TOKEN = ctx.githubInstallationToken; process.env.GITHUB_REPOSITORY = githubRepository; process.env.PULLFROG_MODES = JSON.stringify(allModes); process.env.PULLFROG_PAYLOAD = JSON.stringify(ctx.payload); @@ -122594,7 +122668,6 @@ ${encode(eventWithoutContext)}`; return ctx.agent.run({ payload: ctx.payload, mcpServers: ctx.mcpServers, - githubInstallationToken: ctx.githubInstallationToken, apiKey: ctx.apiKey, cliPath: ctx.cliPath }); From 91f8b551670b889411cf402e85938166ec9856f6 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Wed, 26 Nov 2025 23:25:36 -0800 Subject: [PATCH 17/17] add Address Reviews mode --- mcp/README.md | 25 +++++++++++++++++++++++++ mcp/comment.ts | 32 ++++++++++++++++++++++++++++++++ mcp/server.ts | 2 ++ modes.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 85 insertions(+) diff --git a/mcp/README.md b/mcp/README.md index 0d9eefd..af7ceb9 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -74,6 +74,31 @@ await mcp.call("gh_pullfrog/list_pull_request_reviews", { }); ``` +#### `reply_to_review_comment` +reply to a PR review comment thread explaining how the feedback was addressed. + +**parameters:** +- `pull_number` (number): the pull request number +- `comment_id` (number): the ID of the review comment to reply to +- `body` (string): the reply text explaining how the feedback was addressed + +**replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies` + +**returns:** +the created reply comment including: +- comment id, body, html_url +- in_reply_to_id showing it's a reply to the specified comment + +**example:** +```typescript +// after addressing a review comment +await mcp.call("gh_pullfrog/reply_to_review_comment", { + pull_number: 47, + comment_id: 2567334961, + body: "removed the function as requested" +}); +``` + ### other tools see individual files for documentation on other tools: diff --git a/mcp/comment.ts b/mcp/comment.ts index 70d8618..50db8c0 100644 --- a/mcp/comment.ts +++ b/mcp/comment.ts @@ -168,3 +168,35 @@ export const UpdateWorkingCommentTool = tool({ }; }), }); + +export const ReplyToReviewComment = type({ + pull_number: type.number.describe("the pull request number"), + comment_id: type.number.describe("the ID of the review comment to reply to"), + body: type.string.describe("the reply text explaining how the feedback was addressed"), +}); + +export const ReplyToReviewCommentTool = tool({ + name: "reply_to_review_comment", + description: + "Reply to a PR review comment thread explaining how the feedback was addressed. Use this after addressing each review comment to provide specific context about the changes made.", + parameters: ReplyToReviewComment, + execute: contextualize(async ({ pull_number, comment_id, body }, ctx) => { + const bodyWithFooter = addFooter(body, ctx.payload); + + const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({ + owner: ctx.owner, + repo: ctx.name, + pull_number, + comment_id, + body: bodyWithFooter, + }); + + return { + success: true, + commentId: result.data.id, + url: result.data.html_url, + body: result.data.body, + in_reply_to_id: result.data.in_reply_to_id, + }; + }), +}); diff --git a/mcp/server.ts b/mcp/server.ts index 7fad9c0..93843dd 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -8,6 +8,7 @@ import { CreateCommentTool, CreateWorkingCommentTool, EditCommentTool, + ReplyToReviewCommentTool, UpdateWorkingCommentTool, } from "./comment.ts"; import { DebugShellCommandTool } from "./debug.ts"; @@ -66,6 +67,7 @@ export async function startMcpHttpServer(): Promise<{ url: string; close: () => EditCommentTool, CreateWorkingCommentTool, UpdateWorkingCommentTool, + ReplyToReviewCommentTool, IssueTool, IssueInfoTool, GetIssueCommentsTool, diff --git a/modes.ts b/modes.ts index d2bb1b7..e0661ea 100644 --- a/modes.ts +++ b/modes.ts @@ -33,6 +33,32 @@ export const modes: Mode[] = [ 9. When you are done, create a final commit. If relevant, indicate which issue the PR addresses somewhere in the commit message (e.g. "Fixes #123"). Create a PR with an informative title and body. If relevant, include links to the issue or comment that triggered the PR. 10. Update the Working Comment one final time with a summary of the results. Include links to any created issues/PRs, e.g. \`[View PR](https://github.com/org/repo/pull/123)\` +`, + }, + { + name: "Address Reviews", + description: + "Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR", + prompt: `Follow these steps: +1. ${initialCommentInstruction} + +2. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically fetches and checks out the PR branch) + +3. Review the feedback provided. Understand each review comment and what changes are being requested. + +4. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context. Read AGENTS.md if it exists. + +5. Make the necessary code changes to address the feedback. Work through each review comment systematically. + +6. After addressing each review comment, use ${ghPullfrogMcpName}/reply_to_review_comment to reply directly to that comment thread explaining what change was made (keep replies concise, 1-2 sentences). + +7. Test your changes to ensure they work correctly. + +8. Update your working comment using ${ghPullfrogMcpName}/update_working_comment to share overall progress. + +9. When done, commit and push your changes to the existing PR branch. Do not create a new branch or PR - you are updating an existing one. + +10. Update the Working Comment one final time with a summary of all changes made. `, }, {