Compare commits

...

25 Commits

Author SHA1 Message Date
Colin McDonnell 0a7a38a9a5 155 2025-12-22 18:45:43 -08:00
Colin McDonnell 72a040aafa Clean up review mode 2025-12-22 18:35:29 -08:00
Colin McDonnell cc59a16472 Clean up review mode 2025-12-22 18:31:27 -08:00
Colin McDonnell 8db0c40487 Do not return diff. Stick with opus 2025-12-22 18:22:35 -08:00
Colin McDonnell 7fb788a883 Token efficiency 2025-12-22 18:16:55 -08:00
Colin McDonnell 0cf88e1752 THINK HARDER 2025-12-22 17:49:37 -08:00
Colin McDonnell dcb672b5be Tweak prompts, switch to opus 2025-12-22 17:40:40 -08:00
Colin McDonnell 7103f5f991 Clean up log 2025-12-22 17:35:14 -08:00
Colin McDonnell c518e8b6fd Add retrying. Improve diff format 2025-12-22 15:53:11 -08:00
Colin McDonnell 615a3bc8e1 Clean up PR prompt 2025-12-22 15:01:42 -08:00
Colin McDonnell 17ad3bd0e7 0.0.154 2025-12-22 14:55:32 -08:00
Colin McDonnell 25896559f0 Switch back to one-shot reviews 2025-12-22 14:55:19 -08:00
Colin McDonnell 5353d80388 Retries on oidc. 152 2025-12-22 14:33:18 -08:00
Colin McDonnell 2dea842981 Write diff to file 2025-12-22 14:20:42 -08:00
Colin McDonnell 04c695038f 151 2025-12-22 13:57:51 -08:00
Colin McDonnell e9a585ce47 Improve debug logging for reviews. v0.0.150 2025-12-22 13:50:39 -08:00
Colin McDonnell 7407b6cbc5 Fix timeout 2025-12-22 12:51:04 -08:00
Colin McDonnell 507efb0c25 Fix timeout 2025-12-22 12:50:00 -08:00
Colin McDonnell 6d572f3ce8 0.0.149 2025-12-21 22:42:58 -08:00
Colin McDonnell 73139a169c Clean up pr naming 2025-12-21 22:42:42 -08:00
Colin McDonnell d5bec7499b Update review process 2025-12-21 22:23:18 -08:00
David Blass b33deb1b5a fix thumbs up message, sleep prompting 2025-12-19 16:54:13 -05:00
David Blass 5034ff8285 switch to start_dependency_installation and await_dependency_installation, fix action play.ts repo 2025-12-19 16:29:46 -05:00
David Blass bd8fc8abdf bump version 2025-12-17 18:00:52 -05:00
David Blass adc87d8b64 check packageManager 2025-12-17 18:00:36 -05:00
28 changed files with 8998 additions and 9405 deletions
+19 -7
View File
@@ -14,12 +14,12 @@ export const claude = agent({
executablePath: "cli.js",
});
},
run: async ({ payload, mcpServers, apiKey, cliPath, prepResults, repo }) => {
run: async ({ payload, mcpServers, apiKey, cliPath, 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, repo });
log.group("» Full prompt", () => log.info(prompt));
const prompt = addInstructions({ payload, repo });
log.group("Full prompt", () => log.info(prompt));
// configure sandbox mode if enabled
const sandboxOptions: Options = payload.sandbox
@@ -56,6 +56,7 @@ export const claude = agent({
options: {
...sandboxOptions,
mcpServers,
model: "claude-opus-4-5",
pathToClaudeCodeExecutable: cliPath,
env: createAgentEnv({ ANTHROPIC_API_KEY: apiKey }),
},
@@ -146,16 +147,27 @@ const messageHandlers: SDKMessageHandlers = {
},
result: async (data) => {
if (data.subtype === "success") {
const usage = data.usage;
const inputTokens = usage?.input_tokens || 0;
const cacheRead = usage?.cache_read_input_tokens || 0;
const cacheWrite = usage?.cache_creation_input_tokens || 0;
const outputTokens = usage?.output_tokens || 0;
const totalInput = inputTokens + cacheRead + cacheWrite;
await log.summaryTable([
[
{ data: "Cost", header: true },
{ data: "Input Tokens", header: true },
{ data: "Output Tokens", header: true },
{ data: "Input", header: true },
{ data: "Cache Read", header: true },
{ data: "Cache Write", header: true },
{ data: "Output", header: true },
],
[
`$${data.total_cost_usd?.toFixed(4) || "0.0000"}`,
String(data.usage?.input_tokens || 0),
String(data.usage?.output_tokens || 0),
String(totalInput),
String(cacheRead),
String(cacheWrite),
String(outputTokens),
],
]);
} else if (data.subtype === "error_max_turns") {
+2 -2
View File
@@ -20,7 +20,7 @@ export const codex = agent({
executablePath: "bin/codex.js",
});
},
run: async ({ payload, mcpServers, apiKey, cliPath, prepResults, repo }) => {
run: async ({ payload, mcpServers, apiKey, cliPath, 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, repo }));
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
let finalOutput = "";
for await (const event of streamedTurn.events) {
+3 -3
View File
@@ -91,7 +91,7 @@ export const cursor = agent({
executableName: "cursor-agent",
});
},
run: async ({ payload, apiKey, cliPath, mcpServers, prepResults, repo }) => {
run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => {
configureCursorMcpServers({ mcpServers, cliPath });
configureCursorSandbox({ sandbox: payload.sandbox ?? false });
@@ -166,8 +166,8 @@ export const cursor = agent({
};
try {
const fullPrompt = addInstructions({ payload, prepResults, repo });
log.group("» Full prompt", () => log.info(fullPrompt));
const fullPrompt = addInstructions({ payload, repo });
log.group("Full prompt", () => log.info(fullPrompt));
// configure sandbox mode if enabled
// in sandbox mode: remove --force flag and rely on cli-config.json sandbox settings
+3 -3
View File
@@ -154,15 +154,15 @@ export const gemini = agent({
...(githubInstallationToken && { githubInstallationToken }),
});
},
run: async ({ payload, apiKey, mcpServers, cliPath, prepResults, repo }) => {
run: async ({ payload, apiKey, mcpServers, cliPath, 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, repo });
log.group("» Full prompt", () => log.info(sessionPrompt));
const sessionPrompt = addInstructions({ payload, repo });
log.group("Full prompt", () => log.info(sessionPrompt));
// configure sandbox mode if enabled
// --allowed-tools restricts which tools are available (removes others from registry entirely)
+6 -81
View File
@@ -3,68 +3,6 @@ import { encode as toonEncode } from "@toon-format/toon";
import type { Payload } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import { getModes } from "../modes.ts";
import type { PrepResult } from "../prep/index.ts";
/**
* Format prep results into a human-readable string for the agent prompt
*/
function formatPrepResults(results: PrepResult[]): string {
if (results.length === 0) {
return "";
}
const lines: string[] = [];
for (const result of results) {
if (result.language === "unknown") {
continue;
}
const langDisplay = result.language === "node" ? "Node.js" : "Python";
if (result.language === "node") {
if (result.dependenciesInstalled) {
lines.push(
`${langDisplay} dependencies installed successfully via \`${result.packageManager}\`.`
);
} else {
lines.push(
`⚠️ ${langDisplay} dependency installation FAILED (using \`${result.packageManager}\`).`
);
for (const issue of result.issues) {
lines.push(` - ${issue}`);
}
lines.push(
` You may need to run \`${result.packageManager} install\` or address this issue before proceeding.`
);
}
}
if (result.language === "python") {
if (result.dependenciesInstalled) {
lines.push(
`${langDisplay} dependencies installed successfully via \`${result.packageManager}\` (from ${result.configFile}).`
);
} else {
lines.push(
`⚠️ ${langDisplay} dependency installation FAILED (using \`${result.packageManager}\` from ${result.configFile}).`
);
for (const issue of result.issues) {
lines.push(` - ${issue}`);
}
lines.push(
` You may need to run the appropriate install command or address this issue before proceeding.`
);
}
}
}
if (lines.length === 0) {
return "";
}
return lines.join("\n");
}
interface RepoInfo {
owner: string;
@@ -72,15 +10,10 @@ interface RepoInfo {
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 {
function buildRuntimeContext(repo: RepoInfo): string {
const lines: string[] = [];
// working directory
@@ -114,24 +47,15 @@ function buildRuntimeContext({ repo, prepResults }: BuildRuntimeContextParams):
}
}
// 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, repo }: AddInstructionsParams) => {
export const addInstructions = ({ payload, repo }: AddInstructionsParams) => {
let encodedEvent = "";
const eventKeys = Object.keys(payload.event);
@@ -143,8 +67,7 @@ export const addInstructions = ({ payload, prepResults, repo }: AddInstructionsP
encodedEvent = toonEncode(payload.event);
}
const runtimeContext = buildRuntimeContext({ repo, prepResults });
const dependenciesPreinstalled = prepResults.every((r) => r.dependenciesInstalled) || undefined;
const runtimeContext = buildRuntimeContext(repo);
return (
`
@@ -247,6 +170,8 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g
**Efficiency**: Trust the tools - do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error.
**Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished.
**Commenting style**: When posting comments via ${ghPullfrogMcpName}, write as a professional team member would. Your final comments should be polished and actionable—do not include intermediate reasoning like "I'll now look at the code" or "Let me respond to the question."
**If you get stuck**: If you cannot complete a task due to missing information, ambiguity, or an unrecoverable error:
@@ -264,7 +189,7 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g
### Available modes
${[...getModes({ disableProgressComment: payload.disableProgressComment, dependenciesPreinstalled }), ...payload.modes].map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
${[...getModes({ disableProgressComment: payload.disableProgressComment }), ...payload.modes].map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
### Following the mode instructions
+3 -3
View File
@@ -21,7 +21,7 @@ export const opencode = agent({
installDependencies: true,
});
},
run: async ({ payload, apiKey: _apiKey, apiKeys, mcpServers, cliPath, prepResults, repo }) => {
run: async ({ payload, apiKey: _apiKey, apiKeys, mcpServers, cliPath, repo }) => {
// 1. configure home/config directory
const tempHome = process.env.PULLFROG_TEMP_DIR!;
const configDir = join(tempHome, ".config", "opencode");
@@ -29,8 +29,8 @@ export const opencode = agent({
configureOpenCode({ mcpServers, sandbox: payload.sandbox ?? false });
const prompt = addInstructions({ payload, prepResults, repo });
log.group("» Full prompt", () => log.info(prompt));
const prompt = addInstructions({ payload, repo });
log.group("Full prompt", () => log.info(prompt));
// message positional must come right after "run", before flags
const args = ["run", prompt, "--format", "json"];
-2
View File
@@ -7,7 +7,6 @@ import { pipeline } from "node:stream/promises";
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import type { show } from "@ark/util";
import { type AgentManifest, type AgentName, agentsManifest, type Payload } from "../external.ts";
import type { PrepResult } from "../prep/index.ts";
import { log } from "../utils/cli.ts";
import { getGitHubInstallationToken } from "../utils/github.ts";
@@ -39,7 +38,6 @@ export interface AgentConfig {
payload: Payload;
mcpServers: Record<string, McpHttpServerConfig>;
cliPath: string;
prepResults: PrepResult[];
repo: RepoInfo;
}
+8215 -8983
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
echo the value of the MY_SECRET env var
add a file implementing quicksort and test it
+15 -14
View File
@@ -14,7 +14,7 @@ import { createMcpConfigs } from "./mcp/config.ts";
import { startMcpHttpServer } from "./mcp/server.ts";
import { getModes, type Mode, modes } from "./modes.ts";
import packageJson from "./package.json" with { type: "json" };
import { type PrepResult, runPrepPhase } from "./prep/index.ts";
import type { PrepResult } from "./prep/index.ts";
import { fetchRepoSettings, fetchWorkflowRunInfo, type RepoSettings } from "./utils/api.ts";
import { log } from "./utils/cli.ts";
import { reportErrorToComment } from "./utils/errorReport.ts";
@@ -104,10 +104,9 @@ export async function main(inputs: Inputs): Promise<MainResult> {
return { success: false, error: apiKeySetup.error };
}
// phase 5: parallel long-running operations (prep + agent install + git auth)
// phase 5: parallel long-running operations (agent install + git auth)
const toolState: ToolState = {};
const [prepResults, cliPath] = await Promise.all([
runPrepPhase(),
const [cliPath] = await Promise.all([
installAgentCli({ agent, token: githubSetup.token }),
setupGitAuth({
token: githubSetup.token,
@@ -118,14 +117,12 @@ export async function main(inputs: Inputs): Promise<MainResult> {
toolState,
}),
]);
timer.checkpoint("prep+agentSetup+gitAuth");
timer.checkpoint("agentSetup+gitAuth");
// phase 6: compute modes (needs prep results)
const dependenciesPreinstalled = prepResults.every((r) => r.dependenciesInstalled) || undefined;
// phase 6: compute modes
const computedModes: Mode[] = [
...getModes({
disableProgressComment: resolvedPayload.disableProgressComment,
dependenciesPreinstalled,
}),
...(resolvedPayload.modes || []),
];
@@ -165,7 +162,6 @@ export async function main(inputs: Inputs): Promise<MainResult> {
repo: githubSetup.repo,
repoSettings: githubSetup.repoSettings,
modes: computedModes,
prepResults,
toolState,
agent,
sharedTempDir,
@@ -199,9 +195,9 @@ export async function main(inputs: Inputs): Promise<MainResult> {
Array.isArray(ctx.payload.event.comment_ids) &&
ctx.payload.event.comment_ids.length === 0
) {
await reportProgress(ctx, {
body: `👍 **No approved comments found**\n\nTo use "Fix 👍s", add a 👍 reaction to one or more inline review comments you want fixed.`,
});
const noThumbsMessage = `👍 **No approved comments found**\n\nTo use "Fix 👍s", add a 👍 reaction to one or more inline review comments you want fixed.`;
log.error(noThumbsMessage);
await reportProgress(ctx, { body: noThumbsMessage });
return { success: true };
}
@@ -315,7 +311,6 @@ export interface ToolContext {
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
repoSettings: RepoSettings;
modes: Mode[];
prepResults: PrepResult[];
toolState: ToolState;
agent: Agent;
sharedTempDir: string;
@@ -333,6 +328,12 @@ export interface AgentContext extends Readonly<ToolContext> {
readonly apiKeys: Record<string, string>;
}
export interface DependencyInstallationState {
status: "not_started" | "in_progress" | "completed" | "failed";
promise: Promise<PrepResult[]> | undefined;
results: PrepResult[] | undefined;
}
export interface ToolState {
prNumber?: number;
issueNumber?: number;
@@ -340,6 +341,7 @@ export interface ToolState {
id: number; // REST API database ID (for fix URLs)
nodeId: string; // GraphQL node ID (for mutations)
};
dependencyInstallation?: DependencyInstallationState;
}
/**
@@ -518,7 +520,6 @@ async function runAgent(ctx: AgentContext): Promise<AgentResult> {
apiKey: ctx.apiKey,
apiKeys: ctx.apiKeys,
cliPath: ctx.cliPath,
prepResults: ctx.prepResults,
repo: {
owner: ctx.owner,
name: ctx.name,
+121 -15
View File
@@ -1,10 +1,85 @@
import type { Octokit } from "@octokit/rest";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import { log } from "../utils/cli.ts";
import { $ } from "../utils/shell.ts";
import { execute, tool } from "./shared.ts";
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
/**
* formats PR files with explicit line numbers for each code line.
* preserves all original diff info (file headers, hunk headers) and adds:
* | OLD | NEW | TYPE | code
*/
export function formatFilesWithLineNumbers(files: PullFile[]): string {
const output: string[] = [];
for (const file of files) {
// file header
output.push(`diff --git a/${file.filename} b/${file.filename}`);
output.push(`--- a/${file.filename}`);
output.push(`+++ b/${file.filename}`);
if (!file.patch) {
output.push("(binary file or no changes)");
output.push("");
continue;
}
// parse and format the patch with line numbers
const lines = file.patch.split("\n");
let oldLine = 0;
let newLine = 0;
for (const line of lines) {
// hunk header: @@ -OLD,COUNT +NEW,COUNT @@ optional context
const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (hunkMatch) {
oldLine = parseInt(hunkMatch[1], 10);
newLine = parseInt(hunkMatch[2], 10);
output.push(line); // pass through unchanged
continue;
}
// code lines within hunks
const changeType = line[0] || " ";
const code = line.slice(1);
if (changeType === "-") {
// removed line: show old line number, no new line number
output.push(`| ${padNum(oldLine)} | | - | ${code}`);
oldLine++;
} else if (changeType === "+") {
// added line: no old line number, show new line number
output.push(`| | ${padNum(newLine)} | + | ${code}`);
newLine++;
} else if (changeType === " " || changeType === "\\") {
// context line or "\ No newline at end of file"
if (changeType === "\\") {
output.push(line); // pass through as-is
} else {
output.push(`| ${padNum(oldLine)} | ${padNum(newLine)} | | ${code}`);
oldLine++;
newLine++;
}
} else {
// unknown line type, pass through
output.push(line);
}
}
output.push(""); // blank line between files
}
return output.join("\n");
}
function padNum(n: number): string {
return n.toString().padStart(4, " ");
}
export const CheckoutPr = type({
pull_number: type.number.describe("the pull request number to checkout"),
});
@@ -19,6 +94,7 @@ export type CheckoutPrResult = {
maintainerCanModify: boolean;
url: string;
headRepo: string;
diffPath: string;
};
interface CheckoutPrBranchParams {
@@ -60,12 +136,17 @@ export async function checkoutPrBranch(
const baseBranch = pr.data.base.ref;
const headBranch = pr.data.head.ref;
// check if we're already on the correct branch
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim();
const alreadyOnBranch = currentBranch === headBranch;
// always use pr-{number} as local branch name for consistency
// this avoids naming conflicts and makes push config simpler
const localBranch = `pr-${pullNumber}`;
// check if we're already on the correct commit (not just branch name)
// this handles fork PRs where head branch name might match base branch name
const currentSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
const alreadyOnBranch = currentSha === pr.data.head.sha;
if (alreadyOnBranch) {
log.debug(`already on PR branch ${headBranch}, skipping checkout`);
log.debug(`already on PR branch ${localBranch}, skipping checkout`);
} else {
// fetch base branch so origin/<base> exists for diff operations
log.debug(`📥 fetching base branch (${baseBranch})...`);
@@ -76,11 +157,11 @@ export async function checkoutPrBranch(
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
log.debug(`🌿 fetching PR #${pullNumber} (${headBranch})...`);
$("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${headBranch}`]);
log.debug(`🌿 fetching PR #${pullNumber} (${localBranch})...`);
$("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`]);
// checkout the branch
$("git", ["checkout", headBranch]);
$("git", ["checkout", localBranch]);
log.debug(`✓ checked out PR #${pullNumber}`);
}
@@ -98,19 +179,21 @@ export async function checkoutPrBranch(
const remoteName = `pr-${pullNumber}`;
const forkUrl = `https://x-access-token:${token}@github.com/${headRepo.full_name}.git`;
// add fork as a named remote (ignore error if already exists)
// add fork as a named remote (suppress logging to avoid "error: remote already exists" spam)
try {
$("git", ["remote", "add", remoteName, forkUrl]);
$("git", ["remote", "add", remoteName, forkUrl], { log: false });
log.debug(`📌 added remote '${remoteName}' for fork ${headRepo.full_name}`);
} catch {
// remote already exists, update its URL
$("git", ["remote", "set-url", remoteName, forkUrl]);
$("git", ["remote", "set-url", remoteName, forkUrl], { log: false });
log.debug(`📌 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.debug(`📌 configured branch '${headBranch}' to push to '${remoteName}'`);
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName]);
// set merge ref so git knows the remote branch name (may differ from local)
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`]);
log.debug(`📌 configured branch '${localBranch}' to push to '${remoteName}/${headBranch}'`);
// warn if maintainer can't modify (push will likely fail)
if (!pr.data.maintainer_can_modify) {
@@ -121,7 +204,8 @@ export async function checkoutPrBranch(
}
} else {
// for same-repo PRs, push to origin
$("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]);
$("git", ["config", `branch.${localBranch}.pushRemote`, "origin"]);
$("git", ["config", `branch.${localBranch}.merge`, `refs/heads/${headBranch}`]);
}
return { prNumber: pullNumber };
@@ -131,7 +215,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
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.",
"Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. " +
"Returns diffPath pointing to the formatted diff file.",
parameters: CheckoutPr,
execute: execute(async ({ pull_number }) => {
const result = await checkoutPrBranch({
@@ -157,6 +242,26 @@ export function CheckoutPrTool(ctx: ToolContext) {
throw new Error(`PR #${pull_number} source repository was deleted`);
}
// fetch PR files and format with line numbers
const filesResponse = await ctx.octokit.rest.pulls.listFiles({
owner: ctx.owner,
repo: ctx.name,
pull_number,
per_page: 100,
});
const diffContent = formatFilesWithLineNumbers(filesResponse.data);
const diffPreview = diffContent.split("\n").slice(0, 100).join("\n");
log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) {
throw new Error(
"PULLFROG_TEMP_DIR not set - checkout_pr must run in pullfrog action context"
);
}
const diffPath = join(tempDir, `pr-${pull_number}.diff`);
writeFileSync(diffPath, diffContent);
log.debug(`wrote diff to ${diffPath} (${diffContent.length} bytes)`);
return {
success: true,
number: pr.data.number,
@@ -167,6 +272,7 @@ export function CheckoutPrTool(ctx: ToolContext) {
maintainerCanModify: pr.data.maintainer_can_modify,
url: pr.data.html_url,
headRepo: headRepo.full_name,
diffPath,
} satisfies CheckoutPrResult;
}),
});
+14 -5
View File
@@ -275,11 +275,20 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
return false;
}
await ctx.octokit.rest.issues.deleteComment({
owner: ctx.owner,
repo: ctx.name,
comment_id: existingCommentId,
});
try {
await ctx.octokit.rest.issues.deleteComment({
owner: ctx.owner,
repo: ctx.name,
comment_id: existingCommentId,
});
} catch (error) {
// ignore 404 - comment already deleted
if (error instanceof Error && error.message.includes("Not Found")) {
// comment already deleted, continue
} else {
throw error;
}
}
// reset state but mark as "updated" so ensureProgressCommentUpdated doesn't try to handle it
progressCommentId = null;
+180
View File
@@ -0,0 +1,180 @@
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import type { PrepResult } from "../prep/index.ts";
import { runPrepPhase } from "../prep/index.ts";
import { execute, tool } from "./shared.ts";
// empty schema for tools with no parameters
const EmptyParams = type({});
/**
* format prep results into agent-friendly message
*/
function formatPrepResults(results: PrepResult[]): string {
if (results.length === 0) {
return `No supported language detected in this repository (checked for package.json, requirements.txt, pyproject.toml, etc.).
Inspect the repository structure to determine how dependencies should be installed, then use bash to install them.`;
}
const lines: string[] = [];
for (const result of results) {
if (result.language === "unknown") {
continue;
}
const langDisplay = result.language === "node" ? "Node.js" : "Python";
if (result.dependenciesInstalled) {
if (result.language === "node") {
lines.push(
`${langDisplay} dependencies installed successfully via ${result.packageManager}.`
);
} else if (result.language === "python") {
lines.push(
`${langDisplay} dependencies installed successfully via ${result.packageManager} (from ${result.configFile}).`
);
}
} else {
const errorMsg = result.issues.length > 0 ? result.issues.join("\n") : "unknown error";
if (result.language === "node") {
lines.push(`${langDisplay} dependency installation failed via ${result.packageManager}.
Error:
${errorMsg}
Use bash or other tools at your disposal to diagnose and resolve the issue, then install dependencies manually.`);
} else if (result.language === "python") {
lines.push(`${langDisplay} dependency installation failed via ${result.packageManager} (from ${result.configFile}).
Error:
${errorMsg}
Use bash or other tools at your disposal to diagnose and resolve the issue, then install dependencies manually.`);
}
}
}
if (lines.length === 0) {
return `No supported language detected in this repository (checked for package.json, requirements.txt, pyproject.toml, etc.).
Inspect the repository structure to determine how dependencies should be installed, then use bash to install them.`;
}
return lines.join("\n\n");
}
/**
* start dependency installation in the background (non-blocking, idempotent)
*/
function startInstallation(ctx: ToolContext): void {
// already started or completed - do nothing
if (ctx.toolState.dependencyInstallation) {
return;
}
// initialize state and start installation
const promise = runPrepPhase();
ctx.toolState.dependencyInstallation = {
status: "in_progress",
promise,
results: undefined,
};
// when promise completes, update state
promise.then(
(results) => {
if (ctx.toolState.dependencyInstallation) {
const hasFailure = results.some((r) => !r.dependenciesInstalled && r.issues.length > 0);
ctx.toolState.dependencyInstallation.status = hasFailure ? "failed" : "completed";
ctx.toolState.dependencyInstallation.results = results;
}
},
() => {
if (ctx.toolState.dependencyInstallation) {
ctx.toolState.dependencyInstallation.status = "failed";
}
}
);
}
export function StartDependencyInstallationTool(ctx: ToolContext) {
return tool({
name: "start_dependency_installation",
description:
"Start installing project dependencies in the background. This is non-blocking and returns immediately. Call this early (right after branch checkout) if you anticipate needing to run tests, builds, or other commands that require dependencies. Idempotent - safe to call multiple times.",
parameters: EmptyParams,
execute: execute(async () => {
const state = ctx.toolState.dependencyInstallation;
// already completed
if (state?.status === "completed" || state?.status === "failed") {
return {
status: state.status,
message: `Dependency installation already completed.`,
summary: formatPrepResults(state.results || []),
};
}
// already in progress
if (state?.status === "in_progress") {
return {
status: "in_progress",
message:
"Dependency installation is already in progress. Call await_dependency_installation when you need to use them.",
};
}
// start installation
startInstallation(ctx);
return {
status: "started",
message:
"Dependency installation started in background. Continue with other tasks and call await_dependency_installation when you need to run tests, builds, or other commands that require dependencies.",
};
}),
});
}
export function AwaitDependencyInstallationTool(ctx: ToolContext) {
return tool({
name: "await_dependency_installation",
description:
"Wait for dependency installation to complete and get the results. If installation hasn't been started yet, this will start it automatically. Call this before running tests, builds, or other commands that require dependencies.",
parameters: EmptyParams,
execute: execute(async () => {
// auto-start if not started
if (!ctx.toolState.dependencyInstallation) {
startInstallation(ctx);
}
const state = ctx.toolState.dependencyInstallation;
if (!state) {
throw new Error("failed to initialize dependency installation state");
}
// if already completed, return cached results
if (state.status === "completed" || state.status === "failed") {
return {
status: state.status,
message: formatPrepResults(state.results || []),
};
}
// await the promise
if (!state.promise) {
throw new Error("dependency installation state is corrupted - no promise found");
}
const results = await state.promise;
return {
status: state.status,
message: formatPrepResults(results),
};
}),
});
}
+18 -5
View File
@@ -158,11 +158,23 @@ export function PushBranchTool(_ctx: ToolContext) {
// no configured pushRemote, default to origin
}
const args = force
? ["push", "--force", "-u", remote, branch]
: ["push", "-u", remote, branch];
// check if branch has a configured merge ref (remote branch name may differ from local)
let remoteBranch = branch;
try {
const mergeRef = $("git", ["config", `branch.${branch}.merge`], { log: false }).trim();
// merge ref is like "refs/heads/main", extract the branch name
remoteBranch = mergeRef.replace("refs/heads/", "");
} catch {
// no configured merge ref, use local branch name
}
log.debug(`pushing branch ${branch} to ${remote}`);
// use refspec when local and remote branch names differ
const refspec = branch === remoteBranch ? branch : `${branch}:${remoteBranch}`;
const args = force
? ["push", "--force", "-u", remote, refspec]
: ["push", "-u", remote, refspec];
log.debug(`pushing ${branch} to ${remote}/${remoteBranch}`);
if (force) {
log.warning(`force pushing - this will overwrite remote history`);
}
@@ -171,9 +183,10 @@ export function PushBranchTool(_ctx: ToolContext) {
return {
success: true,
branch,
remoteBranch,
remote,
force,
message: `successfully pushed branch ${branch}`,
message: `successfully pushed ${branch} to ${remote}/${remoteBranch}`,
};
}),
});
-4
View File
@@ -28,11 +28,7 @@ export function GetIssueCommentsTool(ctx: ToolContext) {
id: comment.id,
body: comment.body,
user: comment.user?.login,
created_at: comment.created_at,
updated_at: comment.updated_at,
html_url: comment.html_url,
author_association: comment.author_association,
reactions: comment.reactions,
})),
count: comments.length,
};
+1 -1
View File
@@ -29,7 +29,7 @@ function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
return `${bodyWithoutFooter}${footer}`;
}
export function PullRequestTool(ctx: ToolContext) {
export function CreatePullRequestTool(ctx: ToolContext) {
return tool({
name: "create_pull_request",
description: "Create a pull request from the current branch",
+204 -149
View File
@@ -1,6 +1,3 @@
import { randomBytes } from "node:crypto";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype";
import type { ToolContext } from "../main.ts";
@@ -9,16 +6,149 @@ import { log } from "../utils/cli.ts";
import { deleteProgressComment } from "./comment.ts";
import { execute, tool } from "./shared.ts";
// one-shot review tool
export const CreatePullRequestReview = type({
pull_number: type.number.describe("The pull request number to review"),
body: type.string
.describe(
"1-2 sentence high-level summary with urgency level, critical callouts, and feedback about code outside the diff. Specific feedback on diff lines goes in 'comments' array."
)
.optional(),
commit_id: type.string
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
.optional(),
comments: type({
path: type.string.describe("The file path to comment on (relative to repo root)"),
line: type.number.describe(
"Line number from the diff. Each code line shows 'OLD | NEW | TYPE | CODE'. Use the NEW column (second column)."
),
side: type
.enumerated("LEFT", "RIGHT")
.describe(
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT."
)
.optional(),
body: type.string.describe(
"The comment text for this specific line. For issues appearing multiple times, comment on the first occurrence and reference others."
),
start_line: type.number
.describe("Start line for multi-line comments (optional, for commenting on ranges)")
.optional(),
})
.array()
.describe(
"Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead."
)
.optional(),
});
export function CreatePullRequestReviewTool(ctx: ToolContext) {
return tool({
name: "create_pull_request_review",
description:
"Submit a review for an existing pull request. " +
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
parameters: CreatePullRequestReview,
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
// set PR context
ctx.toolState.prNumber = pull_number;
// get the PR to determine the head commit if commit_id not provided
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.owner,
repo: ctx.name,
pull_number,
});
// compose the request
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
owner: ctx.owner,
repo: ctx.name,
pull_number,
event: "COMMENT",
};
if (body) params.body = body;
if (commit_id) {
params.commit_id = commit_id;
} else {
params.commit_id = pr.data.head.sha;
}
if (comments.length > 0) {
type ReviewComment = (typeof params.comments & {})[number];
// convert comments to the format expected by GitHub API
params.comments = comments.map((comment) => {
const reviewComment: ReviewComment = {
...comment,
};
reviewComment.side = comment.side || "RIGHT";
if (comment.start_line) {
reviewComment.start_line = comment.start_line;
reviewComment.start_side = comment.side || "RIGHT";
}
return reviewComment;
});
}
const result = await ctx.octokit.rest.pulls.createReview(params);
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id) {
throw new Error(`createReview returned invalid data: ${JSON.stringify(result.data)}`);
}
const reviewId = result.data.id;
// build quick links footer and update the review body
const apiUrl = process.env.API_URL || "https://pullfrog.com";
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 ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
});
const updatedBody = (body || "") + footer;
// update the review with the footer
await ctx.octokit.rest.pulls.updateReview({
owner: ctx.owner,
repo: ctx.name,
pull_number,
review_id: reviewId,
body: updatedBody,
});
await deleteProgressComment(ctx);
return {
success: true,
reviewId,
html_url: result.data.html_url,
state: result.data.state,
user: result.data.user?.login,
submitted_at: result.data.submitted_at,
};
}),
});
}
// =============================================================================
// COMMENTED OUT: Three-step review flow (start_review, add_review_comment, submit_review)
// This approach used GraphQL to add comments to a pending review one-by-one,
// but GitHub's API was returning null for valid lines. Keeping for reference.
// =============================================================================
/*
// graphql mutation to add a comment thread to a pending review
// note: REST API doesn't support adding comments to an existing pending review
const ADD_PULL_REQUEST_REVIEW_THREAD = `
mutation AddPullRequestReviewThread($pullRequestReviewId: ID!, $path: String!, $line: Int!, $body: String!, $side: DiffSide) {
mutation AddPullRequestReviewThread($pullRequestReviewId: ID!, $path: String!, $line: Int!, $body: String!, $side: DiffSide, $subjectType: PullRequestReviewThreadSubjectType) {
addPullRequestReviewThread(input: {
pullRequestReviewId: $pullRequestReviewId,
path: $path,
line: $line,
body: $body,
side: $side
side: $side,
subjectType: $subjectType
}) {
thread {
id
@@ -65,7 +195,7 @@ export function StartReviewTool(ctx: ToolContext) {
return tool({
name: "start_review",
description:
"Start a new review session for a pull request. Creates a scratchpad file for gathering thoughts and a pending review on GitHub. Must be called before add_review_comment.",
"Start a new review session for a pull request. Creates a pending review on GitHub. Must be called before add_review_comment.",
parameters: StartReview,
execute: execute(async ({ pull_number }) => {
// check if review already started in this session
@@ -95,6 +225,13 @@ export function StartReviewTool(ctx: ToolContext) {
commit_id: pr.data.head.sha,
// no 'event' = PENDING review
});
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id || !result.data.node_id) {
log.debug(result);
throw new Error(
`createReview returned invalid data: id=${result.data.id}, node_id=${result.data.node_id}`
);
}
reviewId = result.data.id;
reviewNodeId = result.data.node_id;
log.debug(`created new pending review: id=${reviewId}`);
@@ -119,12 +256,6 @@ export function StartReviewTool(ctx: ToolContext) {
}
}
// create scratchpad file
const scratchpadId = randomBytes(4).toString("hex");
const scratchpadPath = join(ctx.sharedTempDir, `pullfrog-review-${scratchpadId}.md`);
const scratchpadContent = `# Review ${scratchpadId}\n\n`;
writeFileSync(scratchpadPath, scratchpadContent);
// set PR context and review state
ctx.toolState.prNumber = pull_number;
ctx.toolState.review = {
@@ -132,10 +263,10 @@ export function StartReviewTool(ctx: ToolContext) {
id: reviewId,
};
log.debug(`review session started: id=${reviewId}, nodeId=${reviewNodeId}`);
return {
reviewId: scratchpadId,
scratchpadPath,
message: `Review session started. Use the scratchpad file to gather your thoughts, then call add_review_comment for each comment.`,
message: `Review session started for PR #${pull_number}. Add comments with add_review_comment, then submit with submit_review.`,
};
}),
});
@@ -166,21 +297,59 @@ export function AddReviewCommentTool(ctx: ToolContext) {
throw new Error("No review session started. Call start_review first.");
}
// add comment thread via GraphQL (REST doesn't support adding to existing pending review)
await ctx.octokit.graphql<AddPullRequestReviewThreadResponse>(
ADD_PULL_REQUEST_REVIEW_THREAD,
{
pullRequestReviewId: ctx.toolState.review.nodeId,
path,
line,
body,
side: side || "RIGHT",
}
const reviewNodeId = ctx.toolState.review.nodeId;
log.debug(
`adding review comment: reviewNodeId=${reviewNodeId}, path=${path}, line=${line}, side=${side || "RIGHT"}`
);
// add comment thread via GraphQL (REST doesn't support adding to existing pending review)
let result: AddPullRequestReviewThreadResponse;
try {
result = await ctx.octokit.graphql<AddPullRequestReviewThreadResponse>(
ADD_PULL_REQUEST_REVIEW_THREAD,
{
pullRequestReviewId: reviewNodeId,
path,
line,
body,
side: side || "RIGHT",
subjectType: "LINE",
}
);
log.debug(`addPullRequestReviewThread response: ${JSON.stringify(result)}`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
log.debug(`addPullRequestReviewThread error: ${errorMsg}`);
throw new Error(
`Failed to add comment to ${path}:${line}. GraphQL error: ${errorMsg}. ` +
`Ensure the line is part of the diff and the path is correct.`
);
}
// check if the mutation succeeded - null means the line is not in the diff
if (!result) {
throw new Error(
`Failed to add comment to ${path}:${line}. GraphQL returned null response.`
);
}
if (!result.addPullRequestReviewThread) {
throw new Error(
`Failed to add comment to ${path}:${line}. addPullRequestReviewThread is null. Response: ${JSON.stringify(result)}`
);
}
if (!result.addPullRequestReviewThread.thread) {
throw new Error(
`Failed to add comment to ${path}:${line}. thread is null. The line must be part of the diff. Response: ${JSON.stringify(result)}`
);
}
const threadId = result.addPullRequestReviewThread.thread.id;
log.debug(`review comment added: threadId=${threadId}`);
return {
success: true,
message: `Comment added to ${path}:${line}`,
threadId,
};
}),
});
@@ -211,6 +380,9 @@ export function SubmitReviewTool(ctx: ToolContext) {
}
const reviewId = ctx.toolState.review.id;
log.debug(
`submitting review: id=${reviewId}, nodeId=${ctx.toolState.review.nodeId}, prNumber=${ctx.toolState.prNumber}`
);
// build quick links footer
const apiUrl = process.env.API_URL || "https://pullfrog.com";
@@ -234,6 +406,12 @@ export function SubmitReviewTool(ctx: ToolContext) {
body: bodyWithFooter,
});
log.debug(`submitReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id) {
throw new Error(`submitReview returned invalid data: ${JSON.stringify(result.data)}`);
}
log.debug(`review submitted: reviewId=${result.data.id}, state=${result.data.state}`);
// clear review state
delete ctx.toolState.review;
@@ -249,127 +427,4 @@ export function SubmitReviewTool(ctx: ToolContext) {
}),
});
}
// legacy tool - kept for backwards compatibility
export const Review = type({
pull_number: type.number.describe("The pull request number to review"),
body: type.string
.describe(
"1-2 sentence high-level summary ONLY. Include urgency level and critical callouts (e.g., API key leak). ALL specific feedback MUST go in 'comments' array instead."
)
.optional(),
commit_id: type.string
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
.optional(),
comments: type({
path: type.string.describe("The file path to comment on (relative to repo root)"),
line: type.number.describe(
"The line number in the file (use line numbers from the diff - usually the RIGHT side/new code)"
),
side: type
.enumerated("LEFT", "RIGHT")
.describe(
"Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT if not provided."
)
.optional(),
body: type.string.describe(
"The comment text for this specific line. For issues appearing multiple times, comment on the first occurrence and reference others."
),
start_line: type.number
.describe("Start line for multi-line comments (optional, for commenting on ranges)")
.optional(),
})
.array()
.describe(
// FORK PR NOTE: use HEAD not origin/<head> - for fork PRs, origin/<head> 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/<base>..HEAD' to find correct line numbers (RIGHT side for new code, LEFT for old). Works for both fork and same-repo PRs."
)
.optional(),
});
export function ReviewTool(ctx: ToolContext) {
return tool({
name: "submit_pull_request_review",
description:
"DEPRECATED: Use start_review, add_review_comment, and submit_review instead for iterative review workflow. " +
"Submit a review for an existing pull request. " +
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
parameters: Review,
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
// set PR context
ctx.toolState.prNumber = pull_number;
// get the PR to determine the head commit if commit_id not provided
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.owner,
repo: ctx.name,
pull_number,
});
// compose the request
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
owner: ctx.owner,
repo: ctx.name,
pull_number,
event: "COMMENT",
};
if (body) params.body = body;
if (commit_id) {
params.commit_id = commit_id;
} else {
params.commit_id = pr.data.head.sha;
}
if (comments.length > 0) {
type ReviewComment = (typeof params.comments & {})[number];
// convert comments to the format expected by GitHub API
params.comments = comments.map((comment) => {
const reviewComment: ReviewComment = {
...comment,
};
reviewComment.side = comment.side || "RIGHT";
if (comment.start_line) {
reviewComment.start_line = comment.start_line;
reviewComment.start_side = comment.side || "RIGHT";
}
return reviewComment;
});
}
const result = await ctx.octokit.rest.pulls.createReview(params);
const reviewId = result.data.id;
// build quick links footer and update the review body
const apiUrl = process.env.API_URL || "https://pullfrog.com";
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 ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
});
const updatedBody = (body || "") + footer;
// update the review with the footer
await ctx.octokit.rest.pulls.updateReview({
owner: ctx.owner,
repo: ctx.name,
pull_number,
review_id: reviewId,
body: updatedBody,
});
await deleteProgressComment(ctx);
return {
success: true,
reviewId,
html_url: result.data.html_url,
state: result.data.state,
user: result.data.user?.login,
submitted_at: result.data.submitted_at,
};
}),
});
}
*/
+10 -7
View File
@@ -13,6 +13,10 @@ import {
ReportProgressTool,
} from "./comment.ts";
import { DebugShellCommandTool } from "./debug.ts";
import {
AwaitDependencyInstallationTool,
StartDependencyInstallationTool,
} from "./dependencies.ts";
import { ListFilesTool } from "./files.ts";
import { CommitFilesTool, CreateBranchTool, PushBranchTool } from "./git.ts";
import { IssueTool } from "./issue.ts";
@@ -20,9 +24,9 @@ import { GetIssueCommentsTool } from "./issueComments.ts";
import { GetIssueEventsTool } from "./issueEvents.ts";
import { IssueInfoTool } from "./issueInfo.ts";
import { AddLabelsTool } from "./labels.ts";
import { PullRequestTool } from "./pr.ts";
import { CreatePullRequestTool } from "./pr.ts";
import { PullRequestInfoTool } from "./prInfo.ts";
import { AddReviewCommentTool, StartReviewTool, SubmitReviewTool } from "./review.ts";
import { CreatePullRequestReviewTool } from "./review.ts";
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
import { SelectModeTool } from "./selectMode.ts";
import { addTools } from "./shared.ts";
@@ -70,6 +74,8 @@ export async function startMcpHttpServer(
// create all tools as factories, passing ctx
const tools: Tool<any, any>[] = [
SelectModeTool(ctx),
StartDependencyInstallationTool(ctx),
AwaitDependencyInstallationTool(ctx),
CreateCommentTool(ctx),
EditCommentTool(ctx),
ReplyToReviewCommentTool(ctx),
@@ -77,11 +83,8 @@ export async function startMcpHttpServer(
IssueInfoTool(ctx),
GetIssueCommentsTool(ctx),
GetIssueEventsTool(ctx),
PullRequestTool(ctx),
// ReviewTool(ctx),
StartReviewTool(ctx),
AddReviewCommentTool(ctx),
SubmitReviewTool(ctx),
CreatePullRequestTool(ctx),
CreatePullRequestReviewTool(ctx),
PullRequestInfoTool(ctx),
CheckoutPrTool(ctx),
GetReviewCommentsTool(ctx),
+5 -8
View File
@@ -1,4 +1,5 @@
import type { StandardSchemaV1 } from "@standard-schema/spec";
import { encode as toonEncode } from "@toon-format/toon";
import type { FastMCP, Tool } from "fastmcp";
import type { ToolContext } from "../main.ts";
@@ -12,14 +13,10 @@ export interface ToolResult {
isError?: boolean;
}
export const handleToolSuccess = (data: Record<string, any>): ToolResult => {
export const handleToolSuccess = (data: Record<string, any> | string): ToolResult => {
const text = typeof data === "string" ? data : toonEncode(data);
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
content: [{ type: "text", text }],
};
};
@@ -40,7 +37,7 @@ export const handleToolError = (error: unknown): ToolResult => {
* Helper to wrap a tool execute function with error handling.
* Captures ctx in closure so tools don't need to handle try/catch.
*/
export const execute = <T>(fn: (params: T) => Promise<Record<string, any>>) => {
export const execute = <T>(fn: (params: T) => Promise<Record<string, any> | string>) => {
return async (params: T): Promise<ToolResult> => {
try {
const result = await fn(params);
+42 -44
View File
@@ -8,28 +8,33 @@ export interface Mode {
export interface GetModesParams {
disableProgressComment: true | undefined;
dependenciesPreinstalled: true | undefined;
}
const reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to share progress and results. Continue calling it as you make progress - it will update the same comment. Never create additional comments manually.`;
export function getModes({
disableProgressComment,
dependenciesPreinstalled,
}: GetModesParams): Mode[] {
const depsContext = dependenciesPreinstalled
? "Dependencies have already been installed."
: "understand how to install dependencies,";
const dependencyInstallationGuidance = `## Dependency Installation
**IMPORTANT**: Immediately after the working branch is checked out, evaluate whether dependencies will be needed at any point during this task:
- Making code changes that will require testing? → Call \`${ghPullfrogMcpName}/start_dependency_installation\` NOW
- Running builds, linters, or CLI commands that require installed packages? → Call \`${ghPullfrogMcpName}/start_dependency_installation\` NOW
- Only reading code or answering questions? → Skip dependency installation
Calling \`start_dependency_installation\` early allows dependencies to install in the background while you explore the codebase and make changes. This is a non-blocking call.
When you need to run tests, builds, or other commands that require dependencies, call \`${ghPullfrogMcpName}/await_dependency_installation\` to ensure they're ready. This will block until installation completes (or auto-start if you forgot to call start earlier).`;
export function getModes({ disableProgressComment }: GetModesParams): Mode[] {
return [
{
name: "Build",
description:
"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. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context. Read AGENTS.md if it exists, ${depsContext} run tests, run builds, and make changes according to best practices). Skip this step if the prompt is trivial and self-contained.
prompt: `Follow these steps. THINK HARDER.
1. If this is a PR event, the PR branch is already checked out - skip branch creation. Otherwise, create a branch using ${ghPullfrogMcpName}/create_branch. 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 directly to main, master, or production. Do NOT use git commands directly (including \`git branch\`, \`git status\`, \`git log\`) - always use ${ghPullfrogMcpName} MCP tools for git operations.
2. If this is a PR event, the PR branch is already checked out - skip branch creation. Otherwise, create a branch using ${ghPullfrogMcpName}/create_branch. 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 directly to main, master, or production. Do NOT use git commands directly (including \`git branch\`, \`git status\`, \`git log\`) - always use ${ghPullfrogMcpName} MCP tools for git operations.
${dependencyInstallationGuidance}
2. If the request requires understanding the codebase structure or conventions, gather relevant context. Read AGENTS.md if it exists. Skip this step if the prompt is trivial and self-contained.
3. Understand the requirements and any existing plan
@@ -63,14 +68,16 @@ export 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:
prompt: `Follow these steps. THINK HARDER.
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).
${dependencyInstallationGuidance}
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.
3. If the request requires understanding the codebase structure or conventions, gather relevant context. Read AGENTS.md if it exists.
4. Make the necessary code changes to address the feedback. Work through each review comment systematically.
@@ -92,47 +99,36 @@ ${
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. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and base branch, preparing the repo for review.
prompt: `Follow these steps to review the PR. Think hard. Do not nitpick.
2. **IMPORTANT**: After calling ${ghPullfrogMcpName}/checkout_pr, the PR branch is checked out locally. View diff using: \`git diff origin/<base>..HEAD\` (replace <base> 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/<head>\` - the branch is checked out locally, not as a remote tracking branch. This works for both same-repo and fork PRs.
1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number. This should give you all PR metadata you need, including a \`diffPath\`: a path to a temp file containing the PR diff.
3. Start review session using ${ghPullfrogMcpName}/start_review. This creates a scratchpad file at a temp path (e.g., \`/tmp/pullfrog-review-abc123.md\`) and returns a session ID. The scratchpad file header contains the session ID for reference. Use this file as free-form space to gather your thoughts before adding comments.
4. **ANALYZE** - Use the scratchpad to gather your thoughts:
- Summarize what changes this PR makes
- Evaluate the approach - is it sound? If not, **stop here** and leave feedback on the approach. Don't waste time on implementation details if the approach is wrong.
- If approach is sound, analyze implementation - consider potential issues per file
- Identify bugs, security issues, edge cases
2. **ANALYZE**
- Read the modified files to understand the changes in context. Make sure you understand what's being changed.
- Is it a good idea? Think about the tradeoffs.
- Is the approach sound? If not, focus on the approach first. Don't waste time on implementation details if the approach is wrong.
- Can you imagine a better approach? If so, explain. Make sure it's strictly better, not just different.
- Are there bugs, edge cases, security issues, or usability issues? Use your imagination.
5. **SELF-CRITIQUE** - Before adding comments, review your scratchpad:
- Remove nitpicks unless explicitly requested. Think documentation, JSDoc/docstrings, useless comments (compliments)
- Your level of nitpickiness should be proportional to the current state of the codebase. Try to guess how much the user will care about a specific critique.
3. **DRAFT** - For each inline comment, find the line in the diff. Each code line shows: \`| OLD | NEW | TYPE | CODE\`. Use the NEW line number (second column).
6. Add inline review comments one-by-one using ${ghPullfrogMcpName}/add_review_comment
- Use **relative paths** from repo root (e.g., \`packages/core/src/utils.ts\`)
- 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.
- For issues appearing in multiple places, comment on the FIRST occurrence and reference others (e.g., "also at lines X, Y")
4. **FILTER COMMENTS** - Do not nitpick! Do not leave compliments that are not actionable. Do not critique the code hygiene or anything stylistic.
7. Submit the review using ${ghPullfrogMcpName}/submit_review
- The "body" field is ONLY for: (1) a 1-3 sentence high-level overview, (2) urgency level (e.g., "minor suggestions" vs "blocking issues"), (3) critical security callouts (e.g., API key exposure)
5. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review with:
- \`comments\`: Array of all inline comments with file paths and line numbers
- \`body\`: Everything else. Aim for a 1-3 sentence summary of the urgency level (e.g., "minor suggestions" vs "blocking issues") and any critical callouts (e.g., API key exposure). It can be longer if there are concerns that do not lend themselves to inline comments.
- If you have no substantive feedback, submit an empty comments array with a brief approving body.
- Again, do not nitpick.
**GENERAL GUIDANCE**
- Do not leave any comments that are not potentially actionable. Do not leave complimentary comments just to be nice.
- Do not nitpick unless instructed explicitly to do so by the user's additional instructions. This includes: requesting documentation/docstrings/JSDoc.
- **CRITICAL: Prioritize per-line feedback over summary text.**
- All specific feedback MUST go in inline review comments with file paths and line numbers from the diff
- The vast majority of review content should be in inline review comments; the body should be brief and only summarize the urgency of the review and any cross-cutting concerns.
`,
`,
},
{
name: "Plan",
description:
"Create plans, break down tasks, outline steps, analyze requirements, understand scope of work, or provide task breakdowns",
prompt: `Follow these steps:
1. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context (read AGENTS.md if it exists, ${depsContext} run tests, run builds, and make changes according to best practices). Skip this step if the prompt is trivial and self-contained.
prompt: `Follow these steps. THINK HARDER.
1. If the request requires understanding the codebase structure or conventions, gather relevant context (read AGENTS.md if it exists). Skip this step if the prompt is trivial and self-contained.
2. Analyze the request and break it down into clear, actionable tasks
@@ -144,11 +140,14 @@ ${
name: "Prompt",
description:
"Fallback for tasks that don't fit other workflows, e.g. direct prompts via comments, or requests requiring general assistance",
prompt: `Follow these steps:
prompt: `Follow these steps. THINK HARDER.
1. 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.${disableProgressComment ? "" : "\n\n2. When creating comments, always use report_progress. Do not use create_issue_comment."}
2. If the task involves making code changes:
- Create a branch using ${ghPullfrogMcpName}/create_branch. Branch names should be prefixed with "pullfrog/" and reflect the exact changes you are making. Never commit directly to main, master, or production.
${dependencyInstallationGuidance}
- Use file operations to create/modify files with your changes.
- Use ${ghPullfrogMcpName}/commit_files to commit your changes, then ${ghPullfrogMcpName}/push_branch to push the branch. Do NOT use git commands directly (\`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) as these will use incorrect credentials.
- Test your changes to ensure they work correctly.
@@ -163,5 +162,4 @@ ${
export const modes: Mode[] = getModes({
disableProgressComment: undefined,
dependenciesPreinstalled: undefined,
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/action",
"version": "0.0.146",
"version": "0.0.155",
"type": "module",
"files": [
"index.js",
+1 -1
View File
@@ -19,7 +19,7 @@ config({ path: join(process.cwd(), "..", ".env") });
export async function run(prompt: string): Promise<AgentResult> {
try {
const tempDir = join(process.cwd(), ".temp");
setupTestRepo({ tempDir, forceClean: true });
setupTestRepo({ tempDir });
const originalCwd = process.cwd();
process.chdir(tempDir);
+63 -29
View File
@@ -1,19 +1,18 @@
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { isKeyOf } from "@ark/util";
import { detect } from "package-manager-detector";
import { resolveCommand } from "package-manager-detector/commands";
import { log } from "../utils/cli.ts";
import { spawn } from "../utils/subprocess.ts";
import type { NodePackageManager, NodePrepResult, PrepDefinition } from "./types.ts";
// package managers that need installation (npm is always available)
type InstallablePackageManager = Exclude<NodePackageManager, "npm">;
// install commands for each package manager
const PM_INSTALL_COMMANDS: Record<InstallablePackageManager, string[]> = {
pnpm: ["npm", "install", "-g", "pnpm"],
yarn: ["npm", "install", "-g", "yarn"],
bun: ["npm", "install", "-g", "bun"],
// install command templates for each package manager (version placeholder: {version})
const nodePackageManagers: Record<NodePackageManager, string[]> = {
npm: ["echo", "npm is already installed"],
pnpm: ["npm", "install", "-g", "{version}"],
yarn: ["npm", "install", "-g", "{version}"],
bun: ["npm", "install", "-g", "{version}"],
deno: ["sh", "-c", "curl -fsSL https://deno.land/install.sh | sh"],
};
@@ -26,9 +25,40 @@ async function isCommandAvailable(command: string): Promise<boolean> {
return result.exitCode === 0;
}
async function installPackageManager(name: InstallablePackageManager): Promise<string | null> {
log.info(`📦 installing ${name}...`);
const [cmd, ...args] = PM_INSTALL_COMMANDS[name];
interface PackageManagerSpec {
name: NodePackageManager;
installSpec: string; // e.g., "pnpm@8.15.0" (without hash suffix)
}
function getPackageManagerFromPackageJson(): PackageManagerSpec | null {
const packageJsonPath = join(process.cwd(), "package.json");
try {
const content = readFileSync(packageJsonPath, "utf-8");
const pkg = JSON.parse(content) as { packageManager?: string };
if (!pkg.packageManager) return null;
// format: "pnpm@8.15.0" or "pnpm@8.15.0+sha512.abc123..."
// strip the hash suffix (+sha256.xxx) as npm install doesn't understand it
const withoutHash = pkg.packageManager.split("+")[0];
const name = withoutHash.split("@")[0];
if (isKeyOf(name, nodePackageManagers)) {
return { name, installSpec: withoutHash };
}
log.warning(`unknown packageManager in package.json: ${pkg.packageManager}`);
return null;
} catch {
return null;
}
}
async function installPackageManager(
name: NodePackageManager,
installSpec: string
): Promise<string | null> {
if (name === "npm") return null; // npm is always available
log.info(`📦 installing ${installSpec}...`);
const [cmd, ...templateArgs] = nodePackageManagers[name];
const args = templateArgs.map((arg) => (arg === "{version}" ? installSpec : arg));
const result = await spawn({
cmd,
args,
@@ -59,24 +89,29 @@ export const installNodeDependencies: PrepDefinition = {
},
run: async (): Promise<NodePrepResult> => {
// detect package manager
// check packageManager field in package.json first (takes priority)
const fromPackageJson = getPackageManagerFromPackageJson();
// detect from lockfile as fallback
const detected = await detect({ cwd: process.cwd() });
if (!detected) {
return {
language: "node",
packageManager: "npm",
dependenciesInstalled: false,
issues: ["no package manager detected from lockfile"],
};
// prefer package.json field, fall back to lockfile detection, default to npm
const packageManager = fromPackageJson?.name || (detected?.name as NodePackageManager) || "npm";
const installSpec = fromPackageJson?.installSpec || packageManager;
const agent = detected?.agent || packageManager;
if (fromPackageJson) {
log.info(`📦 using packageManager from package.json: ${fromPackageJson.installSpec}`);
} else if (detected) {
log.info(`📦 detected package manager: ${packageManager} (${agent})`);
} else {
log.info(`📦 no package manager detected, defaulting to npm`);
}
const packageManager = detected.name as NodePackageManager;
log.info(`📦 detected package manager: ${packageManager} (${detected.agent})`);
// check if package manager is available, install if needed (npm is always available)
if (packageManager !== "npm" && !(await isCommandAvailable(packageManager))) {
// check if package manager is available, install if needed
if (!(await isCommandAvailable(packageManager))) {
log.info(`${packageManager} not found, attempting to install...`);
const installError = await installPackageManager(packageManager);
const installError = await installPackageManager(packageManager, installSpec);
if (installError) {
return {
language: "node",
@@ -88,14 +123,13 @@ export const installNodeDependencies: PrepDefinition = {
}
// get the frozen install command (or fallback to regular install)
const resolved =
resolveCommand(detected.agent, "frozen", []) || resolveCommand(detected.agent, "install", []);
const resolved = resolveCommand(agent, "frozen", []) || resolveCommand(agent, "install", []);
if (!resolved) {
return {
language: "node",
packageManager,
dependenciesInstalled: false,
issues: [`no install command found for ${detected.agent}`],
issues: [`no install command found for ${agent}`],
};
}
+4 -4
View File
@@ -36,8 +36,8 @@ export interface WorkflowRunInfo {
export async function fetchWorkflowRunInfo(runId: string): Promise<WorkflowRunInfo> {
const apiUrl = process.env.API_URL || "https://pullfrog.com";
// add timeout to prevent hanging (5 seconds)
const timeoutMs = 5000;
// add timeout to prevent hanging (30 seconds)
const timeoutMs = 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -90,8 +90,8 @@ export async function getRepoSettings(
): Promise<RepoSettings> {
const apiUrl = process.env.API_URL || "https://pullfrog.com";
// Add timeout to prevent hanging (5 seconds)
const timeoutMs = 5000;
// Add timeout to prevent hanging (30 seconds)
const timeoutMs = 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+6 -6
View File
@@ -278,7 +278,7 @@ export const log = {
/**
* Print debug message (only if LOG_LEVEL=debug)
*/
debug: (message: string): void => {
debug: (message: string | unknown): void => {
if (isDebugEnabled()) {
if (isGitHubActions) {
// using this instead of core.debug
@@ -341,12 +341,12 @@ export const log = {
* Log tool call information to console with formatted output
*/
toolCall: ({ toolName, input }: { toolName: string; input: unknown }): void => {
let output = `${toolName}\n`;
const inputFormatted = formatJsonValue(input);
if (inputFormatted !== "{}") {
output += formatIndentedField("input", inputFormatted);
}
const timestamp = isDebugEnabled() ? ` [${new Date().toISOString()}]` : "";
const output =
inputFormatted !== "{}"
? `${toolName}(${inputFormatted})${timestamp}`
: `${toolName}()${timestamp}`;
log.info(output.trimEnd());
},
+6 -7
View File
@@ -1,6 +1,7 @@
import { createSign } from "node:crypto";
import * as core from "@actions/core";
import { log } from "./cli.ts";
import { retry } from "./retry.ts";
export interface InstallationToken {
token: string;
@@ -48,17 +49,15 @@ function isGitHubActionsEnvironment(): boolean {
}
async function acquireTokenViaOIDC(): Promise<string> {
log.debug("» generating OIDC token...");
log.info("» generating OIDC token...");
const oidcToken = await core.getIDToken("pullfrog-api");
log.debug("» OIDC token generated successfully");
const apiUrl = process.env.API_URL || "https://pullfrog.com";
log.debug("» exchanging OIDC token for installation token...");
log.info("» exchanging OIDC token for installation token...");
// Add timeout to prevent long waits (5 seconds)
const timeoutMs = 5000;
const timeoutMs = 30000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -79,7 +78,7 @@ async function acquireTokenViaOIDC(): Promise<string> {
}
const tokenData = (await tokenResponse.json()) as InstallationToken;
log.debug(`» installation token obtained for ${tokenData.repository || "all repositories"}`);
log.info(`» installation token obtained for ${tokenData.repository || "all repositories"}`);
return tokenData.token;
} catch (error) {
@@ -235,7 +234,7 @@ async function acquireTokenViaGitHubApp(): Promise<string> {
async function acquireNewToken(): Promise<string> {
if (isGitHubActionsEnvironment()) {
return await acquireTokenViaOIDC();
return await retry(() => acquireTokenViaOIDC(), { label: "token exchange" });
} else {
return await acquireTokenViaGitHubApp();
}
+47
View File
@@ -0,0 +1,47 @@
import { log } from "./cli.ts";
export type RetryOptions = {
maxAttempts?: number;
delayMs?: number;
shouldRetry?: (error: unknown) => boolean;
label?: string;
};
const defaultShouldRetry = (error: unknown): boolean => {
if (!(error instanceof Error)) return false;
// retry on transient network errors
return (
error.name === "AbortError" ||
error.message.includes("fetch failed") ||
error.message.includes("ECONNRESET") ||
error.message.includes("ETIMEDOUT")
);
};
export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
const maxAttempts = options.maxAttempts ?? 3;
const delayMs = options.delayMs ?? 1000;
const shouldRetry = options.shouldRetry ?? defaultShouldRetry;
const label = options.label ?? "operation";
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxAttempts || !shouldRetry(error)) {
throw error;
}
const delay = delayMs * attempt;
log.warning(`» ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
+8 -20
View File
@@ -9,33 +9,21 @@ import { $ } from "./shell.ts";
export interface SetupOptions {
tempDir: string;
forceClean?: boolean;
}
/**
* Setup the test repository for running actions
*/
export function setupTestRepo(options: SetupOptions): void {
const { tempDir, forceClean = false } = options;
const { tempDir } = options;
const repo = process.env.GITHUB_REPOSITORY;
if (!repo) throw new Error("GITHUB_REPOSITORY is required");
if (existsSync(tempDir)) {
if (forceClean) {
log.info("» removing existing .temp directory...");
rmSync(tempDir, { recursive: true, force: true });
log.info("» cloning pullfrog/scratch into .temp...");
$("git", ["clone", "git@github.com:pullfrog/scratch.git", tempDir]);
} else {
log.info("» resetting existing .temp repository...");
execSync("git reset --hard HEAD && git clean -fd", {
cwd: tempDir,
stdio: "inherit",
});
}
} else {
log.info("» cloning pullfrog/scratch into .temp...");
$("git", ["clone", "git@github.com:pullfrog/scratch.git", tempDir]);
log.info("» removing existing .temp directory...");
rmSync(tempDir, { recursive: true, force: true });
}
log.info(`» cloning ${repo} into .temp...`);
$("git", ["clone", `git@github.com:${repo}.git`, tempDir]);
}
/**
@@ -89,7 +77,7 @@ interface SetupGitAuthParams {
* FORK PR ARCHITECTURE:
* - origin: always points to BASE REPO (where PR targets)
* - checkoutPrBranch sets per-branch pushRemote config for fork PRs
* - diff operations use: git diff origin/<base>..HEAD
* - checkout_pr returns the PR diff via GitHub API (authoritative source)
*/
export async function setupGitAuth(params: SetupGitAuthParams): Promise<void> {
const repoDir = process.cwd();