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/agents/codex.ts b/agents/codex.ts index d40f1c9..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"; @@ -17,6 +19,13 @@ export const codex = agent({ process.env.OPENAI_API_KEY = apiKey; process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken; + // 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 }); // Configure Codex @@ -44,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 875e7d6..24127dc 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"; @@ -22,8 +23,6 @@ export const cursor = agent({ try { const fullPrompt = addInstructions(payload); - const tempDir = cliPath.split("/.local/bin/")[0]; - log.info("Running Cursor CLI..."); return new Promise((resolve) => { @@ -36,7 +35,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 } @@ -107,14 +107,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/agents/instructions.ts b/agents/instructions.ts index ec9ec5e..d97da94 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"; @@ -15,6 +16,8 @@ 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. 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 @@ -62,4 +65,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/agents/shared.ts b/agents/shared.ts index 71a9816..683dc33 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -14,8 +14,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/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 2a00c87..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; @@ -39,6 +44,96 @@ 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; + branch: string; + [key: string]: any; + } + | { + trigger: "pull_request_review_requested"; + pr_number: number; + pr_title: string; + pr_body: string | null; + branch: string; + [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; + branch: string; + [key: string]: any; + } + | { + trigger: "pull_request_review_comment_created"; + pr_number: number; + pr_title: string; + comment_id: number; + comment_body: string; + thread?: any; + branch: string; + [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; + branch?: string; + [key: string]: any; + } + | { + trigger: "check_suite_completed"; + pr_number: number; + pr_title: string; + pr_body: string | null; + pull_request: any; + branch: string; + check_suite: { + id: number; + head_sha: string; + head_branch: string | null; + status: string | null; + conclusion: string | null; + url: string; + }; + [key: string]: any; + } + | { + trigger: "unknown"; + [key: string]: any; + }; + // payload type for agent execution export type Payload = { "~pullfrog": true; @@ -55,9 +150,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/fixtures/basic.txt b/fixtures/basic.txt index b9fcc52..117d20a 100644 --- a/fixtures/basic.txt +++ b/fixtures/basic.txt @@ -1 +1,3 @@ -comment on https://github.com/pullfrogai/scratch/pull/29 that writes a really funny joke about Codex \ 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/main.ts b/main.ts index 608c765..9127a99 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 @@ -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,14 @@ 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); validateApiKey(ctx); @@ -72,7 +76,10 @@ export async function main(inputs: Inputs): Promise { error: errorMessage, }; } finally { - await cleanup(partialCtx); + await cleanup({ + ...partialCtx, + payload: ctx.payload, + }); } } @@ -166,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]; } @@ -213,7 +222,9 @@ function parsePayload(inputs: Inputs): Payload { "~pullfrog": true, agent: null, prompt: inputs.prompt, - event: {}, + event: { + trigger: "unknown", + }, modes, }; } @@ -221,7 +232,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)}`); } @@ -277,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/mcp/comment.ts b/mcp/comment.ts index 5e1632b..3cc630e 100644 --- a/mcp/comment.ts +++ b/mcp/comment.ts @@ -1,6 +1,46 @@ 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"; +const PULLFROG_DIVIDER = ""; + +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"; + const agentUrl = agentInfo?.url || "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 ` +${PULLFROG_DIVIDER} +--- + +🐸 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({ issueNumber: type.number.describe("the issue number to comment on"), body: type.string.describe("the comment body content"), @@ -11,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 { @@ -37,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 { @@ -73,11 +117,14 @@ export const CreateWorkingCommentTool = tool({ throw new Error("create_working_comment may not be called multiple times"); } + 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: `${intent} `, + body: bodyWithFooter, }); workingCommentId = result.data.id; @@ -104,11 +151,13 @@ export const UpdateWorkingCommentTool = tool({ throw new Error("create_working_comment must be called before update_working_comment"); } + const bodyWithFooter = addFooter(body, ctx.payload); + 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..374bb39 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,24 @@ 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), + PULLFROG_TEMP_DIR: process.env.PULLFROG_TEMP_DIR!, + }; + + // 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/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/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>) => { 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/play.ts b/play.ts index c7b7a26..3d12db4 100644 --- a/play.ts +++ b/play.ts @@ -6,15 +6,15 @@ 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(); +config({ path: join(process.cwd(), "../.env") }); -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 }); @@ -22,9 +22,12 @@ export async function run( 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: "cursor", ...flatMorph(agents, (_, agent) => agent.apiKeyNames.map((inputKey) => [inputKey, process.env[inputKey.toUpperCase()]]) ), 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..63d88dc 100644 --- a/todo.md +++ b/todo.md @@ -5,9 +5,21 @@ [] 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 +[] implement Learnings +[] implement Billing ## MAYBE 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 087573b..fd332a9 100644 --- a/utils/setup.ts +++ b/utils/setup.ts @@ -1,7 +1,9 @@ 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"; export interface SetupOptions { tempDir: string; @@ -25,7 +27,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 +37,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 +89,39 @@ 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"); } + +/** + * Setup git branch based on payload event context + * Automatically checks out the appropriate branch before agent execution + */ +export function setupGitBranch(payload: Payload): void { + 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)}` + ); + } +} 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(); +}