From 975eaa9a64ee5c89356d21398a4550c856f00f2a Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 20 Nov 2025 15:35:11 -0500 Subject: [PATCH 01/12] use temp dir as home in codex --- agents/codex.ts | 1 + mcp/arkConfig.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/agents/codex.ts b/agents/codex.ts index d40f1c9..dc6198a 100644 --- a/agents/codex.ts +++ b/agents/codex.ts @@ -16,6 +16,7 @@ export const codex = agent({ run: async ({ payload, mcpServers, apiKey, cliPath, githubInstallationToken }) => { process.env.OPENAI_API_KEY = apiKey; process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken; + process.env.HOME = process.env.PULLFROG_TEMP_DIR; configureCodexMcpServers({ mcpServers, cliPath }); diff --git a/mcp/arkConfig.ts b/mcp/arkConfig.ts index 8128d85..c9cfb15 100644 --- a/mcp/arkConfig.ts +++ b/mcp/arkConfig.ts @@ -2,6 +2,6 @@ import { configure } from "arktype/config"; configure({ toJsonSchema: { - dialect: null, + // dialect: null, }, }); From 43acacd25a7fe5b02261dc35ddc949d1c4fa5179 Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 20 Nov 2025 16:09:55 -0500 Subject: [PATCH 02/12] improve types --- agents/shared.ts | 4 ++-- play.ts | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/agents/shared.ts b/agents/shared.ts index a60e757..381d052 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -12,8 +12,8 @@ import { log } from "../utils/cli.ts"; */ export interface AgentResult { success: boolean; - output?: string; - error?: string; + output?: string | undefined; + error?: string | undefined; metadata?: Record; } diff --git a/play.ts b/play.ts index c7b7a26..e04074a 100644 --- a/play.ts +++ b/play.ts @@ -6,15 +6,14 @@ import { flatMorph } from "@ark/util"; import arg from "arg"; import { config } from "dotenv"; import { agents } from "./agents/index.ts"; +import type { AgentResult } from "./agents/shared.ts"; import { type Inputs, main } from "./main.ts"; import { log } from "./utils/cli.ts"; import { setupTestRepo } from "./utils/setup.ts"; config(); -export async function run( - prompt: string -): Promise<{ success: boolean; output?: string | undefined; error?: string | undefined }> { +export async function run(prompt: string): Promise { try { const tempDir = join(process.cwd(), ".temp"); setupTestRepo({ tempDir, forceClean: true }); From e5878de9e413a02d4b05bd3e11fb5acd13318377 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Thu, 20 Nov 2025 15:37:08 -0800 Subject: [PATCH 03/12] Drop usage of execSync, switch to $ util --- mcp/files.ts | 15 +++++++---- mcp/pr.ts | 6 ++--- mcp/prInfo.ts | 8 +++--- scratch.ts | 21 +++++++-------- todo.md | 5 ++++ utils/setup.ts | 7 ++--- utils/shell.ts | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 106 insertions(+), 27 deletions(-) create mode 100644 utils/shell.ts diff --git a/mcp/files.ts b/mcp/files.ts index 8d566e7..f4f9555 100644 --- a/mcp/files.ts +++ b/mcp/files.ts @@ -1,5 +1,5 @@ -import { execSync } from "node:child_process"; import { type } from "arktype"; +import { $ } from "../utils/shell.ts"; import { contextualize, tool } from "./shared.ts"; export const ListFiles = type({ @@ -17,14 +17,19 @@ export const ListFilesTool = tool({ try { // Use git ls-files to list tracked files // This respects .gitignore and gives a clean list of source files - const command = path === "." ? "git ls-files" : `git ls-files ${path}`; - const output = execSync(command, { encoding: "utf-8" }); + const pathStr = path ?? "."; + const output = $("git", pathStr === "." ? ["ls-files"] : ["ls-files", pathStr], { + log: false, + }); const files = output.split("\n").filter((f) => f.trim() !== ""); if (files.length === 0) { // Fallback for non-git environments or untracked files - const findCmd = `find ${path} -maxdepth 3 -not -path '*/.*' -type f`; - const findOutput = execSync(findCmd, { encoding: "utf-8" }); + const findOutput = $( + "find", + [pathStr, "-maxdepth", "3", "-not", "-path", "*/.*", "-type", "f"], + { log: false } + ); return { files: findOutput.split("\n").filter((f) => f.trim() !== ""), method: "find", diff --git a/mcp/pr.ts b/mcp/pr.ts index 1e14350..abcfce7 100644 --- a/mcp/pr.ts +++ b/mcp/pr.ts @@ -1,6 +1,6 @@ -import { execSync } from "node:child_process"; import { type } from "arktype"; import { log } from "../utils/cli.ts"; +import { $ } from "../utils/shell.ts"; import { contextualize, tool } from "./shared.ts"; export const PullRequest = type({ @@ -15,9 +15,7 @@ export const PullRequestTool = tool({ parameters: PullRequest, execute: contextualize(async ({ title, body, base }, ctx) => { // Get the current branch name - const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { - encoding: "utf8", - }).trim(); + const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); log.info(`Current branch: ${currentBranch}`); diff --git a/mcp/prInfo.ts b/mcp/prInfo.ts index 857bb30..7e54698 100644 --- a/mcp/prInfo.ts +++ b/mcp/prInfo.ts @@ -1,6 +1,6 @@ -import { execSync } from "node:child_process"; import { type } from "arktype"; import { log } from "../utils/cli.ts"; +import { $ } from "../utils/shell.ts"; import { contextualize, tool } from "./shared.ts"; export const PullRequestInfo = type({ @@ -30,14 +30,14 @@ export const PullRequestInfoTool = tool({ // Automatically fetch and checkout branches for review log.info(`Fetching base branch: origin/${baseBranch}`); - execSync(`git fetch origin ${baseBranch} --depth=20`, { stdio: "inherit" }); + $("git", ["fetch", "origin", baseBranch, "--depth=20"]); log.info(`Fetching PR branch: origin/${headBranch}`); - execSync(`git fetch origin ${headBranch}`, { stdio: "inherit" }); + $("git", ["fetch", "origin", headBranch]); log.info(`Checking out PR branch: origin/${headBranch}`); // check out a local branch tracking the remote branch so we can push changes - execSync(`git checkout -B ${headBranch} origin/${headBranch}`, { stdio: "inherit" }); + $("git", ["checkout", "-B", headBranch, `origin/${headBranch}`]); return { number: data.number, diff --git a/scratch.ts b/scratch.ts index ef1edc8..64ee81b 100644 --- a/scratch.ts +++ b/scratch.ts @@ -2,15 +2,14 @@ import { spawnSync } from "child_process"; import { existsSync } from "fs"; function findCliPath(name: string): string | null { - - const result = spawnSync("which", [name], { encoding: "utf-8" }); - if (result.status === 0 && result.stdout) { - const cliPath = result.stdout.trim(); - if (cliPath && existsSync(cliPath)) { - return cliPath; - } - } - return null; + const result = spawnSync("which", [name], { encoding: "utf-8" }); + if (result.status === 0 && result.stdout) { + const cliPath = result.stdout.trim(); + if (cliPath && existsSync(cliPath)) { + return cliPath; + } } - -console.log(findCliPath("codei")); \ No newline at end of file + return null; +} + +console.log(findCliPath("codei")); diff --git a/todo.md b/todo.md index a7d7984..4f13abd 100644 --- a/todo.md +++ b/todo.md @@ -8,6 +8,11 @@ [] add footer to the working comment ("executed by {agent}", link to pullfrog (homepage) w/ small logo?, feedback (create github issue), link to workflow run)- see https://github.com/colinhacks/zod/issues/5459#issuecomment-3548382991 [] avoid passing all of process.env into agents: minimum # of vars [] toon encode in prompt +[] branching: use pullfrog/ prefix +[] test modifications to existing PRs (proper branching) +[] web access settings +[] implement Learnings +[] implement Billing ## MAYBE diff --git a/utils/setup.ts b/utils/setup.ts index 087573b..e204569 100644 --- a/utils/setup.ts +++ b/utils/setup.ts @@ -2,6 +2,7 @@ import { execSync } from "node:child_process"; import { existsSync, rmSync } from "node:fs"; import { log } from "./cli.ts"; import type { RepoContext } from "./github.ts"; +import { $ } from "./shell.ts"; export interface SetupOptions { tempDir: string; @@ -25,7 +26,7 @@ export function setupTestRepo(options: SetupOptions): void { rmSync(tempDir, { recursive: true, force: true }); log.info("📦 Cloning pullfrogai/scratch into .temp..."); - execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" }); + $("git", ["clone", repoUrl, tempDir]); } else { log.info("📦 Resetting existing .temp repository..."); execSync("git reset --hard HEAD && git clean -fd", { @@ -35,7 +36,7 @@ export function setupTestRepo(options: SetupOptions): void { } } else { log.info("📦 Cloning pullfrogai/scratch into .temp..."); - execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" }); + $("git", ["clone", repoUrl, tempDir]); } } @@ -87,6 +88,6 @@ export function setupGitAuth(githubToken: string, repoContext: RepoContext): voi // Update remote URL to embed the token const remoteUrl = `https://x-access-token:${githubToken}@github.com/${repoContext.owner}/${repoContext.name}.git`; - execSync(`git remote set-url origin "${remoteUrl}"`, { stdio: "inherit" }); + $("git", ["remote", "set-url", "origin", remoteUrl]); log.info("✓ Updated remote URL with authentication token"); } diff --git a/utils/shell.ts b/utils/shell.ts new file mode 100644 index 0000000..fe85b11 --- /dev/null +++ b/utils/shell.ts @@ -0,0 +1,71 @@ +import { spawnSync } from "node:child_process"; + +interface ShellOptions { + cwd?: string; + encoding?: + | "utf-8" + | "utf8" + | "ascii" + | "base64" + | "base64url" + | "hex" + | "latin1" + | "ucs-2" + | "ucs2" + | "utf16le"; + log?: boolean; + onError?: (result: { status: number; stdout: string; stderr: string }) => void; +} + +/** + * Execute a shell command safely using spawnSync with argument arrays. + * Prevents shell injection by avoiding string interpolation in shell commands. + * + * @param cmd - The command to execute + * @param args - Array of arguments to pass to the command + * @param options - Optional configuration (cwd, encoding, onError) + * @returns The trimmed stdout output + * @throws Error if command fails and no onError handler is provided + */ +export function $(cmd: string, args: string[], options?: ShellOptions): string { + const encoding = options?.encoding ?? "utf-8"; + const result = spawnSync(cmd, args, { + stdio: ["inherit", "pipe", "pipe"], + encoding, + cwd: options?.cwd, + }); + + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + + // Write output to process streams so it behaves like stdio: "inherit" + // Only log if log option is not explicitly set to false + if (options?.log !== false) { + if (stdout) { + process.stdout.write(stdout); + } + if (stderr) { + process.stderr.write(stderr); + } + } + + // Handle errors + if (result.status !== 0) { + const errorResult = { + status: result.status ?? -1, + stdout, + stderr, + }; + + if (options?.onError) { + options.onError(errorResult); + return stdout.trim(); + } + + throw new Error( + `Command failed with exit code ${errorResult.status}: ${stderr || "Unknown error"}` + ); + } + + return stdout.trim(); +} From f8bb2e12f3ce797938ed3942f9b766202c55cb08 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Thu, 20 Nov 2025 15:57:20 -0800 Subject: [PATCH 04/12] Make PayloadEvent typesafe w/ discriminated union --- agents/instructions.ts | 3 +- external.ts | 84 +++++++++++++++++++++++++++++++++++++++++- main.ts | 7 +++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/agents/instructions.ts b/agents/instructions.ts index ec9ec5e..cec07ce 100644 --- a/agents/instructions.ts +++ b/agents/instructions.ts @@ -1,3 +1,4 @@ +import { encode as toonEncode } from "@toon-format/toon"; import type { Payload } from "../external.ts"; import { ghPullfrogMcpName } from "../external.ts"; import { modes } from "../modes.ts"; @@ -62,4 +63,4 @@ ${[...modes, ...payload.modes].map((w) => ` - "${w.name}": ${w.description}`) ${payload.prompt} -${typeof payload.event === "string" ? payload.event : JSON.stringify(payload.event, null, 2)}`; +${toonEncode(payload.event)}`; diff --git a/external.ts b/external.ts index 2a00c87..29cf583 100644 --- a/external.ts +++ b/external.ts @@ -39,6 +39,86 @@ export type AgentName = keyof typeof agentsManifest; export type AgentApiKeyName = (typeof agentsManifest)[AgentName]["apiKeyNames"][number]; +// discriminated union for payload event based on trigger +export type PayloadEvent = + | { + trigger: "pull_request_opened"; + pr_number: number; + pr_title: string; + pr_body: string | null; + [key: string]: any; + } + | { + trigger: "pull_request_review_requested"; + pr_number: number; + pr_title: string; + pr_body: string | null; + [key: string]: any; + } + | { + trigger: "pull_request_review_submitted"; + pr_number: number; + review_id: number; + review_body: string | null; + review_state: string; + review_comments: any[]; + context: any; + [key: string]: any; + } + | { + trigger: "pull_request_review_comment_created"; + pr_number: number; + pr_title: string; + comment_id: number; + comment_body: string; + thread?: any; + [key: string]: any; + } + | { + trigger: "issues_opened"; + issue_number: number; + issue_title: string; + issue_body: string | null; + [key: string]: any; + } + | { + trigger: "issues_assigned"; + issue_number: number; + issue_title: string; + issue_body: string | null; + [key: string]: any; + } + | { + trigger: "issues_labeled"; + issue_number: number; + issue_title: string; + issue_body: string | null; + [key: string]: any; + } + | { + trigger: "issue_comment_created"; + comment_id: number; + comment_body: string; + issue_number: number; + [key: string]: any; + } + | { + trigger: "check_suite_completed"; + pr_number: number; + pr_title: string; + pr_body: string | null; + pull_request: any; + check_suite: { + id: number; + head_sha: string; + head_branch: string | null; + status: string | null; + conclusion: string | null; + url: string; + }; + [key: string]: any; + }; + // payload type for agent execution export type Payload = { "~pullfrog": true; @@ -55,9 +135,9 @@ export type Payload = { /** * Event data from webhook payload. - * Can be an object (will be JSON.stringify'd) or a string (used as-is). + * Discriminated union based on trigger field. */ - readonly event: object | string; + readonly event: PayloadEvent; /** * Execution mode configuration diff --git a/main.ts b/main.ts index 608c765..4eb7ea1 100644 --- a/main.ts +++ b/main.ts @@ -213,7 +213,12 @@ function parsePayload(inputs: Inputs): Payload { "~pullfrog": true, agent: null, prompt: inputs.prompt, - event: {}, + event: { + trigger: "issues_opened", + issue_number: 0, + issue_title: "", + issue_body: null, + } as Payload["event"], modes, }; } From 6c6b7b0b2d12e70cd1ba1f6387de515b336f12c4 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Thu, 20 Nov 2025 16:05:07 -0800 Subject: [PATCH 05/12] Add footer links --- main.ts | 2 +- mcp/comment.ts | 43 +++++++++++++++++++++++++++++++++++++++++-- mcp/config.ts | 25 +++++++++++++++++++------ mcp/shared.ts | 23 ++++++++++++++++++++--- 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/main.ts b/main.ts index 4eb7ea1..cd79f3c 100644 --- a/main.ts +++ b/main.ts @@ -226,7 +226,7 @@ function parsePayload(inputs: Inputs): Payload { function setupMcpServers(ctx: MainContext): void { const allModes = [...modes, ...(ctx.payload.modes || [])]; - ctx.mcpServers = createMcpConfigs(ctx.githubInstallationToken, allModes); + ctx.mcpServers = createMcpConfigs(ctx.githubInstallationToken, allModes, ctx.payload); log.debug(`📋 MCP Config: ${JSON.stringify(ctx.mcpServers, null, 2)}`); } diff --git a/mcp/comment.ts b/mcp/comment.ts index 5e1632b..6ef5c3d 100644 --- a/mcp/comment.ts +++ b/mcp/comment.ts @@ -1,6 +1,39 @@ import { type } from "arktype"; +import type { Payload } from "../external.ts"; +import { agentsManifest } from "../external.ts"; +import { parseRepoContext } from "../utils/github.ts"; import { contextualize, tool } from "./shared.ts"; +function buildCommentFooter(payload: Payload): string { + const repoContext = parseRepoContext(); + const runId = process.env.GITHUB_RUN_ID; + + const agentName = payload.agent; + const agentInfo = agentName ? agentsManifest[agentName] : null; + const agentDisplayName = agentInfo?.displayName || "Unknown Agent"; + + // agent URLs based on manifest + const agentUrls: Record = { + claude: "https://claude.com/claude-code", + codex: "https://platform.openai.com/docs/guides/codex", + cursor: "https://cursor.com/", + gemini: "https://ai.google.dev/gemini-api/docs", + }; + + const agentUrl = agentName + ? agentUrls[agentName] || "https://pullfrog.ai" + : "https://pullfrog.ai"; + + // build workflow run URL + const workflowRunUrl = runId + ? `https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId}` + : `https://github.com/${repoContext.owner}/${repoContext.name}`; + + return `--- + + Triggered by [Pullfrog](https://pullfrog.ai) | Using [${agentDisplayName}](${agentUrl}) | [View workflow run](${workflowRunUrl}) | [![Share on X](https://img.shields.io/badge/X-share-black)](https://x.com/pullfrogai)`; +} + export const Comment = type({ issueNumber: type.number.describe("the issue number to comment on"), body: type.string.describe("the comment body content"), @@ -73,11 +106,14 @@ export const CreateWorkingCommentTool = tool({ throw new Error("create_working_comment may not be called multiple times"); } + const footer = buildCommentFooter(ctx.payload); + const body = `${intent} ${footer}`; + const result = await ctx.octokit.rest.issues.createComment({ owner: ctx.owner, repo: ctx.name, issue_number: issueNumber, - body: `${intent} `, + body, }); workingCommentId = result.data.id; @@ -104,11 +140,14 @@ export const UpdateWorkingCommentTool = tool({ throw new Error("create_working_comment must be called before update_working_comment"); } + const footer = buildCommentFooter(ctx.payload); + const bodyWithFooter = `${body}${footer}`; + const result = await ctx.octokit.rest.issues.updateComment({ owner: ctx.owner, repo: ctx.name, comment_id: workingCommentId, - body, + body: bodyWithFooter, }); return { diff --git a/mcp/config.ts b/mcp/config.ts index a26e184..c374543 100644 --- a/mcp/config.ts +++ b/mcp/config.ts @@ -4,6 +4,7 @@ import type { McpStdioServerConfig } from "@anthropic-ai/claude-agent-sdk"; import { fromHere } from "@ark/fs"; +import type { Payload } from "../external.ts"; import { ghPullfrogMcpName } from "../external.ts"; import type { Mode } from "../modes.ts"; import { parseRepoContext } from "../utils/github.ts"; @@ -12,7 +13,11 @@ export type McpName = typeof ghPullfrogMcpName; export type McpConfigs = Record; -export function createMcpConfigs(githubInstallationToken: string, modes: Mode[]): McpConfigs { +export function createMcpConfigs( + githubInstallationToken: string, + modes: Mode[], + payload: Payload +): McpConfigs { const repoContext = parseRepoContext(); const githubRepository = `${repoContext.owner}/${repoContext.name}`; @@ -20,15 +25,23 @@ export function createMcpConfigs(githubInstallationToken: string, modes: Mode[]) // In development, server.ts is in the same directory as this file (config.ts) const serverPath = process.env.GITHUB_ACTIONS ? fromHere("mcp-server") : fromHere("server.ts"); + const env: Record = { + GITHUB_INSTALLATION_TOKEN: githubInstallationToken, + GITHUB_REPOSITORY: githubRepository, + PULLFROG_MODES: JSON.stringify(modes), + PULLFROG_PAYLOAD: JSON.stringify(payload), + }; + + // pass through GITHUB_RUN_ID if available (automatically set in GitHub Actions) + if (process.env.GITHUB_RUN_ID) { + env.GITHUB_RUN_ID = process.env.GITHUB_RUN_ID; + } + return { [ghPullfrogMcpName]: { command: "node", args: [serverPath], - env: { - GITHUB_INSTALLATION_TOKEN: githubInstallationToken, - GITHUB_REPOSITORY: githubRepository, - PULLFROG_MODES: JSON.stringify(modes), - }, + env, }, }; } diff --git a/mcp/shared.ts b/mcp/shared.ts index d86490d..6a70251 100644 --- a/mcp/shared.ts +++ b/mcp/shared.ts @@ -1,7 +1,7 @@ -import { cached } from "@ark/util"; 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 { log } from "../utils/cli.ts"; import { parseRepoContext, type RepoContext } from "../utils/github.ts"; @@ -13,7 +13,22 @@ export interface ToolResult { isError?: boolean; } -export const getMcpContext = cached((): McpContext => { +export function getPayload(): Payload { + const payloadEnv = process.env.PULLFROG_PAYLOAD; + if (!payloadEnv) { + throw new Error("PULLFROG_PAYLOAD environment variable is required"); + } + + try { + return JSON.parse(payloadEnv) as Payload; + } catch (error) { + throw new Error( + `Failed to parse PULLFROG_PAYLOAD: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +export function getMcpContext(): McpContext { const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN; if (!githubInstallationToken) { throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required"); @@ -24,11 +39,13 @@ export const getMcpContext = cached((): McpContext => { octokit: new Octokit({ auth: githubInstallationToken, }), + payload: getPayload(), }; -}); +} export interface McpContext extends RepoContext { octokit: Octokit; + payload: Payload; } export const tool = (toolDef: Tool>) => { From 0ce1d9fd7b38fe1a47d0d4d7eba548704101fd62 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Thu, 20 Nov 2025 16:31:00 -0800 Subject: [PATCH 06/12] deterministically set up working branch --- agents/instructions.ts | 1 + external.ts | 6 ++++++ main.ts | 3 ++- utils/setup.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/agents/instructions.ts b/agents/instructions.ts index cec07ce..785fa71 100644 --- a/agents/instructions.ts +++ b/agents/instructions.ts @@ -16,6 +16,7 @@ You do not add unecessary comments, tests, or documentation unless explicitly pr You adapt your writing style to the style of your coworkers, while never being unprofessional. You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions. You make reasonable assumptions when details are missing, but fail with an explicit error if critical information is missing (e.g. user asks to review a PR but does not provide a link or ID). +Never push commits directly to protected branches: main, master, production. Always create a feature branch. ## SECURITY diff --git a/external.ts b/external.ts index 29cf583..efda803 100644 --- a/external.ts +++ b/external.ts @@ -46,6 +46,7 @@ export type PayloadEvent = pr_number: number; pr_title: string; pr_body: string | null; + branch: string; [key: string]: any; } | { @@ -53,6 +54,7 @@ export type PayloadEvent = pr_number: number; pr_title: string; pr_body: string | null; + branch: string; [key: string]: any; } | { @@ -63,6 +65,7 @@ export type PayloadEvent = review_state: string; review_comments: any[]; context: any; + branch: string; [key: string]: any; } | { @@ -72,6 +75,7 @@ export type PayloadEvent = comment_id: number; comment_body: string; thread?: any; + branch: string; [key: string]: any; } | { @@ -100,6 +104,7 @@ export type PayloadEvent = comment_id: number; comment_body: string; issue_number: number; + branch?: string; [key: string]: any; } | { @@ -108,6 +113,7 @@ export type PayloadEvent = pr_title: string; pr_body: string | null; pull_request: any; + branch: string; check_suite: { id: number; head_sha: string; diff --git a/main.ts b/main.ts index cd79f3c..72ef58c 100644 --- a/main.ts +++ b/main.ts @@ -17,7 +17,7 @@ import { revokeInstallationToken, setupGitHubInstallationToken, } from "./utils/github.ts"; -import { setupGitAuth, setupGitConfig } from "./utils/setup.ts"; +import { setupGitAuth, setupGitBranch, setupGitConfig } from "./utils/setup.ts"; // runtime validation using agents (needed for ArkType) // Note: The AgentName type is defined in external.ts, this is the runtime validator @@ -57,6 +57,7 @@ export async function main(inputs: Inputs): Promise { setupMcpLogPolling(ctx); ctx.payload = parsePayload(inputs); + setupGitBranch(ctx.payload); setupMcpServers(ctx); await installAgentCli(ctx); validateApiKey(ctx); diff --git a/utils/setup.ts b/utils/setup.ts index e204569..985d38a 100644 --- a/utils/setup.ts +++ b/utils/setup.ts @@ -1,5 +1,6 @@ import { execSync } from "node:child_process"; import { existsSync, rmSync } from "node:fs"; +import type { Payload } from "../external.ts"; import { log } from "./cli.ts"; import type { RepoContext } from "./github.ts"; import { $ } from "./shell.ts"; @@ -91,3 +92,42 @@ export function setupGitAuth(githubToken: string, repoContext: RepoContext): voi $("git", ["remote", "set-url", "origin", remoteUrl]); log.info("✓ Updated remote URL with authentication token"); } + +/** + * Setup git branch based on payload event context + * Automatically checks out the appropriate branch before agent execution + */ +export function setupGitBranch(payload: Payload): void { + // Only set up git branch in GitHub Actions environment + // In local testing, this might interfere with local git state + if (!process.env.GITHUB_ACTIONS) { + return; + } + + const branch = payload.event.branch; + + if (!branch) { + log.debug("No branch specified in payload, using default branch"); + return; + } + + log.info(`🌿 Setting up git branch: ${branch}`); + + try { + // Fetch the branch from origin + log.debug(`Fetching branch from origin: ${branch}`); + execSync(`git fetch origin ${branch}`, { stdio: "pipe" }); + + // Checkout the branch, creating local tracking branch + log.debug(`Checking out branch: ${branch}`); + execSync(`git checkout -B ${branch} origin/${branch}`, { stdio: "pipe" }); + + log.info(`✓ Successfully checked out branch: ${branch}`); + } catch (error) { + // If git operations fail, log warning but don't fail the action + // The agent might still be able to work with the default branch + log.warning( + `Failed to checkout branch ${branch}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} From b460bd31094d31fcad82960f04ff6c418c729328 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Thu, 20 Nov 2025 16:46:13 -0800 Subject: [PATCH 07/12] Flesh out modes --- agents/instructions.ts | 2 +- modes.ts | 32 +++++++++++++++++++------------- todo.md | 11 +++++++++-- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/agents/instructions.ts b/agents/instructions.ts index 785fa71..83cabdf 100644 --- a/agents/instructions.ts +++ b/agents/instructions.ts @@ -16,7 +16,7 @@ You do not add unecessary comments, tests, or documentation unless explicitly pr You adapt your writing style to the style of your coworkers, while never being unprofessional. You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions. You make reasonable assumptions when details are missing, but fail with an explicit error if critical information is missing (e.g. user asks to review a PR but does not provide a link or ID). -Never push commits directly to protected branches: main, master, production. Always create a feature branch. +Never push commits directly to protected branches: main, master, production. Always create a feature branch. All created branches must be prefixed with "pullfrog/" and have VERY specific names in order to avoid collisions. ## SECURITY diff --git a/modes.ts b/modes.ts index 904c654..e20e29e 100644 --- a/modes.ts +++ b/modes.ts @@ -15,8 +15,7 @@ export const modes: Mode[] = [ "Create plans, break down tasks, outline steps, analyze requirements, understand scope of work, or provide task breakdowns", prompt: `Follow these steps: 1. ${initialCommentInstruction} - -2. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context (read AGENTS.md if it exists, understand how to install dependencies, run tests, run builds, and make changes according to best practices). Skip this step if the prompt is trivial and self-contained. + 2. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context (read AGENTS.md if it exists, understand how to install dependencies, run tests, run builds, and make changes according to best practices). Skip this step if the prompt is trivial and self-contained. 3. Analyze the request and break it down into clear, actionable tasks 4. Consider dependencies, potential challenges, and implementation order 5. Create a structured plan with clear milestones @@ -29,20 +28,23 @@ export const modes: Mode[] = [ "Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details", prompt: `Follow these steps: 1. ${initialCommentInstruction} -2. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context (read AGENTS.md if it exists, understand how to install dependencies, run tests, run builds, and make changes according to best practices). Skip this step if the prompt is trivial and self-contained. -3. Understand the requirements and any existing plan -4. Make the necessary code changes -5. Test your changes to ensure they work correctly -6. Update your comment using ${ghPullfrogMcpName}/update_working_comment to share progress and results -7. Continue updating the same comment as you make progress (never create additional comments - always use update_working_comment)`, +2. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context. Read AGENTS.md if it exists, understand how to install dependencies, run tests, run builds, and make changes according to best practices). Skip this step if the prompt is trivial and self-contained. +3. Create a branch for your work. The branch name should be prefixed with "pullfrog/". The rest of the name should reflect the exact changes you are making. It should be specific to avoid collisions with other branches. Never commit to directly to main, master, or production. +4. Understand the requirements and any existing plan +5. Make the necessary code changes. Create intermediate commits if called for. +6. Test your changes to ensure they work correctly +7. Update your comment using ${ghPullfrogMcpName}/update_working_comment to share progress and results. +8. Continue updating the same comment as you make progress. Never create additional comments. Always use update_working_comment. +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 and links to any created issues, PRs, etc. +`, }, { name: "Review", description: "Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness", prompt: `Follow these steps: -1. ${initialCommentInstruction} - +1. ${initialCommentInstruction} 2. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch) 3. View diff: git diff origin/...origin/ (use line numbers from this for inline comments, replace and with 'base' and 'head' from PR info) 4. Read files from the checked-out PR branch to understand the implementation @@ -57,9 +59,13 @@ export const modes: Mode[] = [ "Fallback for tasks that don't fit other workflows, direct prompts via comments, or requests requiring general assistance without a specific workflow pattern", prompt: `Follow these steps: 1. ${initialCommentInstruction} - 2. Perform the requested task. Only take action if you have high confidence that you understand what is being asked. If you are not sure, ask for clarification. Take stock of the tools at your disposal. -3. As your work progresses, update your Working Comment to share progress and results using ${ghPullfrogMcpName}/update_working_comment. Do not create additional comments unless you are explicitly asked to do so. -4. When you finish the task, update the Working Comment a final time with a summary of the results and links to any created issues, PRs, etc.`, +3. If the task involves making code changes: + - Create a branch for your work. The branch name should be prefixed with "pullfrog/". The rest of the name should reflect the exact changes you are making. It should be specific to avoid collisions with other branches. Never commit to directly to main, master, or production. + - Make the necessary code changes. Create intermediate commits if called for. + - Test your changes to ensure they work correctly. + - 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. +4. As your work progresses, update your Working Comment to share progress and results using ${ghPullfrogMcpName}/update_working_comment. Do not create additional comments unless you are explicitly asked to do so. +5. When you finish the task, update the Working Comment a final time with a summary of the results and links to any created issues, PRs, etc.`, }, ]; diff --git a/todo.md b/todo.md index 4f13abd..63d88dc 100644 --- a/todo.md +++ b/todo.md @@ -5,9 +5,16 @@ [] test agent/mode combinations [] test if home directory mcp.json works if mcp.json is specified in repo -[] add footer to the working comment ("executed by {agent}", link to pullfrog (homepage) w/ small logo?, feedback (create github issue), link to workflow run)- see https://github.com/colinhacks/zod/issues/5459#issuecomment-3548382991 +[] add footer to the working comment + - "executed by {agent}" + - link to pullfrog (homepage) w/ small logo? + - feedback (create github issue) + - link to workflow run + - Open in Pullfrog? + - update with "Execute Plan"? + - see https://github.com/colinhacks/zod/issues/5459#issuecomment-3548382991 [] avoid passing all of process.env into agents: minimum # of vars -[] toon encode in prompt +[x] toon encode in prompt [] branching: use pullfrog/ prefix [] test modifications to existing PRs (proper branching) [] web access settings From dd2089d71b28a2505781f02aae6c2bff5b7d76c9 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Thu, 20 Nov 2025 16:58:21 -0800 Subject: [PATCH 08/12] have payload.agent take precedence over inputs.defaultAgent --- action.yml | 9 +++++++++ entry.ts | 2 +- external.ts | 4 ++++ main.ts | 29 ++++++++++++++++------------- play.ts | 5 ++++- 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/action.yml b/action.yml index 128f5db..5b33ef3 100644 --- a/action.yml +++ b/action.yml @@ -7,6 +7,15 @@ inputs: description: "Prompt to send to Claude Code" required: true default: "Hello from Claude Code!" + defaultAgent: + description: "Default agent to use. Overridden by payload.agent if present." + required: false + type: choice + options: + - claude + - codex + - cursor + - gemini anthropic_api_key: description: "Anthropic API key for Claude Code authentication" required: false diff --git a/entry.ts b/entry.ts index dd2ac43..6f9d77c 100644 --- a/entry.ts +++ b/entry.ts @@ -22,7 +22,7 @@ async function run(): Promise { try { const inputs: Required = { prompt: core.getInput("prompt", { required: true }), - agent: core.getInput("agent") ? AgentName.assert(core.getInput("agent")) : undefined, + defaultAgent: core.getInput("defaultAgent") ? AgentName.assert(core.getInput("defaultAgent")) : undefined, ...flatMorph(agents, (_, agent) => agent.apiKeyNames.map((inputKey) => [inputKey, core.getInput(inputKey)]) ), diff --git a/external.ts b/external.ts index efda803..b1b3d6a 100644 --- a/external.ts +++ b/external.ts @@ -123,6 +123,10 @@ export type PayloadEvent = url: string; }; [key: string]: any; + } + | { + trigger: "unknown"; + [key: string]: any; }; // payload type for agent execution diff --git a/main.ts b/main.ts index 72ef58c..9127a99 100644 --- a/main.ts +++ b/main.ts @@ -35,7 +35,9 @@ const keyInputDefs = flatMorph(agents, (_, agent) => export const Inputs = type({ prompt: "string", ...keyInputDefs, - "agent?": type.enumerated(...Object.values(agents).map((agent) => agent.name)).or("undefined"), + "defaultAgent?": type + .enumerated(...Object.values(agents).map((agent) => agent.name)) + .or("undefined"), }); export type Inputs = typeof Inputs.infer; @@ -51,12 +53,13 @@ export async function main(inputs: Inputs): Promise { const ctx = partialCtx as MainContext; try { + // parse payload early to extract agent for determineAgent + ctx.payload = parsePayload(inputs); await determineAgent(ctx); setupGitAuth(ctx.githubInstallationToken, ctx.repoContext); await setupTempDirectory(ctx); setupMcpLogPolling(ctx); - ctx.payload = parsePayload(inputs); setupGitBranch(ctx.payload); setupMcpServers(ctx); await installAgentCli(ctx); @@ -73,7 +76,10 @@ export async function main(inputs: Inputs): Promise { error: errorMessage, }; } finally { - await cleanup(partialCtx); + await cleanup({ + ...partialCtx, + payload: ctx.payload, + }); } } @@ -167,14 +173,16 @@ async function initializeContext( } async function determineAgent( - ctx: Omit + ctx: Omit ): Promise { const repoSettings = await fetchRepoSettings({ token: ctx.githubInstallationToken, repoContext: ctx.repoContext, }); - ctx.agentName = ctx.inputs.agent || repoSettings.defaultAgent || "claude"; + // precedence: payload.agent > inputs.defaultAgent > repoSettings.defaultAgent > "claude" + ctx.agentName = + ctx.payload.agent || ctx.inputs.defaultAgent || repoSettings.defaultAgent || "claude"; ctx.agent = agents[ctx.agentName]; } @@ -215,11 +223,8 @@ function parsePayload(inputs: Inputs): Payload { agent: null, prompt: inputs.prompt, event: { - trigger: "issues_opened", - issue_number: 0, - issue_title: "", - issue_body: null, - } as Payload["event"], + trigger: "unknown", + }, modes, }; } @@ -283,9 +288,7 @@ async function handleAgentResult( }; } -async function cleanup( - ctx: Omit -): Promise { +async function cleanup(ctx: Omit): Promise { if (ctx.pollInterval) { clearInterval(ctx.pollInterval); } diff --git a/play.ts b/play.ts index e04074a..7926fe0 100644 --- a/play.ts +++ b/play.ts @@ -21,9 +21,12 @@ export async function run(prompt: string): Promise { const originalCwd = process.cwd(); process.chdir(tempDir); + // check if prompt is a pullfrog payload and extract agent + // note: agent from payload will be used by determineAgent with highest precedence + // we don't need to extract it here since main() will parse the payload const inputs: Required = { prompt, - agent: "codex", + defaultAgent: undefined, ...flatMorph(agents, (_, agent) => agent.apiKeyNames.map((inputKey) => [inputKey, process.env[inputKey.toUpperCase()]]) ), From 935fe26013a9170d48a4d8cc2687a4f4956ebc1b Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Thu, 20 Nov 2025 17:05:42 -0800 Subject: [PATCH 09/12] Tweak footer --- mcp/comment.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mcp/comment.ts b/mcp/comment.ts index 6ef5c3d..7aedf2b 100644 --- a/mcp/comment.ts +++ b/mcp/comment.ts @@ -29,9 +29,11 @@ function buildCommentFooter(payload: Payload): string { ? `https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId}` : `https://github.com/${repoContext.owner}/${repoContext.name}`; - return `--- + return ` - Triggered by [Pullfrog](https://pullfrog.ai) | Using [${agentDisplayName}](${agentUrl}) | [View workflow run](${workflowRunUrl}) | [![Share on X](https://img.shields.io/badge/X-share-black)](https://x.com/pullfrogai)`; +--- + +Triggered by [Pullfrog](https://pullfrog.ai) | Using [${agentDisplayName}](${agentUrl}) | [View workflow run](${workflowRunUrl}) | [𝕏](https://x.com/pullfrogai)`; } export const Comment = type({ From 8298cdd07ca966551cc9fbfbcbb7e3cb01a8bf30 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Thu, 20 Nov 2025 17:06:52 -0800 Subject: [PATCH 10/12] add urls to agent manifest --- external.ts | 5 +++++ mcp/comment.ts | 13 +------------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/external.ts b/external.ts index b1b3d6a..ac5daa0 100644 --- a/external.ts +++ b/external.ts @@ -12,6 +12,7 @@ export const ghPullfrogMcpName = "gh_pullfrog"; export interface AgentManifest { displayName: string; apiKeyNames: string[]; + url: string; } // agent manifest - static metadata about available agents @@ -19,18 +20,22 @@ export const agentsManifest = { claude: { displayName: "Claude Code", apiKeyNames: ["anthropic_api_key"], + url: "https://claude.com/claude-code", }, codex: { displayName: "Codex CLI", apiKeyNames: ["openai_api_key"], + url: "https://platform.openai.com/docs/guides/codex", }, cursor: { displayName: "Cursor CLI", apiKeyNames: ["cursor_api_key"], + url: "https://cursor.com/", }, gemini: { displayName: "Gemini CLI", apiKeyNames: ["google_api_key", "gemini_api_key"], + url: "https://ai.google.dev/gemini-api/docs", }, } as const satisfies Record; diff --git a/mcp/comment.ts b/mcp/comment.ts index 7aedf2b..45f82ac 100644 --- a/mcp/comment.ts +++ b/mcp/comment.ts @@ -11,18 +11,7 @@ function buildCommentFooter(payload: Payload): string { const agentName = payload.agent; const agentInfo = agentName ? agentsManifest[agentName] : null; const agentDisplayName = agentInfo?.displayName || "Unknown Agent"; - - // agent URLs based on manifest - const agentUrls: Record = { - claude: "https://claude.com/claude-code", - codex: "https://platform.openai.com/docs/guides/codex", - cursor: "https://cursor.com/", - gemini: "https://ai.google.dev/gemini-api/docs", - }; - - const agentUrl = agentName - ? agentUrls[agentName] || "https://pullfrog.ai" - : "https://pullfrog.ai"; + const agentUrl = agentInfo?.url || "https://pullfrog.ai"; // build workflow run URL const workflowRunUrl = runId From 550a162ca63c735b0df1d1443bcb6b821df8770a Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Thu, 20 Nov 2025 18:56:45 -0800 Subject: [PATCH 11/12] fix mcp tools by passing pullfrog_temp_dir to server and handling home directory correctly for codex and cursor --- agents/codex.ts | 11 ++++++++++- agents/cursor.ts | 21 ++++++++++----------- mcp/config.ts | 1 + 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/agents/codex.ts b/agents/codex.ts index dc6198a..eaaa20c 100644 --- a/agents/codex.ts +++ b/agents/codex.ts @@ -1,4 +1,6 @@ import { spawnSync } from "node:child_process"; +import { mkdirSync } from "node:fs"; +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"; @@ -16,7 +18,13 @@ export const codex = agent({ run: async ({ payload, mcpServers, apiKey, cliPath, githubInstallationToken }) => { process.env.OPENAI_API_KEY = apiKey; process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken; - process.env.HOME = process.env.PULLFROG_TEMP_DIR; + + // 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; configureCodexMcpServers({ mcpServers, cliPath }); @@ -45,6 +53,7 @@ export const codex = agent({ let finalOutput = ""; for await (const event of streamedTurn.events) { const handler = messageHandlers[event.type]; + console.log(event as never); if (handler) { handler(event as never); } diff --git a/agents/cursor.ts b/agents/cursor.ts index 99cc790..14533a3 100644 --- a/agents/cursor.ts +++ b/agents/cursor.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; import { log } from "../utils/cli.ts"; import { addInstructions } from "./instructions.ts"; @@ -25,9 +26,6 @@ export const cursor = agent({ // and --approve-mcps to automatically approve all MCP servers const fullPrompt = addInstructions(payload); - // Find temp directory from cliPath to set HOME for MCP config lookup - const tempDir = cliPath.split("/.local/bin/")[0]; - log.info("Running Cursor CLI..."); // Use spawn to handle streaming output @@ -42,7 +40,8 @@ export const cursor = agent({ ...process.env, CURSOR_API_KEY: apiKey, GITHUB_INSTALLATION_TOKEN: githubInstallationToken, - HOME: tempDir, // Set HOME so Cursor CLI can find .cursor/mcp.json + // 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 } @@ -116,14 +115,14 @@ export const cursor = agent({ }, }); -/** - * Configure MCP servers for Cursor by writing to the Cursor configuration file. - * For cursor, we need to add the MCP servers to the Cursor configuration file manually as there is no CLI command to do this. - */ -function configureCursorMcpServers({ mcpServers, cliPath }: ConfigureMcpServersParams) { - const tempDir = cliPath.split("/.local/bin/")[0]; - const cursorConfigDir = join(tempDir, ".cursor"); +// 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({ mcpServers }: ConfigureMcpServersParams) { + const realHome = homedir(); + const cursorConfigDir = join(realHome, ".cursor"); const mcpConfigPath = join(cursorConfigDir, "mcp.json"); mkdirSync(cursorConfigDir, { recursive: true }); writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8"); + log.info(`MCP config written to ${mcpConfigPath}`); } diff --git a/mcp/config.ts b/mcp/config.ts index c374543..374bb39 100644 --- a/mcp/config.ts +++ b/mcp/config.ts @@ -30,6 +30,7 @@ export function createMcpConfigs( GITHUB_REPOSITORY: githubRepository, PULLFROG_MODES: JSON.stringify(modes), PULLFROG_PAYLOAD: JSON.stringify(payload), + PULLFROG_TEMP_DIR: process.env.PULLFROG_TEMP_DIR!, }; // pass through GITHUB_RUN_ID if available (automatically set in GitHub Actions) From 595b246235aeeb5c63a8fee8d40d4d60d76c552b Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Thu, 20 Nov 2025 18:58:20 -0800 Subject: [PATCH 12/12] update instructions fixtures and comment handling --- agents/instructions.ts | 1 + fixtures/basic.txt | 4 +++- mcp/comment.ts | 37 ++++++++++++++++++++++++++++--------- play.ts | 3 ++- utils/api.ts | 2 -- utils/setup.ts | 6 ------ 6 files changed, 34 insertions(+), 19 deletions(-) diff --git a/agents/instructions.ts b/agents/instructions.ts index 83cabdf..d97da94 100644 --- a/agents/instructions.ts +++ b/agents/instructions.ts @@ -17,6 +17,7 @@ You adapt your writing style to the style of your coworkers, while never being u You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions. You make reasonable assumptions when details are missing, but fail with an explicit error if critical information is missing (e.g. user asks to review a PR but does not provide a link or ID). Never push commits directly to protected branches: main, master, production. Always create a feature branch. All created branches must be prefixed with "pullfrog/" and have VERY specific names in order to avoid collisions. +Never add co-author trailers (e.g., "Co-authored-by" or "Co-Authored-By") to commit messages. Commits should only include the commit message itself, without any co-author attribution. ## SECURITY diff --git a/fixtures/basic.txt b/fixtures/basic.txt index 789260c..117d20a 100644 --- a/fixtures/basic.txt +++ b/fixtures/basic.txt @@ -1 +1,3 @@ -create a comment on https://github.com/pullfrogai/scratch/pull/29 that says ribbit \ No newline at end of file +Print the MCP tools available to you. +Try to call select_mode with the name "Plan". +Then tell me a joke \ No newline at end of file diff --git a/mcp/comment.ts b/mcp/comment.ts index 45f82ac..3cc630e 100644 --- a/mcp/comment.ts +++ b/mcp/comment.ts @@ -4,6 +4,8 @@ import { agentsManifest } from "../external.ts"; import { parseRepoContext } from "../utils/github.ts"; import { contextualize, tool } from "./shared.ts"; +const PULLFROG_DIVIDER = ""; + function buildCommentFooter(payload: Payload): string { const repoContext = parseRepoContext(); const runId = process.env.GITHUB_RUN_ID; @@ -19,10 +21,24 @@ function buildCommentFooter(payload: Payload): string { : `https://github.com/${repoContext.owner}/${repoContext.name}`; return ` - +${PULLFROG_DIVIDER} --- -Triggered by [Pullfrog](https://pullfrog.ai) | Using [${agentDisplayName}](${agentUrl}) | [View workflow run](${workflowRunUrl}) | [𝕏](https://x.com/pullfrogai)`; +🐸 Triggered by [Pullfrog](https://pullfrog.ai) | 🤖 [${agentDisplayName}](${agentUrl}) | [View workflow run](${workflowRunUrl}) | [𝕏](https://x.com/pullfrogai)`; +} + +function stripExistingFooter(body: string): string { + const dividerIndex = body.indexOf(PULLFROG_DIVIDER); + if (dividerIndex === -1) { + return body; + } + return body.substring(0, dividerIndex).trimEnd(); +} + +function addFooter(body: string, payload: Payload): string { + const bodyWithoutFooter = stripExistingFooter(body); + const footer = buildCommentFooter(payload); + return `${bodyWithoutFooter}${footer}`; } export const Comment = type({ @@ -35,11 +51,13 @@ export const CreateCommentTool = tool({ description: "Create a comment on a GitHub issue", parameters: Comment, execute: contextualize(async ({ issueNumber, body }, ctx) => { + const bodyWithFooter = addFooter(body, ctx.payload); + const result = await ctx.octokit.rest.issues.createComment({ owner: ctx.owner, repo: ctx.name, issue_number: issueNumber, - body: body, + body: bodyWithFooter, }); return { @@ -61,11 +79,13 @@ export const EditCommentTool = tool({ description: "Edit a GitHub issue comment by its ID", parameters: EditComment, execute: contextualize(async ({ commentId, body }, ctx) => { + const bodyWithFooter = addFooter(body, ctx.payload); + const result = await ctx.octokit.rest.issues.updateComment({ owner: ctx.owner, repo: ctx.name, comment_id: commentId, - body: body, + body: bodyWithFooter, }); return { @@ -97,14 +117,14 @@ export const CreateWorkingCommentTool = tool({ throw new Error("create_working_comment may not be called multiple times"); } - const footer = buildCommentFooter(ctx.payload); - const body = `${intent} ${footer}`; + const body = `${intent} `; + const bodyWithFooter = addFooter(body, ctx.payload); const result = await ctx.octokit.rest.issues.createComment({ owner: ctx.owner, repo: ctx.name, issue_number: issueNumber, - body, + body: bodyWithFooter, }); workingCommentId = result.data.id; @@ -131,8 +151,7 @@ export const UpdateWorkingCommentTool = tool({ throw new Error("create_working_comment must be called before update_working_comment"); } - const footer = buildCommentFooter(ctx.payload); - const bodyWithFooter = `${body}${footer}`; + const bodyWithFooter = addFooter(body, ctx.payload); const result = await ctx.octokit.rest.issues.updateComment({ owner: ctx.owner, diff --git a/play.ts b/play.ts index 7926fe0..3d12db4 100644 --- a/play.ts +++ b/play.ts @@ -12,6 +12,7 @@ import { log } from "./utils/cli.ts"; import { setupTestRepo } from "./utils/setup.ts"; config(); +config({ path: join(process.cwd(), "../.env") }); export async function run(prompt: string): Promise { try { @@ -26,7 +27,7 @@ export async function run(prompt: string): Promise { // we don't need to extract it here since main() will parse the payload const inputs: Required = { prompt, - defaultAgent: undefined, + defaultAgent: "cursor", ...flatMorph(agents, (_, agent) => agent.apiKeyNames.map((inputKey) => [inputKey, process.env[inputKey.toUpperCase()]]) ), diff --git a/utils/api.ts b/utils/api.ts index b9b95f5..cd6c7ff 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -36,9 +36,7 @@ export async function fetchRepoSettings({ token: string; repoContext: RepoContext; }): Promise { - log.info("Fetching repository settings..."); const settings = await getRepoSettings(token, repoContext); - log.info("Repository settings fetched"); return settings; } diff --git a/utils/setup.ts b/utils/setup.ts index 985d38a..fd332a9 100644 --- a/utils/setup.ts +++ b/utils/setup.ts @@ -98,12 +98,6 @@ export function setupGitAuth(githubToken: string, repoContext: RepoContext): voi * Automatically checks out the appropriate branch before agent execution */ export function setupGitBranch(payload: Payload): void { - // Only set up git branch in GitHub Actions environment - // In local testing, this might interfere with local git state - if (!process.env.GITHUB_ACTIONS) { - return; - } - const branch = payload.event.branch; if (!branch) {