Compare commits

...

9 Commits

Author SHA1 Message Date
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
23 changed files with 8382 additions and 9009 deletions
+2 -2
View File
@@ -14,11 +14,11 @@ 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 });
const prompt = addInstructions({ payload, repo });
log.group("» Full prompt", () => log.info(prompt));
// configure sandbox mode if enabled
+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) {
+2 -2
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,7 +166,7 @@ export const cursor = agent({
};
try {
const fullPrompt = addInstructions({ payload, prepResults, repo });
const fullPrompt = addInstructions({ payload, repo });
log.group("» Full prompt", () => log.info(fullPrompt));
// configure sandbox mode if enabled
+2 -2
View File
@@ -154,14 +154,14 @@ 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 });
const sessionPrompt = addInstructions({ payload, repo });
log.group("» Full prompt", () => log.info(sessionPrompt));
// configure sandbox mode if enabled
+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
+2 -2
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,7 +29,7 @@ export const opencode = agent({
configureOpenCode({ mcpServers, sandbox: payload.sandbox ?? false });
const prompt = addInstructions({ payload, prepResults, repo });
const prompt = addInstructions({ payload, repo });
log.group("» Full prompt", () => log.info(prompt));
// message positional must come right after "run", before flags
-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;
}
+8026 -8792
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
run pnpm --version and print the output
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,
+28 -10
View File
@@ -19,6 +19,7 @@ export type CheckoutPrResult = {
maintainerCanModify: boolean;
url: string;
headRepo: string;
diff: string;
};
interface CheckoutPrBranchParams {
@@ -60,12 +61,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 +82,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}`);
}
@@ -109,8 +115,10 @@ export async function checkoutPrBranch(
}
// 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 +129,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 };
@@ -157,6 +166,14 @@ export function CheckoutPrTool(ctx: ToolContext) {
throw new Error(`PR #${pull_number} source repository was deleted`);
}
// fetch PR diff via API (authoritative source - not affected by main advancing)
const diffResponse = await ctx.octokit.rest.pulls.get({
owner: ctx.owner,
repo: ctx.name,
pull_number,
mediaType: { format: "diff" },
});
return {
success: true,
number: pr.data.number,
@@ -167,6 +184,7 @@ export function CheckoutPrTool(ctx: ToolContext) {
maintainerCanModify: pr.data.maintainer_can_modify,
url: pr.data.html_url,
headRepo: headRepo.full_name,
diff: diffResponse.data as unknown as string,
} 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}`,
};
}),
});
+22 -17
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";
@@ -65,7 +62,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
@@ -119,12 +116,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 +123,13 @@ 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}.`,
instructions:
"Analyze: What does this PR change? Is the approach sound? What bugs, edge cases, or security issues exist? " +
"Before commenting: Skip nitpicks unless requested. Only comment if the codebase maintainer would care.",
};
}),
});
@@ -166,8 +160,12 @@ export function AddReviewCommentTool(ctx: ToolContext) {
throw new Error("No review session started. Call start_review first.");
}
log.debug(
`adding review comment: reviewNodeId=${ctx.toolState.review.nodeId}, path=${path}, line=${line}, side=${side || "RIGHT"}`
);
// add comment thread via GraphQL (REST doesn't support adding to existing pending review)
await ctx.octokit.graphql<AddPullRequestReviewThreadResponse>(
const result = await ctx.octokit.graphql<AddPullRequestReviewThreadResponse>(
ADD_PULL_REQUEST_REVIEW_THREAD,
{
pullRequestReviewId: ctx.toolState.review.nodeId,
@@ -178,9 +176,12 @@ export function AddReviewCommentTool(ctx: ToolContext) {
}
);
log.debug(`review comment added: threadId=${result.addPullRequestReviewThread.thread.id}`);
return {
success: true,
message: `Comment added to ${path}:${line}`,
threadId: result.addPullRequestReviewThread.thread.id,
};
}),
});
@@ -211,6 +212,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 +238,8 @@ export function SubmitReviewTool(ctx: ToolContext) {
body: bodyWithFooter,
});
log.debug(`review submitted: reviewId=${result.data.id}, state=${result.data.state}`);
// clear review state
delete ctx.toolState.review;
@@ -281,9 +287,8 @@ export const Review = type({
})
.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."
// FORK PR NOTE: checkout_pr returns the diff via GitHub API - use that for line numbers
"PRIMARY location for ALL feedback. 95%+ of review content should be here. Use the diff returned from checkout_pr to find correct line numbers (RIGHT side for new code, LEFT for old)."
)
.optional(),
});
+6
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";
@@ -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),
+33 -27
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.
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
@@ -66,11 +71,13 @@ export function getModes({
prompt: `Follow these steps:
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.
@@ -93,29 +100,26 @@ ${
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.
1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and returns the PR diff in the \`diff\` field of the response. Use this diff for your review - it shows exactly what's in the PR (fetched via GitHub API, so it's not affected by main advancing after the branch was created).
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.
2. Start review session using ${ghPullfrogMcpName}/start_review. This creates a pending review on GitHub and returns analysis guidance. Follow the guidance before adding comments.
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.
3. **ANALYZE** - Before adding any comments, think through:
- What does this PR change? Summarize in 1-2 sentences.
- Is the approach sound? If not, **stop here** and comment on the approach first. Don't waste time on implementation details if the approach is wrong.
- What bugs, edge cases, or security issues exist?
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
4. **BEFORE COMMENTING** - For each potential comment, ask yourself:
- Is this a nitpick? Skip it unless explicitly requested.
- Would the codebase maintainer care about this feedback, based on what you can infer about the code quality standards in this repo?
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.
6. Add inline review comments one-by-one using ${ghPullfrogMcpName}/add_review_comment
5. 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")
7. Submit the review using ${ghPullfrogMcpName}/submit_review
6. 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)
**GENERAL GUIDANCE**
@@ -132,7 +136,7 @@ ${
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.
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
@@ -149,6 +153,9 @@ ${
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 +170,4 @@ ${
export const modes: Mode[] = getModes({
disableProgressComment: undefined,
dependenciesPreinstalled: undefined,
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/action",
"version": "0.0.147",
"version": "0.0.151",
"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);
+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);
+5 -5
View File
@@ -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());
},
+5 -6
View File
@@ -48,17 +48,16 @@ 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;
// Add timeout to prevent long waits (30 seconds)
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) {
+7 -28
View File
@@ -9,42 +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 environment variable must be specified (e.g. pullfrog/scratch)"
);
}
const cloneUrl = `git@github.com:${repo}.git`;
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 ${repo} into .temp...`);
$("git", ["clone", cloneUrl, tempDir]);
} else {
log.info("» resetting existing .temp repository...");
execSync("git reset --hard HEAD && git clean -fd", {
cwd: tempDir,
stdio: "inherit",
});
}
} else {
log.info(`» cloning ${repo} into .temp...`);
$("git", ["clone", cloneUrl, 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]);
}
/**
@@ -98,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();