From 1bff21f7fb34ee477b10651c70971b38eb0061c3 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Tue, 16 Dec 2025 18:00:22 -0800 Subject: [PATCH] overhaul git setup --- agents/claude.ts | 4 +- agents/codex.ts | 4 +- agents/cursor.ts | 4 +- agents/gemini.ts | 4 +- agents/instructions.ts | 85 +++++++++-- agents/opencode.ts | 4 +- agents/shared.ts | 10 ++ entry | 288 ++++++++++++++++++++++++----------- fixtures/basic.txt | 2 +- main.ts | 46 ++++-- mcp/checkout.ts | 105 +++++++++++++ mcp/git.ts | 32 ++-- mcp/pr.ts | 28 +++- mcp/prInfo.ts | 2 +- mcp/review.ts | 5 +- mcp/server.ts | 2 + modes.ts | 13 +- utils/buildPullfrogFooter.ts | 11 +- utils/setup.ts | 60 ++------ 19 files changed, 514 insertions(+), 195 deletions(-) create mode 100644 mcp/checkout.ts diff --git a/agents/claude.ts b/agents/claude.ts index 36570fd..72a0cf8 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -14,11 +14,11 @@ export const claude = agent({ executablePath: "cli.js", }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, prepResults }) => { + run: async ({ payload, mcpServers, apiKey, cliPath, prepResults, repo }) => { // Ensure API key is NOT in process.env - only pass via SDK's env option delete process.env.ANTHROPIC_API_KEY; - const prompt = addInstructions({ payload, prepResults }); + const prompt = addInstructions({ payload, prepResults, repo }); console.log(prompt); // configure sandbox mode if enabled diff --git a/agents/codex.ts b/agents/codex.ts index c7007df..3477eb6 100644 --- a/agents/codex.ts +++ b/agents/codex.ts @@ -20,7 +20,7 @@ export const codex = agent({ executablePath: "bin/codex.js", }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, prepResults }) => { + run: async ({ payload, mcpServers, apiKey, cliPath, prepResults, repo }) => { // create config directory for codex before setting HOME const tempHome = process.env.PULLFROG_TEMP_DIR!; const configDir = join(tempHome, ".config", "codex"); @@ -61,7 +61,7 @@ export const codex = agent({ ); try { - const streamedTurn = await thread.runStreamed(addInstructions({ payload, prepResults })); + const streamedTurn = await thread.runStreamed(addInstructions({ payload, prepResults, repo })); let finalOutput = ""; for await (const event of streamedTurn.events) { diff --git a/agents/cursor.ts b/agents/cursor.ts index 38ac9a5..02bd6d0 100644 --- a/agents/cursor.ts +++ b/agents/cursor.ts @@ -91,7 +91,7 @@ export const cursor = agent({ executableName: "cursor-agent", }); }, - run: async ({ payload, apiKey, cliPath, mcpServers, prepResults }) => { + run: async ({ payload, apiKey, cliPath, mcpServers, prepResults, repo }) => { configureCursorMcpServers({ mcpServers, cliPath }); configureCursorSandbox({ sandbox: payload.sandbox ?? false }); @@ -166,7 +166,7 @@ export const cursor = agent({ }; try { - const fullPrompt = addInstructions({ payload, prepResults }); + const fullPrompt = addInstructions({ payload, prepResults, repo }); // configure sandbox mode if enabled // in sandbox mode: remove --force flag and rely on cli-config.json sandbox settings diff --git a/agents/gemini.ts b/agents/gemini.ts index c1819e8..f07223b 100644 --- a/agents/gemini.ts +++ b/agents/gemini.ts @@ -154,14 +154,14 @@ export const gemini = agent({ ...(githubInstallationToken && { githubInstallationToken }), }); }, - run: async ({ payload, apiKey, mcpServers, cliPath, prepResults }) => { + run: async ({ payload, apiKey, mcpServers, cliPath, prepResults, repo }) => { configureGeminiMcpServers({ mcpServers, cliPath }); if (!apiKey) { throw new Error("google_api_key or gemini_api_key is required for gemini agent"); } - const sessionPrompt = addInstructions({ payload, prepResults }); + const sessionPrompt = addInstructions({ payload, prepResults, repo }); log.info(`Starting Gemini CLI with prompt: ${payload.prompt.substring(0, 100)}...`); // configure sandbox mode if enabled diff --git a/agents/instructions.ts b/agents/instructions.ts index 30e0fa3..42dfefc 100644 --- a/agents/instructions.ts +++ b/agents/instructions.ts @@ -1,3 +1,4 @@ +import { execSync } from "node:child_process"; import { encode as toonEncode } from "@toon-format/toon"; import type { Payload } from "../external.ts"; import { ghPullfrogMcpName } from "../external.ts"; @@ -62,19 +63,74 @@ function formatPrepResults(results: PrepResult[]): string { return ""; } - return `************* ENVIRONMENT SETUP ************* + return lines.join("\n"); +} -${lines.join("\n")} +interface RepoInfo { + owner: string; + name: string; + defaultBranch: string; +} -`; +interface BuildRuntimeContextParams { + repo: RepoInfo; + prepResults: PrepResult[]; +} + +/** + * Build runtime context string with git status, repo data, and GitHub Actions variables + */ +function buildRuntimeContext({ repo, prepResults }: BuildRuntimeContextParams): string { + const lines: string[] = []; + + // working directory + lines.push(`working_directory: ${process.cwd()}`); + + // git status (try to get it, but don't fail if git isn't available) + try { + const gitStatus = execSync("git status --short", { encoding: "utf-8", stdio: "pipe" }).trim(); + lines.push(`git_status: ${gitStatus || "(clean)"}`); + } catch { + // git not available or not in a repo + } + + // repo data + lines.push(`repo: ${repo.owner}/${repo.name}`); + lines.push(`default_branch: ${repo.defaultBranch}`); + + // GitHub Actions variables (when running in CI) + const ghVars: Record = { + github_event_name: process.env.GITHUB_EVENT_NAME, + github_ref: process.env.GITHUB_REF, + github_sha: process.env.GITHUB_SHA?.slice(0, 7), + github_actor: process.env.GITHUB_ACTOR, + github_run_id: process.env.GITHUB_RUN_ID, + github_workflow: process.env.GITHUB_WORKFLOW, + }; + for (const [key, value] of Object.entries(ghVars)) { + if (value) { + lines.push(`${key}: ${value}`); + } + } + + // environment setup (dependency installation results) + const envSetup = formatPrepResults(prepResults); + if (envSetup) { + lines.push(""); + lines.push("environment_setup:"); + lines.push(envSetup); + } + + return lines.join("\n"); } interface AddInstructionsParams { payload: Payload; prepResults: PrepResult[]; + repo: RepoInfo; } -export const addInstructions = ({ payload, prepResults }: AddInstructionsParams) => { +export const addInstructions = ({ payload, prepResults, repo }: AddInstructionsParams) => { let encodedEvent = ""; const eventKeys = Object.keys(payload.event); @@ -86,7 +142,7 @@ export const addInstructions = ({ payload, prepResults }: AddInstructionsParams) encodedEvent = toonEncode(payload.event); } - const envSetup = formatPrepResults(prepResults); + const runtimeContext = buildRuntimeContext({ repo, prepResults }); const dependenciesPreinstalled = prepResults.every((r) => r.dependenciesInstalled) || undefined; return ` @@ -163,14 +219,21 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g **Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author. **Available git MCP tools**: +- \`${ghPullfrogMcpName}/checkout_pr\` - Checkout an existing PR branch locally (handles fork PRs automatically) - \`${ghPullfrogMcpName}/create_branch\` - Create a new branch from a base branch - \`${ghPullfrogMcpName}/commit_files\` - Stage and commit files with proper authentication -- \`${ghPullfrogMcpName}/push_branch\` - Push a branch to the remote repository +- \`${ghPullfrogMcpName}/push_branch\` - Push a branch to the remote (automatically uses correct remote for fork PRs) - \`${ghPullfrogMcpName}/create_pull_request\` - Create a PR from the current branch -**Workflow for making code changes**: -1. Use file operations to create/modify files -2. Use \`${ghPullfrogMcpName}/create_branch\` to create a new branch +**Workflow for working on an existing PR**: +1. Use \`${ghPullfrogMcpName}/checkout_pr\` to checkout the PR branch +2. Make your changes using file operations +3. Use \`${ghPullfrogMcpName}/commit_files\` to commit your changes +4. Use \`${ghPullfrogMcpName}/push_branch\` to push (automatically pushes to fork for fork PRs) + +**Workflow for creating new changes**: +1. Use \`${ghPullfrogMcpName}/create_branch\` to create a new branch +2. Make your changes using file operations 3. Use \`${ghPullfrogMcpName}/commit_files\` to commit your changes 4. Use \`${ghPullfrogMcpName}/push_branch\` to push the branch 5. Use \`${ghPullfrogMcpName}/create_pull_request\` to create a PR @@ -221,7 +284,5 @@ ${encodedEvent}` ************* RUNTIME CONTEXT ************* -working_directory: ${process.cwd()} - -${envSetup}`; +${runtimeContext}`; }; diff --git a/agents/opencode.ts b/agents/opencode.ts index 4803900..8c6d13a 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -304,7 +304,7 @@ export const opencode = agent({ installDependencies: true, }); }, - run: async ({ payload, apiKey: _apiKey, apiKeys, mcpServers, cliPath, prepResults }) => { + run: async ({ payload, apiKey: _apiKey, apiKeys, mcpServers, cliPath, prepResults, repo }) => { // 1. configure home/config directory const tempHome = process.env.PULLFROG_TEMP_DIR!; const configDir = join(tempHome, ".config", "opencode"); @@ -313,7 +313,7 @@ export const opencode = agent({ configureOpenCodeMcpServers({ mcpServers }); configureOpenCodeSandbox({ sandbox: payload.sandbox ?? false }); - const prompt = addInstructions({ payload, prepResults }); + const prompt = addInstructions({ payload, prepResults, repo }); const args = ["run", "--format", "json", prompt]; if (payload.sandbox) { diff --git a/agents/shared.ts b/agents/shared.ts index ae3b895..a2c68ad 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -21,6 +21,15 @@ export interface AgentResult { metadata?: Record; } +/** + * Repo info for agent context + */ +export interface RepoInfo { + owner: string; + name: string; + defaultBranch: string; +} + /** * Configuration for agent creation */ @@ -31,6 +40,7 @@ export interface AgentConfig { mcpServers: Record; cliPath: string; prepResults: PrepResult[]; + repo: RepoInfo; } /** diff --git a/entry b/entry index 2b574f7..3096fe7 100755 --- a/entry +++ b/entry @@ -80489,6 +80489,9 @@ function formatIndentedField(label, content) { return formatted; } +// agents/instructions.ts +import { execSync } from "node:child_process"; + // ../node_modules/.pnpm/@toon-format+toon@1.0.0/node_modules/@toon-format/toon/dist/index.js var LIST_ITEM_MARKER = "-"; var LIST_ITEM_PREFIX = "- "; @@ -89416,10 +89419,11 @@ function getModes({ name: "Address Reviews", description: "Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR", prompt: `Follow these steps: -1. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically fetches and checks out the PR branch) +1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and configures push settings (including for fork PRs). 2. Review the feedback provided. Understand each review comment and what changes are being requested. - **EVENT DATA may contain review comment details**: If available, \`approved_comments\` are comments to address, \`unapproved_comments\` are for context only. The \`triggerer\` field indicates who initiated this action - prioritize their replies when deciding how to implement fixes. + - You can use ${ghPullfrogMcpName}/get_pull_request to get PR metadata if needed. 3. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context. Read AGENTS.md if it exists. @@ -89429,7 +89433,7 @@ function getModes({ 6. Test your changes to ensure they work correctly. -7. 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. +7. When done, commit your changes with ${ghPullfrogMcpName}/commit_files, then push with ${ghPullfrogMcpName}/push_branch. The push will automatically go to the correct remote (including fork repos). Do not create a new branch or PR - you are updating an existing one. ${disableProgressComment ? "" : ` 8. ${reportProgressInstruction} @@ -89439,9 +89443,9 @@ ${disableProgressComment ? "" : ` 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. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch) +1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and base branch, preparing the repo for review. -2. **IMPORTANT**: After calling ${ghPullfrogMcpName}/get_pull_request, the PR branch is already checked out locally. View diff using: \`git diff origin/...HEAD\` (replace with 'base' from PR info). Do NOT use \`origin/\` - the branch is checked out locally, not as a remote tracking branch. +2. **IMPORTANT**: After calling checkout_pr, the PR branch is checked out locally. View diff using: \`git diff origin/..HEAD\` (replace with 'base' from checkout_pr result, e.g., \`git diff origin/main..HEAD\`). Use two dots (..) not three dots (...) for reliable diffs. Do NOT use \`origin/\` - the branch is checked out locally, not as a remote tracking branch. This works for both same-repo and fork PRs. 3. Read files from the checked-out PR branch to understand the implementation. Always use **relative paths** from repo root (e.g., \`src/index.ts\`), never absolute paths. @@ -89449,7 +89453,8 @@ ${disableProgressComment ? "" : ` **GENERAL GUIDANCE** -- *CRITICAL* \u2014\xA0Use **relative paths** from repo root (e.g., \`packages/core/src/utils.ts\`) +- Do not leave any comments that are not potentially actionable. Do not leave complimentary comments just to be nice. +- *CRITICAL* \u2014 Use **relative paths** from repo root (e.g., \`packages/core/src/utils.ts\`) - For line numbers, use the NEW file line number from the diff (shown after \`+\` in hunk headers like \`@@ -10,5 +12,8 @@\` means new file starts at line 12) - Only comment on lines that appear in the diff. GitHub will reject comments on unchanged lines. - **CRITICAL: Prioritize per-line feedback over summary text.** @@ -89459,7 +89464,6 @@ ${disableProgressComment ? "" : ` - 95%+ of review content should be in per-line comments; the body should be just a couple sentences - The review body will include quick action links for addressing feedback, so keep it concise - Do not nitpick unless instructed explicity to do so by the user's additional instructions. This includes: requesting documentation/docstrings/JSDoc. -- Do not leave any comments that are not potentially actionable. - The review should be thoughtful. When evaluating complex changes, consider the following conceptual approach: - 1) conceptualize the changes made. make sure you understand it. - 2) evaluate conceptual approach. leave feedback as needed. @@ -89554,20 +89558,47 @@ function formatPrepResults(results) { if (lines.length === 0) { return ""; } - return `************* ENVIRONMENT SETUP ************* - -${lines.join("\n")} - -`; + return lines.join("\n"); } -var addInstructions = ({ payload, prepResults }) => { +function buildRuntimeContext({ repo, prepResults }) { + const lines = []; + lines.push(`working_directory: ${process.cwd()}`); + try { + const gitStatus = execSync("git status --short", { encoding: "utf-8", stdio: "pipe" }).trim(); + lines.push(`git_status: ${gitStatus || "(clean)"}`); + } catch { + } + lines.push(`repo: ${repo.owner}/${repo.name}`); + lines.push(`default_branch: ${repo.defaultBranch}`); + const ghVars = { + github_event_name: process.env.GITHUB_EVENT_NAME, + github_ref: process.env.GITHUB_REF, + github_sha: process.env.GITHUB_SHA?.slice(0, 7), + github_actor: process.env.GITHUB_ACTOR, + github_run_id: process.env.GITHUB_RUN_ID, + github_workflow: process.env.GITHUB_WORKFLOW + }; + for (const [key, value2] of Object.entries(ghVars)) { + if (value2) { + lines.push(`${key}: ${value2}`); + } + } + const envSetup = formatPrepResults(prepResults); + if (envSetup) { + lines.push(""); + lines.push("environment_setup:"); + lines.push(envSetup); + } + return lines.join("\n"); +} +var addInstructions = ({ payload, prepResults, repo }) => { let encodedEvent = ""; const eventKeys = Object.keys(payload.event); if (eventKeys.length === 1 && eventKeys[0] === "trigger") { } else { encodedEvent = encode(payload.event); } - const envSetup = formatPrepResults(prepResults); + const runtimeContext = buildRuntimeContext({ repo, prepResults }); const dependenciesPreinstalled = prepResults.every((r) => r.dependenciesInstalled) || void 0; return ` *********************************************** @@ -89643,14 +89674,21 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g **Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author. **Available git MCP tools**: +- \`${ghPullfrogMcpName}/checkout_pr\` - Checkout an existing PR branch locally (handles fork PRs automatically) - \`${ghPullfrogMcpName}/create_branch\` - Create a new branch from a base branch - \`${ghPullfrogMcpName}/commit_files\` - Stage and commit files with proper authentication -- \`${ghPullfrogMcpName}/push_branch\` - Push a branch to the remote repository +- \`${ghPullfrogMcpName}/push_branch\` - Push a branch to the remote (automatically uses correct remote for fork PRs) - \`${ghPullfrogMcpName}/create_pull_request\` - Create a PR from the current branch -**Workflow for making code changes**: -1. Use file operations to create/modify files -2. Use \`${ghPullfrogMcpName}/create_branch\` to create a new branch +**Workflow for working on an existing PR**: +1. Use \`${ghPullfrogMcpName}/checkout_pr\` to checkout the PR branch +2. Make your changes using file operations +3. Use \`${ghPullfrogMcpName}/commit_files\` to commit your changes +4. Use \`${ghPullfrogMcpName}/push_branch\` to push (automatically pushes to fork for fork PRs) + +**Workflow for creating new changes**: +1. Use \`${ghPullfrogMcpName}/create_branch\` to create a new branch +2. Make your changes using file operations 3. Use \`${ghPullfrogMcpName}/commit_files\` to commit your changes 4. Use \`${ghPullfrogMcpName}/push_branch\` to push the branch 5. Use \`${ghPullfrogMcpName}/create_pull_request\` to create a PR @@ -89694,9 +89732,7 @@ ${encodedEvent}` : ""} ************* RUNTIME CONTEXT ************* -working_directory: ${process.cwd()} - -${envSetup}`; +${runtimeContext}`; }; // agents/shared.ts @@ -90117,9 +90153,9 @@ var claude = agent({ executablePath: "cli.js" }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, prepResults }) => { + run: async ({ payload, mcpServers, apiKey, cliPath, prepResults, repo }) => { delete process.env.ANTHROPIC_API_KEY; - const prompt = addInstructions({ payload, prepResults }); + const prompt = addInstructions({ payload, prepResults, repo }); console.log(prompt); const sandboxOptions = payload.sandbox ? { permissionMode: "default", @@ -90575,7 +90611,7 @@ var codex = agent({ executablePath: "bin/codex.js" }); }, - run: async ({ payload, mcpServers, apiKey, cliPath, prepResults }) => { + run: async ({ payload, mcpServers, apiKey, cliPath, prepResults, repo }) => { const tempHome = process.env.PULLFROG_TEMP_DIR; const configDir = join5(tempHome, ".config", "codex"); mkdirSync2(configDir, { recursive: true }); @@ -90605,7 +90641,7 @@ var codex = agent({ } ); try { - const streamedTurn = await thread.runStreamed(addInstructions({ payload, prepResults })); + const streamedTurn = await thread.runStreamed(addInstructions({ payload, prepResults, repo })); let finalOutput2 = ""; for await (const event of streamedTurn.events) { const handler2 = messageHandlers2[event.type]; @@ -90747,7 +90783,7 @@ var cursor = agent({ executableName: "cursor-agent" }); }, - run: async ({ payload, apiKey, cliPath, mcpServers, prepResults }) => { + run: async ({ payload, apiKey, cliPath, mcpServers, prepResults, repo }) => { configureCursorMcpServers({ mcpServers, cliPath }); configureCursorSandbox({ sandbox: payload.sandbox ?? false }); const loggedModelCallIds = /* @__PURE__ */ new Set(); @@ -90800,7 +90836,7 @@ var cursor = agent({ } }; try { - const fullPrompt = addInstructions({ payload, prepResults }); + const fullPrompt = addInstructions({ payload, prepResults, repo }); const cursorArgs = payload.sandbox ? [ "--print", fullPrompt, @@ -91104,12 +91140,12 @@ var gemini = agent({ ...githubInstallationToken2 && { githubInstallationToken: githubInstallationToken2 } }); }, - run: async ({ payload, apiKey, mcpServers, cliPath, prepResults }) => { + run: async ({ payload, apiKey, mcpServers, cliPath, prepResults, repo }) => { configureGeminiMcpServers({ mcpServers, cliPath }); if (!apiKey) { throw new Error("google_api_key or gemini_api_key is required for gemini agent"); } - const sessionPrompt = addInstructions({ payload, prepResults }); + const sessionPrompt = addInstructions({ payload, prepResults, repo }); log.info(`Starting Gemini CLI with prompt: ${payload.prompt.substring(0, 100)}...`); const args3 = payload.sandbox ? [ "--allowed-tools", @@ -91365,13 +91401,13 @@ var opencode = agent({ installDependencies: true }); }, - run: async ({ payload, apiKey: _apiKey, apiKeys, mcpServers, cliPath, prepResults }) => { + run: async ({ payload, apiKey: _apiKey, apiKeys, mcpServers, cliPath, prepResults, repo }) => { const tempHome = process.env.PULLFROG_TEMP_DIR; const configDir = join7(tempHome, ".config", "opencode"); mkdirSync4(configDir, { recursive: true }); configureOpenCodeMcpServers({ mcpServers }); configureOpenCodeSandbox({ sandbox: payload.sandbox ?? false }); - const prompt = addInstructions({ payload, prepResults }); + const prompt = addInstructions({ payload, prepResults, repo }); const args3 = ["run", "--format", "json", prompt]; if (payload.sandbox) { log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations"); @@ -95064,7 +95100,8 @@ function buildPullfrogFooter(params) { parts.push(`Using [${params.agent.displayName}](${params.agent.url})`); } if (params.workflowRun) { - const url2 = params.workflowRun.htmlUrl ?? `https://github.com/${params.workflowRun.owner}/${params.workflowRun.repo}/actions/runs/${params.workflowRun.runId}`; + const baseUrl = `https://github.com/${params.workflowRun.owner}/${params.workflowRun.repo}/actions/runs/${params.workflowRun.runId}`; + const url2 = params.workflowRun.jobId ? `${baseUrl}/job/${params.workflowRun.jobId}` : baseUrl; parts.push(`[View workflow run](${url2})`); } if (params.customParts) { @@ -123934,6 +123971,70 @@ function $(cmd, args3, options) { return stdout.trim(); } +// mcp/checkout.ts +var CheckoutPr = type({ + pull_number: type.number.describe("the pull request number to checkout") +}); +function CheckoutPrTool(ctx) { + return tool({ + name: "checkout_pr", + description: "Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. Use this when you need to work on an existing PR.", + parameters: CheckoutPr, + execute: execute(ctx, async ({ pull_number }) => { + log.info(`\u{1F500} checking out PR #${pull_number}...`); + const pr = await ctx.octokit.rest.pulls.get({ + owner: ctx.owner, + repo: ctx.name, + pull_number + }); + const headRepo = pr.data.head.repo; + if (!headRepo) { + throw new Error(`PR #${pull_number} source repository was deleted`); + } + const isFork = headRepo.full_name !== pr.data.base.repo.full_name; + const baseBranch = pr.data.base.ref; + const headBranch = pr.data.head.ref; + log.info(`\u{1F4E5} fetching base branch (${baseBranch})...`); + $("git", ["fetch", "--no-tags", "origin", baseBranch]); + log.info(`\u{1F33F} fetching PR #${pull_number} (${headBranch})...`); + $("git", ["fetch", "--no-tags", "origin", `pull/${pull_number}/head:${headBranch}`]); + $("git", ["checkout", headBranch]); + log.info(`\u2713 checked out PR #${pull_number}`); + if (isFork) { + const remoteName = `pr-${pull_number}`; + const forkUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${headRepo.full_name}.git`; + try { + $("git", ["remote", "add", remoteName, forkUrl]); + log.info(`\u{1F4CC} added remote '${remoteName}' for fork ${headRepo.full_name}`); + } catch { + $("git", ["remote", "set-url", remoteName, forkUrl]); + log.info(`\u{1F4CC} updated remote '${remoteName}' for fork ${headRepo.full_name}`); + } + $("git", ["config", `branch.${headBranch}.pushRemote`, remoteName]); + log.info(`\u{1F4CC} configured branch '${headBranch}' to push to '${remoteName}'`); + if (!pr.data.maintainer_can_modify) { + log.warning( + `\u26A0\uFE0F fork PR has maintainer_can_modify=false - push operations will fail. ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.` + ); + } + } else { + $("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]); + } + return { + success: true, + number: pr.data.number, + title: pr.data.title, + base: baseBranch, + head: headBranch, + isFork, + maintainerCanModify: pr.data.maintainer_can_modify, + url: pr.data.html_url, + headRepo: headRepo.full_name + }; + }) + }); +} + // mcp/debug.ts var DebugShellCommand = type({}); var DebugShellCommandTool = tool({ @@ -124079,27 +124180,30 @@ var PushBranch = type({ branchName: type.string.describe("The branch name to push (defaults to current branch)").optional(), force: type.boolean.describe("Force push (use with caution)").default(false) }); -function PushBranchTool(ctx) { - const remote = ctx.pushRemote; +function PushBranchTool(_ctx) { return tool({ name: "push_branch", - description: "Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.", + description: "Push the current branch (or specified branch) to the remote repository. Git automatically determines the correct remote based on branch config (set by checkout_pr for fork PRs). Never force push unless explicitly requested.", parameters: PushBranch, - execute: execute(ctx, async ({ branchName, force }) => { + execute: execute(_ctx, async ({ branchName, force }) => { const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); - const isUrl = remote.startsWith("https://"); - const args3 = force ? ["push", "--force", ...isUrl ? [] : ["-u"], remote, branch] : ["push", ...isUrl ? [] : ["-u"], remote, branch]; - log.info(`Pushing branch ${branch} to ${isUrl ? "(fork URL)" : remote}`); + let remote = "origin"; + try { + remote = $("git", ["config", `branch.${branch}.pushRemote`], { log: false }).trim(); + } catch { + } + const args3 = force ? ["push", "--force", "-u", remote, branch] : ["push", "-u", remote, branch]; + log.info(`pushing branch ${branch} to ${remote}`); if (force) { - log.warning(`Force pushing - this will overwrite remote history`); + log.warning(`force pushing - this will overwrite remote history`); } $("git", args3); return { success: true, branch, - remote: isUrl ? "(fork URL)" : remote, + remote, force, - message: `Successfully pushed branch ${branch}` + message: `successfully pushed branch ${branch}` }; }) }); @@ -124325,6 +124429,19 @@ var PullRequest = type({ body: type.string.describe("the body content of the pull request"), base: type.string.describe("the base branch to merge into (e.g., 'main')") }); +function buildPrBodyWithFooter(ctx, body) { + const repoContext = parseRepoContext(); + const runId = process.env.GITHUB_RUN_ID; + const agentName = ctx.payload.agent; + const agentInfo = agentName ? agentsManifest[agentName] : null; + const footer = buildPullfrogFooter({ + triggeredBy: true, + agent: agentInfo ? { displayName: agentInfo.displayName, url: agentInfo.url } : void 0, + workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : void 0 + }); + const bodyWithoutFooter = stripExistingFooter(body); + return `${bodyWithoutFooter}${footer}`; +} function PullRequestTool(ctx) { return tool({ name: "create_pull_request", @@ -124338,17 +124455,18 @@ function PullRequestTool(ctx) { "PR creation blocked: secrets detected in PR title or body. Please remove any sensitive information (API keys, tokens, passwords) before creating a PR." ); } - const diff = $("git", ["diff", `origin/${base}...HEAD`], { log: false }); + const diff = $("git", ["diff", `origin/${base}..HEAD`], { log: false }); if (containsSecrets(diff)) { throw new Error( "PR creation blocked: secrets detected in changes. Please remove any sensitive information (API keys, tokens, passwords) before creating a PR." ); } + const bodyWithFooter = buildPrBodyWithFooter(ctx, body); const result = await ctx.octokit.rest.pulls.create({ owner: ctx.owner, repo: ctx.name, title, - body, + body: bodyWithFooter, head: currentBranch, base }); @@ -124372,7 +124490,7 @@ var PullRequestInfo = type({ function PullRequestInfoTool(ctx) { return tool({ name: "get_pull_request", - description: "Retrieve PR information (metadata only). PR branch is already checked out during setup.", + description: "Retrieve PR metadata (number, title, state, base/head branches, fork status). To checkout a PR branch locally, use checkout_pr instead.", parameters: PullRequestInfo, execute: execute(ctx, async ({ pull_number }) => { const pr = await ctx.octokit.rest.pulls.get({ @@ -124417,7 +124535,9 @@ var Review = type({ ), start_line: type.number.describe("Start line for multi-line comments (optional, for commenting on ranges)").optional() }).array().describe( - "PRIMARY location for ALL feedback. 95%+ of review content should be here. Use 'git diff origin/...origin/' to find correct line numbers (RIGHT side for new code, LEFT for old)." + // FORK PR NOTE: use HEAD not origin/ - for fork PRs, origin/ doesn't exist + // because the head branch is in a different repo (the fork). HEAD is the locally checked out PR branch. + "PRIMARY location for ALL feedback. 95%+ of review content should be here. Use 'git diff origin/..HEAD' to find correct line numbers (RIGHT side for new code, LEFT for old). Works for both fork and same-repo PRs." ).optional() }); function ReviewTool(ctx) { @@ -124462,6 +124582,7 @@ function ReviewTool(ctx) { const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix&review_id=${reviewId}`; const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`; const footer = buildPullfrogFooter({ + workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId }, customParts: [`[Fix all \u2794](${fixAllUrl})`, `[Fix \u{1F44D}s \u2794](${fixApprovedUrl})`] }); const updatedBody = (body || "") + footer; @@ -124695,6 +124816,7 @@ async function startMcpHttpServer(ctx) { PullRequestTool(ctx), ReviewTool(ctx), PullRequestInfoTool(ctx), + CheckoutPrTool(ctx), GetReviewCommentsTool(ctx), ListPullRequestReviewsTool(ctx), GetCheckSuiteLogsTool(ctx), @@ -125328,16 +125450,16 @@ ${error42}` : `\u274C ${error42}`; } // utils/setup.ts -import { execSync } from "node:child_process"; +import { execSync as execSync2 } from "node:child_process"; function setupGitConfig() { const repoDir = process.cwd(); log.info("\u{1F527} Setting up git configuration..."); try { - execSync('git config --local user.email "team@pullfrog.com"', { + execSync2('git config --local user.email "team@pullfrog.com"', { cwd: repoDir, stdio: "pipe" }); - execSync('git config --local user.name "pullfrog"', { + execSync2('git config --local user.name "pullfrog"', { cwd: repoDir, stdio: "pipe" }); @@ -125350,44 +125472,19 @@ function setupGitConfig() { } async function setupGit(ctx) { const repoDir = process.cwd(); - log.info("\u{1F527} Setting up git authentication..."); + log.info("\u{1F527} setting up git authentication..."); try { - execSync("git config --local --unset-all http.https://github.com/.extraheader", { + execSync2("git config --local --unset-all http.https://github.com/.extraheader", { cwd: repoDir, stdio: "pipe" }); - log.info("\u2713 Removed existing authentication headers"); + log.info("\u2713 removed existing authentication headers"); } catch { - log.debug("No existing authentication headers to remove"); + log.debug("no existing authentication headers to remove"); } const originUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.owner}/${ctx.name}.git`; $("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir }); - log.info("\u2713 Updated origin URL with authentication token"); - if (ctx.payload.event.is_pr !== true || !ctx.payload.event.issue_number) { - return { pushRemote: "origin" }; - } - const prNumber = ctx.payload.event.issue_number; - const pr = await ctx.octokit.rest.pulls.get({ - owner: ctx.owner, - repo: ctx.name, - pull_number: prNumber - }); - const headRepo = pr.data.head.repo; - if (!headRepo) { - throw new Error(`PR #${prNumber} source repository was deleted`); - } - const branch = pr.data.head.ref; - log.info(`\u{1F33F} Checking out PR #${prNumber} (${branch})...`); - $("git", ["fetch", "origin", `pull/${prNumber}/head:${branch}`], { cwd: repoDir }); - $("git", ["checkout", branch], { cwd: repoDir }); - log.info(`\u2713 Successfully checked out PR #${prNumber}`); - const token = process.env.GITHUB_TOKEN || ctx.githubInstallationToken; - const pushUrl = `https://x-access-token:${token}@github.com/${headRepo.full_name}.git`; - const isFork = headRepo.full_name !== pr.data.base.repo.full_name; - if (isFork) { - log.info(`\u{1F374} Fork PR detected, will push to: ${headRepo.full_name}`); - } - return { pushRemote: pushUrl }; + log.info("\u2713 updated origin URL with authentication token"); } // utils/timer.ts @@ -125426,8 +125523,7 @@ async function main(inputs) { const partialCtx = await initializeContext(inputs, payload); const ctx = partialCtx; timer.checkpoint("initializeContext"); - const { pushRemote } = await setupGit(ctx); - ctx.pushRemote = pushRemote; + await setupGit(ctx); timer.checkpoint("setupGit"); await setupTempDirectory(ctx); timer.checkpoint("setupTempDirectory"); @@ -125636,11 +125732,26 @@ function parsePayload(inputs) { } async function startMcpServer(ctx) { const runId = process.env.GITHUB_RUN_ID; - if (runId) { - const workflowRunInfo = await fetchWorkflowRunInfo(runId); - if (workflowRunInfo.progressCommentId) { - process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId; - log.info(`\u{1F4DD} Using pre-created progress comment: ${workflowRunInfo.progressCommentId}`); + if (!runId) { + throw new Error("GITHUB_RUN_ID environment variable is required"); + } + ctx.runId = runId; + const workflowRunInfo = await fetchWorkflowRunInfo(ctx.runId); + if (workflowRunInfo.progressCommentId) { + process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId; + log.info(`\u{1F4DD} Using pre-created progress comment: ${workflowRunInfo.progressCommentId}`); + } + const jobName = process.env.GITHUB_JOB; + if (jobName) { + const jobs = await ctx.octokit.rest.actions.listJobsForWorkflowRun({ + owner: ctx.owner, + repo: ctx.name, + run_id: parseInt(ctx.runId, 10) + }); + const matchingJob = jobs.data.jobs.find((job) => job.name === jobName); + if (matchingJob) { + ctx.jobId = String(matchingJob.id); + log.info(`\u{1F4CB} Found job ID: ${ctx.jobId}`); } } const { url: url2, close } = await startMcpHttpServer(ctx); @@ -125695,7 +125806,12 @@ ${encode(eventWithoutContext)}`; apiKey: ctx.apiKey, apiKeys: ctx.apiKeys, cliPath: ctx.cliPath, - prepResults: ctx.prepResults + prepResults: ctx.prepResults, + repo: { + owner: ctx.owner, + name: ctx.name, + defaultBranch: ctx.repo.default_branch + } }); } async function handleAgentResult(result) { diff --git a/fixtures/basic.txt b/fixtures/basic.txt index 874c2a1..c6aeeca 100644 --- a/fixtures/basic.txt +++ b/fixtures/basic.txt @@ -1 +1 @@ -Tell me a joke \ No newline at end of file +Implement .omit() in a new PR \ No newline at end of file diff --git a/main.ts b/main.ts index c2a6d37..df25ebb 100644 --- a/main.ts +++ b/main.ts @@ -65,8 +65,7 @@ export async function main(inputs: Inputs): Promise { const ctx = partialCtx as Context; timer.checkpoint("initializeContext"); - const { pushRemote } = await setupGit(ctx); - ctx.pushRemote = pushRemote; + await setupGit(ctx); timer.checkpoint("setupGit"); await setupTempDirectory(ctx); @@ -225,7 +224,6 @@ export interface Context { modes: Mode[]; // setup fields - pushRemote: string; sharedTempDir: string; // mcp fields @@ -240,6 +238,10 @@ export interface Context { // prep phase results prepResults: PrepResult[]; + + // workflow run info + runId: string; + jobId: string | undefined; } async function initializeContext( @@ -254,8 +256,9 @@ async function initializeContext( | "cliPath" | "apiKey" | "apiKeys" - | "pushRemote" | "prepResults" + | "runId" + | "jobId" > > { log.info(`🐸 Running pullfrog/action@${packageJson.version}...`); @@ -407,14 +410,32 @@ function parsePayload(inputs: Inputs): Payload { } async function startMcpServer(ctx: Context): Promise { + const runId = process.env.GITHUB_RUN_ID; + if (!runId) { + throw new Error("GITHUB_RUN_ID environment variable is required"); + } + ctx.runId = runId; + // fetch the pre-created progress comment ID from the database // this must be set BEFORE starting the MCP server so comment.ts can read it - const runId = process.env.GITHUB_RUN_ID; - if (runId) { - const workflowRunInfo = await fetchWorkflowRunInfo(runId); - if (workflowRunInfo.progressCommentId) { - process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId; - log.info(`📝 Using pre-created progress comment: ${workflowRunInfo.progressCommentId}`); + const workflowRunInfo = await fetchWorkflowRunInfo(ctx.runId); + if (workflowRunInfo.progressCommentId) { + process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId; + log.info(`📝 Using pre-created progress comment: ${workflowRunInfo.progressCommentId}`); + } + + // fetch job ID by matching GITHUB_JOB name + const jobName = process.env.GITHUB_JOB; + if (jobName) { + const jobs = await ctx.octokit.rest.actions.listJobsForWorkflowRun({ + owner: ctx.owner, + repo: ctx.name, + run_id: parseInt(ctx.runId, 10), + }); + const matchingJob = jobs.data.jobs.find((job) => job.name === jobName); + if (matchingJob) { + ctx.jobId = String(matchingJob.id); + log.info(`📋 Found job ID: ${ctx.jobId}`); } } @@ -485,6 +506,11 @@ async function runAgent(ctx: Context): Promise { apiKeys: ctx.apiKeys, cliPath: ctx.cliPath, prepResults: ctx.prepResults, + repo: { + owner: ctx.owner, + name: ctx.name, + defaultBranch: ctx.repo.default_branch, + }, }); } diff --git a/mcp/checkout.ts b/mcp/checkout.ts new file mode 100644 index 0000000..e23ec63 --- /dev/null +++ b/mcp/checkout.ts @@ -0,0 +1,105 @@ +import { type } from "arktype"; +import type { Context } from "../main.ts"; +import { log } from "../utils/cli.ts"; +import { $ } from "../utils/shell.ts"; +import { execute, tool } from "./shared.ts"; + +export const CheckoutPr = type({ + pull_number: type.number.describe("the pull request number to checkout"), +}); + +export type CheckoutPrResult = { + success: true; + number: number; + title: string; + base: string; + head: string; + isFork: boolean; + maintainerCanModify: boolean; + url: string; + headRepo: string; +}; + +export function CheckoutPrTool(ctx: Context) { + return tool({ + name: "checkout_pr", + description: + "Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. Use this when you need to work on an existing PR.", + parameters: CheckoutPr, + execute: execute(ctx, async ({ pull_number }) => { + log.info(`🔀 checking out PR #${pull_number}...`); + + // fetch PR metadata + const pr = await ctx.octokit.rest.pulls.get({ + owner: ctx.owner, + repo: ctx.name, + pull_number, + }); + + const headRepo = pr.data.head.repo; + if (!headRepo) { + throw new Error(`PR #${pull_number} source repository was deleted`); + } + + const isFork = headRepo.full_name !== pr.data.base.repo.full_name; + const baseBranch = pr.data.base.ref; + const headBranch = pr.data.head.ref; + + // fetch base branch so origin/ exists for diff operations + log.info(`📥 fetching base branch (${baseBranch})...`); + $("git", ["fetch", "--no-tags", "origin", baseBranch]); + + // fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs) + log.info(`🌿 fetching PR #${pull_number} (${headBranch})...`); + $("git", ["fetch", "--no-tags", "origin", `pull/${pull_number}/head:${headBranch}`]); + + // checkout the branch + $("git", ["checkout", headBranch]); + log.info(`✓ checked out PR #${pull_number}`); + + // configure push remote for this branch + if (isFork) { + const remoteName = `pr-${pull_number}`; + const forkUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${headRepo.full_name}.git`; + + // add fork as a named remote (ignore error if already exists) + try { + $("git", ["remote", "add", remoteName, forkUrl]); + log.info(`📌 added remote '${remoteName}' for fork ${headRepo.full_name}`); + } catch { + // remote already exists, update its URL + $("git", ["remote", "set-url", remoteName, forkUrl]); + log.info(`📌 updated remote '${remoteName}' for fork ${headRepo.full_name}`); + } + + // set branch push config so `git push` knows where to push + $("git", ["config", `branch.${headBranch}.pushRemote`, remoteName]); + log.info(`📌 configured branch '${headBranch}' to push to '${remoteName}'`); + + // warn if maintainer can't modify (push will likely fail) + if (!pr.data.maintainer_can_modify) { + log.warning( + `⚠️ fork PR has maintainer_can_modify=false - push operations will fail. ` + + `ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.` + ); + } + } else { + // for same-repo PRs, push to origin + $("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]); + } + + return { + success: true, + number: pr.data.number, + title: pr.data.title, + base: baseBranch, + head: headBranch, + isFork, + maintainerCanModify: pr.data.maintainer_can_modify, + url: pr.data.html_url, + headRepo: headRepo.full_name, + } satisfies CheckoutPrResult; + }), + }); +} + diff --git a/mcp/git.ts b/mcp/git.ts index 9d78c13..2a8c39b 100644 --- a/mcp/git.ts +++ b/mcp/git.ts @@ -141,35 +141,39 @@ export const PushBranch = type({ force: type.boolean.describe("Force push (use with caution)").default(false), }); -export function PushBranchTool(ctx: Context) { - const remote = ctx.pushRemote; - +export function PushBranchTool(_ctx: Context) { return tool({ name: "push_branch", description: - "Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.", + "Push the current branch (or specified branch) to the remote repository. Git automatically determines the correct remote based on branch config (set by checkout_pr for fork PRs). Never force push unless explicitly requested.", parameters: PushBranch, - execute: execute(ctx, async ({ branchName, force }) => { + execute: execute(_ctx, async ({ branchName, force }) => { const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); - // skip -u flag when pushing to URL (can't set upstream without a remote name) - const isUrl = remote.startsWith("https://"); - const args = force - ? ["push", "--force", ...(isUrl ? [] : ["-u"]), remote, branch] - : ["push", ...(isUrl ? [] : ["-u"]), remote, branch]; + // check if branch has a configured pushRemote + let remote = "origin"; + try { + remote = $("git", ["config", `branch.${branch}.pushRemote`], { log: false }).trim(); + } catch { + // no configured pushRemote, default to origin + } - log.info(`Pushing branch ${branch} to ${isUrl ? "(fork URL)" : remote}`); + const args = force + ? ["push", "--force", "-u", remote, branch] + : ["push", "-u", remote, branch]; + + log.info(`pushing branch ${branch} to ${remote}`); if (force) { - log.warning(`Force pushing - this will overwrite remote history`); + log.warning(`force pushing - this will overwrite remote history`); } $("git", args); return { success: true, branch, - remote: isUrl ? "(fork URL)" : remote, + remote, force, - message: `Successfully pushed branch ${branch}`, + message: `successfully pushed branch ${branch}`, }; }), }); diff --git a/mcp/pr.ts b/mcp/pr.ts index a17fa80..7b66426 100644 --- a/mcp/pr.ts +++ b/mcp/pr.ts @@ -1,6 +1,9 @@ import { type } from "arktype"; +import { agentsManifest } from "../external.ts"; import type { Context } from "../main.ts"; +import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts"; import { log } from "../utils/cli.ts"; +import { parseRepoContext } from "../utils/github.ts"; import { containsSecrets } from "../utils/secrets.ts"; import { $ } from "../utils/shell.ts"; import { execute, tool } from "./shared.ts"; @@ -11,6 +14,23 @@ export const PullRequest = type({ base: type.string.describe("the base branch to merge into (e.g., 'main')"), }); +function buildPrBodyWithFooter(ctx: Context, body: string): string { + const repoContext = parseRepoContext(); + const runId = process.env.GITHUB_RUN_ID; + + const agentName = ctx.payload.agent; + const agentInfo = agentName ? agentsManifest[agentName] : null; + + const footer = buildPullfrogFooter({ + triggeredBy: true, + agent: agentInfo ? { displayName: agentInfo.displayName, url: agentInfo.url } : undefined, + workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined, + }); + + const bodyWithoutFooter = stripExistingFooter(body); + return `${bodyWithoutFooter}${footer}`; +} + export function PullRequestTool(ctx: Context) { return tool({ name: "create_pull_request", @@ -29,7 +49,9 @@ export function PullRequestTool(ctx: Context) { } // validate all changes that would be in the PR (from base to HEAD) - const diff = $("git", ["diff", `origin/${base}...HEAD`], { log: false }); + // FORK PR NOTE: origin/ is fetched by setupGit, so this works for both fork and same-repo PRs + // use two-dot (..) not three-dot (...) for reliable diffs with shallow clones + const diff = $("git", ["diff", `origin/${base}..HEAD`], { log: false }); if (containsSecrets(diff)) { throw new Error( "PR creation blocked: secrets detected in changes. " + @@ -37,11 +59,13 @@ export function PullRequestTool(ctx: Context) { ); } + const bodyWithFooter = buildPrBodyWithFooter(ctx, body); + const result = await ctx.octokit.rest.pulls.create({ owner: ctx.owner, repo: ctx.name, title: title, - body: body, + body: bodyWithFooter, head: currentBranch, base: base, }); diff --git a/mcp/prInfo.ts b/mcp/prInfo.ts index a119c19..62a5852 100644 --- a/mcp/prInfo.ts +++ b/mcp/prInfo.ts @@ -10,7 +10,7 @@ export function PullRequestInfoTool(ctx: Context) { return tool({ name: "get_pull_request", description: - "Retrieve PR information (metadata only). PR branch is already checked out during setup.", + "Retrieve PR metadata (number, title, state, base/head branches, fork status). To checkout a PR branch locally, use checkout_pr instead.", parameters: PullRequestInfo, execute: execute(ctx, async ({ pull_number }) => { const pr = await ctx.octokit.rest.pulls.get({ diff --git a/mcp/review.ts b/mcp/review.ts index 27b86d5..a9707ce 100644 --- a/mcp/review.ts +++ b/mcp/review.ts @@ -35,7 +35,9 @@ export const Review = type({ }) .array() .describe( - "PRIMARY location for ALL feedback. 95%+ of review content should be here. Use 'git diff origin/...origin/' to find correct line numbers (RIGHT side for new code, LEFT for old)." + // FORK PR NOTE: use HEAD not origin/ - for fork PRs, origin/ doesn't exist + // because the head branch is in a different repo (the fork). HEAD is the locally checked out PR branch. + "PRIMARY location for ALL feedback. 95%+ of review content should be here. Use 'git diff origin/..HEAD' to find correct line numbers (RIGHT side for new code, LEFT for old). Works for both fork and same-repo PRs." ) .optional(), }); @@ -93,6 +95,7 @@ export function ReviewTool(ctx: Context) { const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`; const footer = buildPullfrogFooter({ + workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId }, customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`], }); diff --git a/mcp/server.ts b/mcp/server.ts index 98b9d5d..593274f 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -5,6 +5,7 @@ import { FastMCP, type Tool } from "fastmcp"; import { ghPullfrogMcpName } from "../external.ts"; import type { Context } from "../main.ts"; import { GetCheckSuiteLogsTool } from "./checkSuite.ts"; +import { CheckoutPrTool } from "./checkout.ts"; import { CreateCommentTool, EditCommentTool, @@ -78,6 +79,7 @@ export async function startMcpHttpServer( PullRequestTool(ctx), ReviewTool(ctx), PullRequestInfoTool(ctx), + CheckoutPrTool(ctx), GetReviewCommentsTool(ctx), ListPullRequestReviewsTool(ctx), GetCheckSuiteLogsTool(ctx), diff --git a/modes.ts b/modes.ts index 23c5da7..65f80b5 100644 --- a/modes.ts +++ b/modes.ts @@ -64,10 +64,11 @@ export function getModes({ description: "Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR", prompt: `Follow these steps: -1. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically fetches and checks out the PR branch) +1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and configures push settings (including for fork PRs). 2. Review the feedback provided. Understand each review comment and what changes are being requested. - **EVENT DATA may contain review comment details**: If available, \`approved_comments\` are comments to address, \`unapproved_comments\` are for context only. The \`triggerer\` field indicates who initiated this action - prioritize their replies when deciding how to implement fixes. + - You can use ${ghPullfrogMcpName}/get_pull_request to get PR metadata if needed. 3. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context. Read AGENTS.md if it exists. @@ -77,7 +78,7 @@ export function getModes({ 6. Test your changes to ensure they work correctly. -7. 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. +7. When done, commit your changes with ${ghPullfrogMcpName}/commit_files, then push with ${ghPullfrogMcpName}/push_branch. The push will automatically go to the correct remote (including fork repos). Do not create a new branch or PR - you are updating an existing one. ${ disableProgressComment ? "" @@ -92,9 +93,9 @@ ${ description: "Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness", prompt: `Follow these steps: -1. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch) +1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and base branch, preparing the repo for review. -2. **IMPORTANT**: After calling ${ghPullfrogMcpName}/get_pull_request, the PR branch is already checked out locally. View diff using: \`git diff origin/...HEAD\` (replace with 'base' from PR info). Do NOT use \`origin/\` - the branch is checked out locally, not as a remote tracking branch. +2. **IMPORTANT**: After calling checkout_pr, the PR branch is checked out locally. View diff using: \`git diff origin/..HEAD\` (replace with 'base' from checkout_pr result, e.g., \`git diff origin/main..HEAD\`). Use two dots (..) not three dots (...) for reliable diffs. Do NOT use \`origin/\` - the branch is checked out locally, not as a remote tracking branch. This works for both same-repo and fork PRs. 3. Read files from the checked-out PR branch to understand the implementation. Always use **relative paths** from repo root (e.g., \`src/index.ts\`), never absolute paths. @@ -102,7 +103,8 @@ ${ **GENERAL GUIDANCE** -- *CRITICAL* — Use **relative paths** from repo root (e.g., \`packages/core/src/utils.ts\`) +- Do not leave any comments that are not potentially actionable. Do not leave complimentary comments just to be nice. +- *CRITICAL* — Use **relative paths** from repo root (e.g., \`packages/core/src/utils.ts\`) - For line numbers, use the NEW file line number from the diff (shown after \`+\` in hunk headers like \`@@ -10,5 +12,8 @@\` means new file starts at line 12) - Only comment on lines that appear in the diff. GitHub will reject comments on unchanged lines. - **CRITICAL: Prioritize per-line feedback over summary text.** @@ -112,7 +114,6 @@ ${ - 95%+ of review content should be in per-line comments; the body should be just a couple sentences - The review body will include quick action links for addressing feedback, so keep it concise - Do not nitpick unless instructed explicity to do so by the user's additional instructions. This includes: requesting documentation/docstrings/JSDoc. -- Do not leave any comments that are not potentially actionable. - The review should be thoughtful. When evaluating complex changes, consider the following conceptual approach: - 1) conceptualize the changes made. make sure you understand it. - 2) evaluate conceptual approach. leave feedback as needed. diff --git a/utils/buildPullfrogFooter.ts b/utils/buildPullfrogFooter.ts index 9455587..f48af84 100644 --- a/utils/buildPullfrogFooter.ts +++ b/utils/buildPullfrogFooter.ts @@ -7,12 +7,12 @@ export interface AgentInfo { url: string; } -export interface WorkflowRunInfo { +export interface WorkflowRunFooterInfo { owner: string; repo: string; runId: string; - /** optional job URL - if provided, will be used instead of building from runId */ - htmlUrl?: string; + /** optional job ID - if provided, will append /job/{jobId} to the workflow run URL */ + jobId?: string | undefined; } export interface BuildPullfrogFooterParams { @@ -21,7 +21,7 @@ export interface BuildPullfrogFooterParams { /** add "Using [agent](url)" link */ agent?: AgentInfo | undefined; /** add "View workflow run" link */ - workflowRun?: WorkflowRunInfo | undefined; + workflowRun?: WorkflowRunFooterInfo | undefined; /** arbitrary custom parts (e.g., action links) */ customParts?: string[]; } @@ -42,7 +42,8 @@ export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string { } if (params.workflowRun) { - const url = params.workflowRun.htmlUrl ?? `https://github.com/${params.workflowRun.owner}/${params.workflowRun.repo}/actions/runs/${params.workflowRun.runId}`; + const baseUrl = `https://github.com/${params.workflowRun.owner}/${params.workflowRun.repo}/actions/runs/${params.workflowRun.runId}`; + const url = params.workflowRun.jobId ? `${baseUrl}/job/${params.workflowRun.jobId}` : baseUrl; parts.push(`[View workflow run](${url})`); } diff --git a/utils/setup.ts b/utils/setup.ts index 025dd69..8693756 100644 --- a/utils/setup.ts +++ b/utils/setup.ts @@ -62,19 +62,19 @@ export function setupGitConfig(): void { } } -export type SetupGitResult = { - pushRemote: string; -}; - /** - * Unified git setup: configures authentication and checks out PR branch if applicable. - * For PR events, always returns a push URL constructed from the PR's head repo. - * This works for both same-repo and fork PRs with a single code path. + * Setup git authentication for the repository. + * PR checkout is handled dynamically by the checkout_pr MCP tool. + * + * FORK PR ARCHITECTURE (handled by checkout_pr tool): + * - origin: always points to BASE REPO (where PR targets) + * - checkout_pr sets per-branch pushRemote config for fork PRs + * - diff operations use: git diff origin/..HEAD */ -export async function setupGit(ctx: Context): Promise { +export async function setupGit(ctx: Context): Promise { const repoDir = process.cwd(); - log.info("🔧 Setting up git authentication..."); + log.info("🔧 setting up git authentication..."); // remove existing git auth headers that actions/checkout might have set try { @@ -82,47 +82,13 @@ export async function setupGit(ctx: Context): Promise { cwd: repoDir, stdio: "pipe", }); - log.info("✓ Removed existing authentication headers"); + log.info("✓ removed existing authentication headers"); } catch { - log.debug("No existing authentication headers to remove"); + log.debug("no existing authentication headers to remove"); } - // set up origin with token (needed for fetch operations) + // authenticate origin - needed for all fetch operations including PR fetches const originUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.owner}/${ctx.name}.git`; $("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir }); - log.info("✓ Updated origin URL with authentication token"); - - // non-PR events: stay on default branch - if (ctx.payload.event.is_pr !== true || !ctx.payload.event.issue_number) { - return { pushRemote: "origin" }; - } - - // PR event: fetch PR info to get branch name and head repo - const prNumber = ctx.payload.event.issue_number; - const pr = await ctx.octokit.rest.pulls.get({ - owner: ctx.owner, - repo: ctx.name, - pull_number: prNumber, - }); - - const headRepo = pr.data.head.repo; - if (!headRepo) { - throw new Error(`PR #${prNumber} source repository was deleted`); - } - - // checkout PR branch using plain git (no gh cli needed) - const branch = pr.data.head.ref; - log.info(`🌿 Checking out PR #${prNumber} (${branch})...`); - $("git", ["fetch", "--no-tags", "origin", `pull/${prNumber}/head:${branch}`], { cwd: repoDir }); - $("git", ["checkout", branch], { cwd: repoDir }); - log.info(`✓ Successfully checked out PR #${prNumber}`); - - // unified push URL - works for both same-repo and fork PRs - const token = process.env.GITHUB_TOKEN || ctx.githubInstallationToken; - const pushUrl = `https://x-access-token:${token}@github.com/${headRepo.full_name}.git`; - const isFork = headRepo.full_name !== pr.data.base.repo.full_name; - if (isFork) { - log.info(`🍴 Fork PR detected, will push to: ${headRepo.full_name}`); - } - return { pushRemote: pushUrl }; + log.info("✓ updated origin URL with authentication token"); }