Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5353d80388 | |||
| 2dea842981 | |||
| 04c695038f | |||
| e9a585ce47 | |||
| 7407b6cbc5 | |||
| 507efb0c25 | |||
| 6d572f3ce8 | |||
| 73139a169c | |||
| d5bec7499b | |||
| b33deb1b5a | |||
| 5034ff8285 |
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
run pnpm --version and print the output
|
||||
add a file implementing quicksort and test it
|
||||
|
||||
@@ -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,
|
||||
|
||||
+50
-14
@@ -1,3 +1,5 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import type { ToolContext } from "../main.ts";
|
||||
@@ -19,6 +21,8 @@ export type CheckoutPrResult = {
|
||||
maintainerCanModify: boolean;
|
||||
url: string;
|
||||
headRepo: string;
|
||||
diff: string;
|
||||
diffPath: string;
|
||||
};
|
||||
|
||||
interface CheckoutPrBranchParams {
|
||||
@@ -60,12 +64,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 +85,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 +107,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 +132,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 +143,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. " +
|
||||
"The PR diff is written to a file (diffPath) for grep access. For small diffs, it's also returned inline.",
|
||||
parameters: CheckoutPr,
|
||||
execute: execute(async ({ pull_number }) => {
|
||||
const result = await checkoutPrBranch({
|
||||
@@ -157,6 +170,27 @@ 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" },
|
||||
});
|
||||
|
||||
// write diff to file for grep access
|
||||
const diffContent = diffResponse.data as unknown as string;
|
||||
const diffPath = join(process.env.PULLFROG_TEMP_DIR!, `pr-${pull_number}.diff`);
|
||||
writeFileSync(diffPath, diffContent);
|
||||
log.debug(`wrote diff to ${diffPath} (${diffContent.length} bytes)`);
|
||||
|
||||
// return diff inline only if small enough
|
||||
const MAX_INLINE_DIFF_SIZE = 50 * 1024; // 50KB
|
||||
const diff =
|
||||
diffContent.length <= MAX_INLINE_DIFF_SIZE
|
||||
? diffContent
|
||||
: `Diff is ${(diffContent.length / 1024).toFixed(0)}KB - use grep or read from ${diffPath}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
number: pr.data.number,
|
||||
@@ -167,6 +201,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
maintainerCanModify: pr.data.maintainer_can_modify,
|
||||
url: pr.data.html_url,
|
||||
headRepo: headRepo.full_name,
|
||||
diff,
|
||||
diffPath,
|
||||
} satisfies CheckoutPrResult;
|
||||
}),
|
||||
});
|
||||
|
||||
+14
-5
@@ -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;
|
||||
|
||||
@@ -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
@@ -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}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
+73
-26
@@ -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
|
||||
@@ -95,6 +92,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 +123,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 +130,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 +164,58 @@ 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",
|
||||
}
|
||||
);
|
||||
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 +246,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 +272,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;
|
||||
|
||||
@@ -281,9 +325,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(),
|
||||
});
|
||||
@@ -337,6 +380,10 @@ export function ReviewTool(ctx: ToolContext) {
|
||||
});
|
||||
}
|
||||
const result = await ctx.octokit.rest.pulls.createReview(params);
|
||||
log.debug(`createReview (legacy) 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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pullfrog/action",
|
||||
"version": "0.0.147",
|
||||
"version": "0.0.152",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"index.js",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
+7
-28
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user