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",
|
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
|
// Ensure API key is NOT in process.env - only pass via SDK's env option
|
||||||
delete process.env.ANTHROPIC_API_KEY;
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
const prompt = addInstructions({ payload, prepResults, repo });
|
const prompt = addInstructions({ payload, repo });
|
||||||
log.group("» Full prompt", () => log.info(prompt));
|
log.group("» Full prompt", () => log.info(prompt));
|
||||||
|
|
||||||
// configure sandbox mode if enabled
|
// configure sandbox mode if enabled
|
||||||
|
|||||||
+2
-2
@@ -20,7 +20,7 @@ export const codex = agent({
|
|||||||
executablePath: "bin/codex.js",
|
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
|
// create config directory for codex before setting HOME
|
||||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||||
const configDir = join(tempHome, ".config", "codex");
|
const configDir = join(tempHome, ".config", "codex");
|
||||||
@@ -61,7 +61,7 @@ export const codex = agent({
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const streamedTurn = await thread.runStreamed(addInstructions({ payload, prepResults, repo }));
|
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
|
||||||
|
|
||||||
let finalOutput = "";
|
let finalOutput = "";
|
||||||
for await (const event of streamedTurn.events) {
|
for await (const event of streamedTurn.events) {
|
||||||
|
|||||||
+2
-2
@@ -91,7 +91,7 @@ export const cursor = agent({
|
|||||||
executableName: "cursor-agent",
|
executableName: "cursor-agent",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
run: async ({ payload, apiKey, cliPath, mcpServers, prepResults, repo }) => {
|
run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => {
|
||||||
configureCursorMcpServers({ mcpServers, cliPath });
|
configureCursorMcpServers({ mcpServers, cliPath });
|
||||||
configureCursorSandbox({ sandbox: payload.sandbox ?? false });
|
configureCursorSandbox({ sandbox: payload.sandbox ?? false });
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ export const cursor = agent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fullPrompt = addInstructions({ payload, prepResults, repo });
|
const fullPrompt = addInstructions({ payload, repo });
|
||||||
log.group("» Full prompt", () => log.info(fullPrompt));
|
log.group("» Full prompt", () => log.info(fullPrompt));
|
||||||
|
|
||||||
// configure sandbox mode if enabled
|
// configure sandbox mode if enabled
|
||||||
|
|||||||
+2
-2
@@ -154,14 +154,14 @@ export const gemini = agent({
|
|||||||
...(githubInstallationToken && { githubInstallationToken }),
|
...(githubInstallationToken && { githubInstallationToken }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
run: async ({ payload, apiKey, mcpServers, cliPath, prepResults, repo }) => {
|
run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => {
|
||||||
configureGeminiMcpServers({ mcpServers, cliPath });
|
configureGeminiMcpServers({ mcpServers, cliPath });
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
|
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));
|
log.group("» Full prompt", () => log.info(sessionPrompt));
|
||||||
|
|
||||||
// configure sandbox mode if enabled
|
// 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 type { Payload } from "../external.ts";
|
||||||
import { ghPullfrogMcpName } from "../external.ts";
|
import { ghPullfrogMcpName } from "../external.ts";
|
||||||
import { getModes } from "../modes.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 {
|
interface RepoInfo {
|
||||||
owner: string;
|
owner: string;
|
||||||
@@ -72,15 +10,10 @@ interface RepoInfo {
|
|||||||
defaultBranch: string;
|
defaultBranch: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BuildRuntimeContextParams {
|
|
||||||
repo: RepoInfo;
|
|
||||||
prepResults: PrepResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build runtime context string with git status, repo data, and GitHub Actions variables
|
* 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[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
// working directory
|
// 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");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AddInstructionsParams {
|
interface AddInstructionsParams {
|
||||||
payload: Payload;
|
payload: Payload;
|
||||||
prepResults: PrepResult[];
|
|
||||||
repo: RepoInfo;
|
repo: RepoInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addInstructions = ({ payload, prepResults, repo }: AddInstructionsParams) => {
|
export const addInstructions = ({ payload, repo }: AddInstructionsParams) => {
|
||||||
let encodedEvent = "";
|
let encodedEvent = "";
|
||||||
|
|
||||||
const eventKeys = Object.keys(payload.event);
|
const eventKeys = Object.keys(payload.event);
|
||||||
@@ -143,8 +67,7 @@ export const addInstructions = ({ payload, prepResults, repo }: AddInstructionsP
|
|||||||
encodedEvent = toonEncode(payload.event);
|
encodedEvent = toonEncode(payload.event);
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeContext = buildRuntimeContext({ repo, prepResults });
|
const runtimeContext = buildRuntimeContext(repo);
|
||||||
const dependenciesPreinstalled = prepResults.every((r) => r.dependenciesInstalled) || undefined;
|
|
||||||
|
|
||||||
return (
|
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.
|
**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."
|
**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:
|
**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
|
### 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
|
### Following the mode instructions
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -21,7 +21,7 @@ export const opencode = agent({
|
|||||||
installDependencies: true,
|
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
|
// 1. configure home/config directory
|
||||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||||
const configDir = join(tempHome, ".config", "opencode");
|
const configDir = join(tempHome, ".config", "opencode");
|
||||||
@@ -29,7 +29,7 @@ export const opencode = agent({
|
|||||||
|
|
||||||
configureOpenCode({ mcpServers, sandbox: payload.sandbox ?? false });
|
configureOpenCode({ mcpServers, sandbox: payload.sandbox ?? false });
|
||||||
|
|
||||||
const prompt = addInstructions({ payload, prepResults, repo });
|
const prompt = addInstructions({ payload, repo });
|
||||||
log.group("» Full prompt", () => log.info(prompt));
|
log.group("» Full prompt", () => log.info(prompt));
|
||||||
|
|
||||||
// message positional must come right after "run", before flags
|
// 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 { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import type { show } from "@ark/util";
|
import type { show } from "@ark/util";
|
||||||
import { type AgentManifest, type AgentName, agentsManifest, type Payload } from "../external.ts";
|
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 { log } from "../utils/cli.ts";
|
||||||
import { getGitHubInstallationToken } from "../utils/github.ts";
|
import { getGitHubInstallationToken } from "../utils/github.ts";
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ export interface AgentConfig {
|
|||||||
payload: Payload;
|
payload: Payload;
|
||||||
mcpServers: Record<string, McpHttpServerConfig>;
|
mcpServers: Record<string, McpHttpServerConfig>;
|
||||||
cliPath: string;
|
cliPath: string;
|
||||||
prepResults: PrepResult[];
|
|
||||||
repo: RepoInfo;
|
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 { startMcpHttpServer } from "./mcp/server.ts";
|
||||||
import { getModes, type Mode, modes } from "./modes.ts";
|
import { getModes, type Mode, modes } from "./modes.ts";
|
||||||
import packageJson from "./package.json" with { type: "json" };
|
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 { fetchRepoSettings, fetchWorkflowRunInfo, type RepoSettings } from "./utils/api.ts";
|
||||||
import { log } from "./utils/cli.ts";
|
import { log } from "./utils/cli.ts";
|
||||||
import { reportErrorToComment } from "./utils/errorReport.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 };
|
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 toolState: ToolState = {};
|
||||||
const [prepResults, cliPath] = await Promise.all([
|
const [cliPath] = await Promise.all([
|
||||||
runPrepPhase(),
|
|
||||||
installAgentCli({ agent, token: githubSetup.token }),
|
installAgentCli({ agent, token: githubSetup.token }),
|
||||||
setupGitAuth({
|
setupGitAuth({
|
||||||
token: githubSetup.token,
|
token: githubSetup.token,
|
||||||
@@ -118,14 +117,12 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
toolState,
|
toolState,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
timer.checkpoint("prep+agentSetup+gitAuth");
|
timer.checkpoint("agentSetup+gitAuth");
|
||||||
|
|
||||||
// phase 6: compute modes (needs prep results)
|
// phase 6: compute modes
|
||||||
const dependenciesPreinstalled = prepResults.every((r) => r.dependenciesInstalled) || undefined;
|
|
||||||
const computedModes: Mode[] = [
|
const computedModes: Mode[] = [
|
||||||
...getModes({
|
...getModes({
|
||||||
disableProgressComment: resolvedPayload.disableProgressComment,
|
disableProgressComment: resolvedPayload.disableProgressComment,
|
||||||
dependenciesPreinstalled,
|
|
||||||
}),
|
}),
|
||||||
...(resolvedPayload.modes || []),
|
...(resolvedPayload.modes || []),
|
||||||
];
|
];
|
||||||
@@ -165,7 +162,6 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
repo: githubSetup.repo,
|
repo: githubSetup.repo,
|
||||||
repoSettings: githubSetup.repoSettings,
|
repoSettings: githubSetup.repoSettings,
|
||||||
modes: computedModes,
|
modes: computedModes,
|
||||||
prepResults,
|
|
||||||
toolState,
|
toolState,
|
||||||
agent,
|
agent,
|
||||||
sharedTempDir,
|
sharedTempDir,
|
||||||
@@ -199,9 +195,9 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
|||||||
Array.isArray(ctx.payload.event.comment_ids) &&
|
Array.isArray(ctx.payload.event.comment_ids) &&
|
||||||
ctx.payload.event.comment_ids.length === 0
|
ctx.payload.event.comment_ids.length === 0
|
||||||
) {
|
) {
|
||||||
await reportProgress(ctx, {
|
const noThumbsMessage = `👍 **No approved comments found**\n\nTo use "Fix 👍s", add a 👍 reaction to one or more inline review comments you want fixed.`;
|
||||||
body: `👍 **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 };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +311,6 @@ export interface ToolContext {
|
|||||||
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
||||||
repoSettings: RepoSettings;
|
repoSettings: RepoSettings;
|
||||||
modes: Mode[];
|
modes: Mode[];
|
||||||
prepResults: PrepResult[];
|
|
||||||
toolState: ToolState;
|
toolState: ToolState;
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
sharedTempDir: string;
|
sharedTempDir: string;
|
||||||
@@ -333,6 +328,12 @@ export interface AgentContext extends Readonly<ToolContext> {
|
|||||||
readonly apiKeys: Record<string, string>;
|
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 {
|
export interface ToolState {
|
||||||
prNumber?: number;
|
prNumber?: number;
|
||||||
issueNumber?: number;
|
issueNumber?: number;
|
||||||
@@ -340,6 +341,7 @@ export interface ToolState {
|
|||||||
id: number; // REST API database ID (for fix URLs)
|
id: number; // REST API database ID (for fix URLs)
|
||||||
nodeId: string; // GraphQL node ID (for mutations)
|
nodeId: string; // GraphQL node ID (for mutations)
|
||||||
};
|
};
|
||||||
|
dependencyInstallation?: DependencyInstallationState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -518,7 +520,6 @@ async function runAgent(ctx: AgentContext): Promise<AgentResult> {
|
|||||||
apiKey: ctx.apiKey,
|
apiKey: ctx.apiKey,
|
||||||
apiKeys: ctx.apiKeys,
|
apiKeys: ctx.apiKeys,
|
||||||
cliPath: ctx.cliPath,
|
cliPath: ctx.cliPath,
|
||||||
prepResults: ctx.prepResults,
|
|
||||||
repo: {
|
repo: {
|
||||||
owner: ctx.owner,
|
owner: ctx.owner,
|
||||||
name: ctx.name,
|
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 { Octokit } from "@octokit/rest";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "../main.ts";
|
||||||
@@ -19,6 +21,8 @@ export type CheckoutPrResult = {
|
|||||||
maintainerCanModify: boolean;
|
maintainerCanModify: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
headRepo: string;
|
headRepo: string;
|
||||||
|
diff: string;
|
||||||
|
diffPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CheckoutPrBranchParams {
|
interface CheckoutPrBranchParams {
|
||||||
@@ -60,12 +64,17 @@ export async function checkoutPrBranch(
|
|||||||
const baseBranch = pr.data.base.ref;
|
const baseBranch = pr.data.base.ref;
|
||||||
const headBranch = pr.data.head.ref;
|
const headBranch = pr.data.head.ref;
|
||||||
|
|
||||||
// check if we're already on the correct branch
|
// always use pr-{number} as local branch name for consistency
|
||||||
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim();
|
// this avoids naming conflicts and makes push config simpler
|
||||||
const alreadyOnBranch = currentBranch === headBranch;
|
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) {
|
if (alreadyOnBranch) {
|
||||||
log.debug(`already on PR branch ${headBranch}, skipping checkout`);
|
log.debug(`already on PR branch ${localBranch}, skipping checkout`);
|
||||||
} else {
|
} else {
|
||||||
// fetch base branch so origin/<base> exists for diff operations
|
// fetch base branch so origin/<base> exists for diff operations
|
||||||
log.debug(`📥 fetching base branch (${baseBranch})...`);
|
log.debug(`📥 fetching base branch (${baseBranch})...`);
|
||||||
@@ -76,11 +85,11 @@ export async function checkoutPrBranch(
|
|||||||
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
|
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
|
||||||
|
|
||||||
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
|
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
|
||||||
log.debug(`🌿 fetching PR #${pullNumber} (${headBranch})...`);
|
log.debug(`🌿 fetching PR #${pullNumber} (${localBranch})...`);
|
||||||
$("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${headBranch}`]);
|
$("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${localBranch}`]);
|
||||||
|
|
||||||
// checkout the branch
|
// checkout the branch
|
||||||
$("git", ["checkout", headBranch]);
|
$("git", ["checkout", localBranch]);
|
||||||
log.debug(`✓ checked out PR #${pullNumber}`);
|
log.debug(`✓ checked out PR #${pullNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,19 +107,21 @@ export async function checkoutPrBranch(
|
|||||||
const remoteName = `pr-${pullNumber}`;
|
const remoteName = `pr-${pullNumber}`;
|
||||||
const forkUrl = `https://x-access-token:${token}@github.com/${headRepo.full_name}.git`;
|
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 {
|
try {
|
||||||
$("git", ["remote", "add", remoteName, forkUrl]);
|
$("git", ["remote", "add", remoteName, forkUrl], { log: false });
|
||||||
log.debug(`📌 added remote '${remoteName}' for fork ${headRepo.full_name}`);
|
log.debug(`📌 added remote '${remoteName}' for fork ${headRepo.full_name}`);
|
||||||
} catch {
|
} catch {
|
||||||
// remote already exists, update its URL
|
// 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}`);
|
log.debug(`📌 updated remote '${remoteName}' for fork ${headRepo.full_name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// set branch push config so `git push` knows where to push
|
// set branch push config so `git push` knows where to push
|
||||||
$("git", ["config", `branch.${headBranch}.pushRemote`, remoteName]);
|
$("git", ["config", `branch.${localBranch}.pushRemote`, remoteName]);
|
||||||
log.debug(`📌 configured branch '${headBranch}' to push to '${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)
|
// warn if maintainer can't modify (push will likely fail)
|
||||||
if (!pr.data.maintainer_can_modify) {
|
if (!pr.data.maintainer_can_modify) {
|
||||||
@@ -121,7 +132,8 @@ export async function checkoutPrBranch(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// for same-repo PRs, push to origin
|
// 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 };
|
return { prNumber: pullNumber };
|
||||||
@@ -131,7 +143,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
|||||||
return tool({
|
return tool({
|
||||||
name: "checkout_pr",
|
name: "checkout_pr",
|
||||||
description:
|
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,
|
parameters: CheckoutPr,
|
||||||
execute: execute(async ({ pull_number }) => {
|
execute: execute(async ({ pull_number }) => {
|
||||||
const result = await checkoutPrBranch({
|
const result = await checkoutPrBranch({
|
||||||
@@ -157,6 +170,27 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
|||||||
throw new Error(`PR #${pull_number} source repository was deleted`);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
number: pr.data.number,
|
number: pr.data.number,
|
||||||
@@ -167,6 +201,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
|||||||
maintainerCanModify: pr.data.maintainer_can_modify,
|
maintainerCanModify: pr.data.maintainer_can_modify,
|
||||||
url: pr.data.html_url,
|
url: pr.data.html_url,
|
||||||
headRepo: headRepo.full_name,
|
headRepo: headRepo.full_name,
|
||||||
|
diff,
|
||||||
|
diffPath,
|
||||||
} satisfies CheckoutPrResult;
|
} satisfies CheckoutPrResult;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-5
@@ -275,11 +275,20 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.octokit.rest.issues.deleteComment({
|
try {
|
||||||
owner: ctx.owner,
|
await ctx.octokit.rest.issues.deleteComment({
|
||||||
repo: ctx.name,
|
owner: ctx.owner,
|
||||||
comment_id: existingCommentId,
|
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
|
// reset state but mark as "updated" so ensureProgressCommentUpdated doesn't try to handle it
|
||||||
progressCommentId = null;
|
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
|
// no configured pushRemote, default to origin
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = force
|
// check if branch has a configured merge ref (remote branch name may differ from local)
|
||||||
? ["push", "--force", "-u", remote, branch]
|
let remoteBranch = branch;
|
||||||
: ["push", "-u", remote, 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) {
|
if (force) {
|
||||||
log.warning(`force pushing - this will overwrite remote history`);
|
log.warning(`force pushing - this will overwrite remote history`);
|
||||||
}
|
}
|
||||||
@@ -171,9 +183,10 @@ export function PushBranchTool(_ctx: ToolContext) {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
branch,
|
branch,
|
||||||
|
remoteBranch,
|
||||||
remote,
|
remote,
|
||||||
force,
|
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 { RestEndpointMethodTypes } from "@octokit/rest";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import type { ToolContext } from "../main.ts";
|
import type { ToolContext } from "../main.ts";
|
||||||
@@ -65,7 +62,7 @@ export function StartReviewTool(ctx: ToolContext) {
|
|||||||
return tool({
|
return tool({
|
||||||
name: "start_review",
|
name: "start_review",
|
||||||
description:
|
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,
|
parameters: StartReview,
|
||||||
execute: execute(async ({ pull_number }) => {
|
execute: execute(async ({ pull_number }) => {
|
||||||
// check if review already started in this session
|
// check if review already started in this session
|
||||||
@@ -95,6 +92,13 @@ export function StartReviewTool(ctx: ToolContext) {
|
|||||||
commit_id: pr.data.head.sha,
|
commit_id: pr.data.head.sha,
|
||||||
// no 'event' = PENDING review
|
// 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;
|
reviewId = result.data.id;
|
||||||
reviewNodeId = result.data.node_id;
|
reviewNodeId = result.data.node_id;
|
||||||
log.debug(`created new pending review: id=${reviewId}`);
|
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
|
// set PR context and review state
|
||||||
ctx.toolState.prNumber = pull_number;
|
ctx.toolState.prNumber = pull_number;
|
||||||
ctx.toolState.review = {
|
ctx.toolState.review = {
|
||||||
@@ -132,10 +130,10 @@ export function StartReviewTool(ctx: ToolContext) {
|
|||||||
id: reviewId,
|
id: reviewId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
log.debug(`review session started: id=${reviewId}, nodeId=${reviewNodeId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reviewId: scratchpadId,
|
message: `Review session started for PR #${pull_number}. Add comments with add_review_comment, then submit with submit_review.`,
|
||||||
scratchpadPath,
|
|
||||||
message: `Review session started. Use the scratchpad file to gather your thoughts, then call add_review_comment for each comment.`,
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -166,21 +164,58 @@ export function AddReviewCommentTool(ctx: ToolContext) {
|
|||||||
throw new Error("No review session started. Call start_review first.");
|
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)
|
const reviewNodeId = ctx.toolState.review.nodeId;
|
||||||
await ctx.octokit.graphql<AddPullRequestReviewThreadResponse>(
|
log.debug(
|
||||||
ADD_PULL_REQUEST_REVIEW_THREAD,
|
`adding review comment: reviewNodeId=${reviewNodeId}, path=${path}, line=${line}, side=${side || "RIGHT"}`
|
||||||
{
|
|
||||||
pullRequestReviewId: ctx.toolState.review.nodeId,
|
|
||||||
path,
|
|
||||||
line,
|
|
||||||
body,
|
|
||||||
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Comment added to ${path}:${line}`,
|
message: `Comment added to ${path}:${line}`,
|
||||||
|
threadId,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -211,6 +246,9 @@ export function SubmitReviewTool(ctx: ToolContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reviewId = ctx.toolState.review.id;
|
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
|
// build quick links footer
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
@@ -234,6 +272,12 @@ export function SubmitReviewTool(ctx: ToolContext) {
|
|||||||
body: bodyWithFooter,
|
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
|
// clear review state
|
||||||
delete ctx.toolState.review;
|
delete ctx.toolState.review;
|
||||||
|
|
||||||
@@ -281,9 +325,8 @@ export const Review = type({
|
|||||||
})
|
})
|
||||||
.array()
|
.array()
|
||||||
.describe(
|
.describe(
|
||||||
// FORK PR NOTE: use HEAD not origin/<head> - for fork PRs, origin/<head> doesn't exist
|
// FORK PR NOTE: checkout_pr returns the diff via GitHub API - use that for line numbers
|
||||||
// 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 the diff returned from checkout_pr to find correct line numbers (RIGHT side for new code, LEFT for old)."
|
||||||
"PRIMARY location for ALL feedback. 95%+ of review content should be here. Use 'git diff origin/<base>..HEAD' to find correct line numbers (RIGHT side for new code, LEFT for old). Works for both fork and same-repo PRs."
|
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
@@ -337,6 +380,10 @@ export function ReviewTool(ctx: ToolContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const result = await ctx.octokit.rest.pulls.createReview(params);
|
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;
|
const reviewId = result.data.id;
|
||||||
|
|
||||||
// build quick links footer and update the review body
|
// build quick links footer and update the review body
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
ReportProgressTool,
|
ReportProgressTool,
|
||||||
} from "./comment.ts";
|
} from "./comment.ts";
|
||||||
import { DebugShellCommandTool } from "./debug.ts";
|
import { DebugShellCommandTool } from "./debug.ts";
|
||||||
|
import {
|
||||||
|
AwaitDependencyInstallationTool,
|
||||||
|
StartDependencyInstallationTool,
|
||||||
|
} from "./dependencies.ts";
|
||||||
import { ListFilesTool } from "./files.ts";
|
import { ListFilesTool } from "./files.ts";
|
||||||
import { CommitFilesTool, CreateBranchTool, PushBranchTool } from "./git.ts";
|
import { CommitFilesTool, CreateBranchTool, PushBranchTool } from "./git.ts";
|
||||||
import { IssueTool } from "./issue.ts";
|
import { IssueTool } from "./issue.ts";
|
||||||
@@ -70,6 +74,8 @@ export async function startMcpHttpServer(
|
|||||||
// create all tools as factories, passing ctx
|
// create all tools as factories, passing ctx
|
||||||
const tools: Tool<any, any>[] = [
|
const tools: Tool<any, any>[] = [
|
||||||
SelectModeTool(ctx),
|
SelectModeTool(ctx),
|
||||||
|
StartDependencyInstallationTool(ctx),
|
||||||
|
AwaitDependencyInstallationTool(ctx),
|
||||||
CreateCommentTool(ctx),
|
CreateCommentTool(ctx),
|
||||||
EditCommentTool(ctx),
|
EditCommentTool(ctx),
|
||||||
ReplyToReviewCommentTool(ctx),
|
ReplyToReviewCommentTool(ctx),
|
||||||
|
|||||||
@@ -8,28 +8,33 @@ export interface Mode {
|
|||||||
|
|
||||||
export interface GetModesParams {
|
export interface GetModesParams {
|
||||||
disableProgressComment: true | undefined;
|
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.`;
|
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({
|
const dependencyInstallationGuidance = `## Dependency Installation
|
||||||
disableProgressComment,
|
|
||||||
dependenciesPreinstalled,
|
|
||||||
}: GetModesParams): Mode[] {
|
|
||||||
const depsContext = dependenciesPreinstalled
|
|
||||||
? "Dependencies have already been installed."
|
|
||||||
: "understand how to install dependencies,";
|
|
||||||
|
|
||||||
|
**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 [
|
return [
|
||||||
{
|
{
|
||||||
name: "Build",
|
name: "Build",
|
||||||
description:
|
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",
|
"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:
|
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
|
3. Understand the requirements and any existing plan
|
||||||
|
|
||||||
@@ -66,11 +71,13 @@ export function getModes({
|
|||||||
prompt: `Follow these steps:
|
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).
|
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.
|
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.
|
- **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.
|
- 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.
|
4. Make the necessary code changes to address the feedback. Work through each review comment systematically.
|
||||||
|
|
||||||
@@ -93,29 +100,26 @@ ${
|
|||||||
description:
|
description:
|
||||||
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
||||||
prompt: `Follow these steps:
|
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:
|
4. **BEFORE COMMENTING** - For each potential comment, ask yourself:
|
||||||
- Summarize what changes this PR makes
|
- Is this a nitpick? Skip it unless explicitly requested.
|
||||||
- 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.
|
- Would the codebase maintainer care about this feedback, based on what you can infer about the code quality standards in this repo?
|
||||||
- If approach is sound, analyze implementation - consider potential issues per file
|
|
||||||
- Identify bugs, security issues, edge cases
|
|
||||||
|
|
||||||
5. **SELF-CRITIQUE** - Before adding comments, review your scratchpad:
|
5. Add inline review comments one-by-one using ${ghPullfrogMcpName}/add_review_comment
|
||||||
- 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
|
|
||||||
- Use **relative paths** from repo root (e.g., \`packages/core/src/utils.ts\`)
|
- 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)
|
- 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.
|
- 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")
|
- 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)
|
- 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**
|
**GENERAL GUIDANCE**
|
||||||
@@ -132,7 +136,7 @@ ${
|
|||||||
description:
|
description:
|
||||||
"Create plans, break down tasks, outline steps, analyze requirements, understand scope of work, or provide task breakdowns",
|
"Create plans, break down tasks, outline steps, analyze requirements, understand scope of work, or provide task breakdowns",
|
||||||
prompt: `Follow these steps:
|
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
|
2. Analyze the request and break it down into clear, actionable tasks
|
||||||
|
|
||||||
@@ -149,6 +153,9 @@ ${
|
|||||||
|
|
||||||
2. If the task involves making code changes:
|
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.
|
- 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 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.
|
- 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.
|
- Test your changes to ensure they work correctly.
|
||||||
@@ -163,5 +170,4 @@ ${
|
|||||||
|
|
||||||
export const modes: Mode[] = getModes({
|
export const modes: Mode[] = getModes({
|
||||||
disableProgressComment: undefined,
|
disableProgressComment: undefined,
|
||||||
dependenciesPreinstalled: undefined,
|
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pullfrog/action",
|
"name": "@pullfrog/action",
|
||||||
"version": "0.0.147",
|
"version": "0.0.152",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
"index.js",
|
"index.js",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ config({ path: join(process.cwd(), "..", ".env") });
|
|||||||
export async function run(prompt: string): Promise<AgentResult> {
|
export async function run(prompt: string): Promise<AgentResult> {
|
||||||
try {
|
try {
|
||||||
const tempDir = join(process.cwd(), ".temp");
|
const tempDir = join(process.cwd(), ".temp");
|
||||||
setupTestRepo({ tempDir, forceClean: true });
|
setupTestRepo({ tempDir });
|
||||||
|
|
||||||
const originalCwd = process.cwd();
|
const originalCwd = process.cwd();
|
||||||
process.chdir(tempDir);
|
process.chdir(tempDir);
|
||||||
|
|||||||
+4
-4
@@ -36,8 +36,8 @@ export interface WorkflowRunInfo {
|
|||||||
export async function fetchWorkflowRunInfo(runId: string): Promise<WorkflowRunInfo> {
|
export async function fetchWorkflowRunInfo(runId: string): Promise<WorkflowRunInfo> {
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
|
|
||||||
// add timeout to prevent hanging (5 seconds)
|
// add timeout to prevent hanging (30 seconds)
|
||||||
const timeoutMs = 5000;
|
const timeoutMs = 30000;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
@@ -90,8 +90,8 @@ export async function getRepoSettings(
|
|||||||
): Promise<RepoSettings> {
|
): Promise<RepoSettings> {
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||||
|
|
||||||
// Add timeout to prevent hanging (5 seconds)
|
// Add timeout to prevent hanging (30 seconds)
|
||||||
const timeoutMs = 5000;
|
const timeoutMs = 30000;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -278,7 +278,7 @@ export const log = {
|
|||||||
/**
|
/**
|
||||||
* Print debug message (only if LOG_LEVEL=debug)
|
* Print debug message (only if LOG_LEVEL=debug)
|
||||||
*/
|
*/
|
||||||
debug: (message: string): void => {
|
debug: (message: string | unknown): void => {
|
||||||
if (isDebugEnabled()) {
|
if (isDebugEnabled()) {
|
||||||
if (isGitHubActions) {
|
if (isGitHubActions) {
|
||||||
// using this instead of core.debug
|
// using this instead of core.debug
|
||||||
@@ -341,12 +341,12 @@ export const log = {
|
|||||||
* Log tool call information to console with formatted output
|
* Log tool call information to console with formatted output
|
||||||
*/
|
*/
|
||||||
toolCall: ({ toolName, input }: { toolName: string; input: unknown }): void => {
|
toolCall: ({ toolName, input }: { toolName: string; input: unknown }): void => {
|
||||||
let output = `→ ${toolName}\n`;
|
|
||||||
|
|
||||||
const inputFormatted = formatJsonValue(input);
|
const inputFormatted = formatJsonValue(input);
|
||||||
if (inputFormatted !== "{}") {
|
const timestamp = isDebugEnabled() ? ` [${new Date().toISOString()}]` : "";
|
||||||
output += formatIndentedField("input", inputFormatted);
|
const output =
|
||||||
}
|
inputFormatted !== "{}"
|
||||||
|
? `→ ${toolName}(${inputFormatted})${timestamp}`
|
||||||
|
: `→ ${toolName}()${timestamp}`;
|
||||||
|
|
||||||
log.info(output.trimEnd());
|
log.info(output.trimEnd());
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-7
@@ -1,6 +1,7 @@
|
|||||||
import { createSign } from "node:crypto";
|
import { createSign } from "node:crypto";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { log } from "./cli.ts";
|
import { log } from "./cli.ts";
|
||||||
|
import { retry } from "./retry.ts";
|
||||||
|
|
||||||
export interface InstallationToken {
|
export interface InstallationToken {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -48,17 +49,15 @@ function isGitHubActionsEnvironment(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function acquireTokenViaOIDC(): Promise<string> {
|
async function acquireTokenViaOIDC(): Promise<string> {
|
||||||
log.debug("» generating OIDC token...");
|
log.info("» generating OIDC token...");
|
||||||
|
|
||||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||||
log.debug("» OIDC token generated successfully");
|
|
||||||
|
|
||||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
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 = 30000;
|
||||||
const timeoutMs = 5000;
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ async function acquireTokenViaOIDC(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tokenData = (await tokenResponse.json()) as InstallationToken;
|
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;
|
return tokenData.token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -235,7 +234,7 @@ async function acquireTokenViaGitHubApp(): Promise<string> {
|
|||||||
|
|
||||||
async function acquireNewToken(): Promise<string> {
|
async function acquireNewToken(): Promise<string> {
|
||||||
if (isGitHubActionsEnvironment()) {
|
if (isGitHubActionsEnvironment()) {
|
||||||
return await acquireTokenViaOIDC();
|
return await retry(() => acquireTokenViaOIDC(), { label: "token exchange" });
|
||||||
} else {
|
} else {
|
||||||
return await acquireTokenViaGitHubApp();
|
return await acquireTokenViaGitHubApp();
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-28
@@ -9,42 +9,21 @@ import { $ } from "./shell.ts";
|
|||||||
|
|
||||||
export interface SetupOptions {
|
export interface SetupOptions {
|
||||||
tempDir: string;
|
tempDir: string;
|
||||||
forceClean?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup the test repository for running actions
|
* Setup the test repository for running actions
|
||||||
*/
|
*/
|
||||||
export function setupTestRepo(options: SetupOptions): void {
|
export function setupTestRepo(options: SetupOptions): void {
|
||||||
const { tempDir, forceClean = false } = options;
|
const { tempDir } = options;
|
||||||
|
|
||||||
const repo = process.env.GITHUB_REPOSITORY;
|
const repo = process.env.GITHUB_REPOSITORY;
|
||||||
if (!repo) {
|
if (!repo) throw new Error("GITHUB_REPOSITORY is required");
|
||||||
throw new Error(
|
|
||||||
"GITHUB_REPOSITORY environment variable must be specified (e.g. pullfrog/scratch)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cloneUrl = `git@github.com:${repo}.git`;
|
|
||||||
|
|
||||||
if (existsSync(tempDir)) {
|
if (existsSync(tempDir)) {
|
||||||
if (forceClean) {
|
log.info("» removing existing .temp directory...");
|
||||||
log.info("» removing existing .temp directory...");
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
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(`» cloning ${repo} into .temp...`);
|
||||||
|
$("git", ["clone", `git@github.com:${repo}.git`, tempDir]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,7 +77,7 @@ interface SetupGitAuthParams {
|
|||||||
* FORK PR ARCHITECTURE:
|
* FORK PR ARCHITECTURE:
|
||||||
* - origin: always points to BASE REPO (where PR targets)
|
* - origin: always points to BASE REPO (where PR targets)
|
||||||
* - checkoutPrBranch sets per-branch pushRemote config for fork PRs
|
* - 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> {
|
export async function setupGitAuth(params: SetupGitAuthParams): Promise<void> {
|
||||||
const repoDir = process.cwd();
|
const repoDir = process.cwd();
|
||||||
|
|||||||
Reference in New Issue
Block a user