Curate context and switch to file-based review comments

This commit is contained in:
Colin McDonnell
2026-01-16 19:36:35 +00:00
committed by pullfrog[bot]
parent 69b9b96ddd
commit c3c0794504
4 changed files with 238 additions and 329 deletions
+125 -139
View File
@@ -119135,15 +119135,18 @@ var CreatePullRequestReview = type({
comments: type({ comments: type({
path: type.string.describe("The file path to comment on (relative to repo root)"), path: type.string.describe("The file path to comment on (relative to repo root)"),
line: type.number.describe( line: type.number.describe(
"Line number from the diff. Each code line shows 'OLD | NEW | TYPE | CODE'. Use the NEW column (second column)." "End line of the comment range (or the only line if no start_line). From diff format 'OLD | NEW | TYPE | CODE', use the NEW column."
), ),
side: type.enumerated("LEFT", "RIGHT").describe( side: type.enumerated("LEFT", "RIGHT").describe(
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT." "Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT."
).optional(), ).optional(),
body: type.string.describe( body: type.string.describe("Explanatory comment text (optional if suggestion is provided)").optional(),
"The comment text for this specific line. For issues appearing multiple times, comment on the first occurrence and reference others. When providing code suggestions, use GitHub's suggestion format with ```suggestion blocks to enable one-click apply. Only include explanatory text if the suggested code requires clarification." suggestion: type.string.describe(
), "Full replacement code for the line range. Replaces the commented lines entirely - can be more, fewer, or same number of lines."
start_line: type.number.describe("Start line for multi-line comments (optional, for commenting on ranges)").optional() ).optional(),
start_line: type.number.describe(
"Start line for multi-line comments/suggestions. The range [start_line, line] defines which lines get replaced."
).optional()
}).array().describe( }).array().describe(
"Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead." "Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead."
).optional() ).optional()
@@ -119151,7 +119154,7 @@ var CreatePullRequestReview = type({
function CreatePullRequestReviewTool(ctx) { function CreatePullRequestReviewTool(ctx) {
return tool({ return tool({
name: "create_pull_request_review", name: "create_pull_request_review",
description: "Submit a review for an existing pull request. IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. When suggesting code changes in comments, use GitHub's suggestion format (```suggestion blocks) to enable one-click apply.", description: "Submit a review for an existing pull request. IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. Use 'suggestion' field to suggest a FULL REPLACEMENT of the associated line range. This will show up for users as a one-click apply suggestion. It doesn't need to be the same length as the original range, but the suggested code must work when applied.",
parameters: CreatePullRequestReview, parameters: CreatePullRequestReview,
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => { execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
ctx.toolState.prNumber = pull_number; ctx.toolState.prNumber = pull_number;
@@ -119174,8 +119177,15 @@ function CreatePullRequestReviewTool(ctx) {
} }
if (comments.length > 0) { if (comments.length > 0) {
params.comments = comments.map((comment) => { params.comments = comments.map((comment) => {
let commentBody = comment.body || "";
if (comment.suggestion !== void 0) {
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
}
const reviewComment = { const reviewComment = {
...comment path: comment.path,
line: comment.line,
body: commentBody
}; };
reviewComment.side = comment.side || "RIGHT"; reviewComment.side = comment.side || "RIGHT";
if (comment.start_line) { if (comment.start_line) {
@@ -119195,7 +119205,12 @@ function CreatePullRequestReviewTool(ctx) {
const fixAllUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix&review_id=${reviewId}`; const fixAllUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix&review_id=${reviewId}`;
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`; const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
workflowRun: { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }, workflowRun: {
owner: ctx.repo.owner,
repo: ctx.repo.name,
runId: ctx.runId,
jobId: ctx.jobId
},
customParts: [`[Fix all \u2794](${fixAllUrl})`, `[Fix \u{1F44D}s \u2794](${fixApprovedUrl})`] customParts: [`[Fix all \u2794](${fixAllUrl})`, `[Fix \u{1F44D}s \u2794](${fixApprovedUrl})`]
}); });
const updatedBody = (body || "") + footer; const updatedBody = (body || "") + footer;
@@ -119220,109 +119235,82 @@ function CreatePullRequestReviewTool(ctx) {
} }
// mcp/reviewComments.ts // mcp/reviewComments.ts
var REVIEW_THREADS_QUERY = ` import { writeFileSync as writeFileSync2 } from "node:fs";
query ($owner: String!, $repo: String!, $pullNumber: Int!) { import { join as join4 } from "node:path";
repository(owner: $owner, name: $repo) {
pullRequest(number: $pullNumber) {
reviewThreads(first: 100) {
nodes {
diffSide
startDiffSide
comments(first: 100) {
nodes {
id
databaseId
body
path
line
startLine
url
author {
login
}
createdAt
updatedAt
pullRequestReview {
databaseId
}
replyTo {
databaseId
}
}
}
}
}
}
}
}
`;
var GetReviewComments = type({ var GetReviewComments = type({
pull_number: type.number.describe("The pull request number"), pull_number: type.number.describe("The pull request number"),
review_id: type.number.describe("The review ID to get comments for") review_id: type.number.describe("The review ID to get comments for"),
approved_by: type.string.describe("Optional GitHub username - only return comments this user gave a \u{1F44D} to").optional()
}); });
function GetReviewCommentsTool(ctx) { function GetReviewCommentsTool(ctx) {
return tool({ return tool({
name: "get_review_comments", name: "get_review_comments",
description: "Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.", description: "Get review comments for a pull request review, including diff context. When approved_by is provided, only returns comments that user approved with \u{1F44D}. Returns commentsPath pointing to a file with full comment details.",
parameters: GetReviewComments, parameters: GetReviewComments,
execute: execute(async ({ pull_number, review_id }) => { execute: execute(async ({ pull_number, review_id, approved_by }) => {
const response = await ctx.octokit.graphql(REVIEW_THREADS_QUERY, { const allComments = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviewComments, {
owner: ctx.repo.owner, owner: ctx.repo.owner,
repo: ctx.repo.name, repo: ctx.repo.name,
pullNumber: pull_number pull_number
}); });
const pullRequest = response.repository?.pullRequest; let reviewComments = allComments.filter((c) => c.pull_request_review_id === review_id);
if (!pullRequest) { if (approved_by) {
return { const approvedIds = /* @__PURE__ */ new Set();
review_id, for (const comment of reviewComments) {
pull_number, const reactions = await ctx.octokit.rest.reactions.listForPullRequestReviewComment({
comments: [], owner: ctx.repo.owner,
count: 0 repo: ctx.repo.name,
}; comment_id: comment.id
} });
const threadNodes = pullRequest.reviewThreads?.nodes; const hasThumbsUp = reactions.data.some(
if (!threadNodes) { (r) => r.content === "+1" && r.user?.login === approved_by
return {
review_id,
pull_number,
comments: [],
count: 0
};
}
const allComments = [];
for (const thread of threadNodes) {
if (!thread?.comments?.nodes) continue;
const threadComments = thread.comments.nodes.filter(
(c) => c !== null
); );
if (threadComments.length === 0) continue; if (hasThumbsUp) approvedIds.add(comment.id);
const rootComment = threadComments.find((c) => c.replyTo === null);
if (!rootComment) continue;
const threadBelongsToReview = rootComment.pullRequestReview?.databaseId === review_id;
if (!threadBelongsToReview) continue;
for (const comment of threadComments) {
allComments.push({
id: comment.databaseId,
body: comment.body,
path: comment.path,
line: comment.line,
start_line: comment.startLine,
side: thread.diffSide,
start_side: thread.startDiffSide,
user: comment.author?.login ?? null,
created_at: comment.createdAt,
updated_at: comment.updatedAt,
html_url: comment.url,
in_reply_to_id: comment.replyTo?.databaseId ?? null,
pull_request_review_id: comment.pullRequestReview?.databaseId ?? null
});
} }
reviewComments = reviewComments.filter((c) => approvedIds.has(c.id));
} }
if (reviewComments.length === 0) {
return { return {
review_id, review_id,
pull_number, pull_number,
comments: allComments, count: 0,
count: allComments.length commentsPath: null,
message: approved_by ? `No comments with \u{1F44D} from ${approved_by}` : "No comments found for this review"
};
}
const lines = [];
for (const comment of reviewComments) {
lines.push(`${"=".repeat(60)}`);
lines.push(`COMMENT #${comment.id} by @${comment.user?.login ?? "unknown"}`);
lines.push(`File: ${comment.path}:${comment.line ?? comment.original_line ?? "?"}`);
if (comment.in_reply_to_id) {
lines.push(`Reply to: #${comment.in_reply_to_id}`);
}
lines.push("");
if (comment.diff_hunk) {
lines.push("```diff");
lines.push(comment.diff_hunk);
lines.push("```");
lines.push("");
}
lines.push("Comment:");
lines.push(comment.body);
lines.push("");
}
const content = lines.join("\n");
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) {
throw new Error("PULLFROG_TEMP_DIR not set");
}
const filename = approved_by ? `review-${review_id}-approved-by-${approved_by}.txt` : `review-${review_id}-comments.txt`;
const commentsPath = join4(tempDir, filename);
writeFileSync2(commentsPath, content);
log.debug(`wrote ${reviewComments.length} comments to ${commentsPath}`);
return {
review_id,
pull_number,
count: reviewComments.length,
commentsPath
}; };
}) })
}); });
@@ -119348,9 +119336,7 @@ function ListPullRequestReviewsTool(ctx) {
body: review.body, body: review.body,
state: review.state, state: review.state,
user: review.user?.login, user: review.user?.login,
commit_id: review.commit_id, submitted_at: review.submitted_at
submitted_at: review.submitted_at,
html_url: review.html_url
})), })),
count: reviews.length count: reviews.length
}; };
@@ -119631,7 +119617,7 @@ import { spawn as spawn3 } from "child_process";
import { createInterface } from "readline"; import { createInterface } from "readline";
import * as fs2 from "fs"; import * as fs2 from "fs";
import { stat as statPromise, open } from "fs/promises"; import { stat as statPromise, open } from "fs/promises";
import { join as join4 } from "path"; import { join as join6 } from "path";
import { homedir } from "os"; import { homedir } from "os";
import { dirname, join as join22 } from "path"; import { dirname, join as join22 } from "path";
import { cwd } from "process"; import { cwd } from "process";
@@ -126333,7 +126319,7 @@ function shouldShowDebugMessage(message, filter) {
return shouldShowDebugCategories(categories, filter); return shouldShowDebugCategories(categories, filter);
} }
function getClaudeConfigHomeDir() { function getClaudeConfigHomeDir() {
return process.env.CLAUDE_CONFIG_DIR ?? join4(homedir(), ".claude"); return process.env.CLAUDE_CONFIG_DIR ?? join6(homedir(), ".claude");
} }
function isEnvTruthy(envVar) { function isEnvTruthy(envVar) {
if (!envVar) if (!envVar)
@@ -136238,7 +136224,7 @@ import { spawnSync as spawnSync2 } from "node:child_process";
import { chmodSync, createWriteStream as createWriteStream2, existsSync as existsSync4 } from "node:fs"; import { chmodSync, createWriteStream as createWriteStream2, existsSync as existsSync4 } from "node:fs";
import { mkdtemp } from "node:fs/promises"; import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join as join6 } from "node:path"; import { join as join7 } from "node:path";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
async function installFromNpmTarball(params) { async function installFromNpmTarball(params) {
let resolvedVersion = params.version; let resolvedVersion = params.version;
@@ -136262,7 +136248,7 @@ async function installFromNpmTarball(params) {
} }
log.debug(`\xBB installing ${params.packageName}@${resolvedVersion}...`); log.debug(`\xBB installing ${params.packageName}@${resolvedVersion}...`);
const tempDir = process.env.PULLFROG_TEMP_DIR; const tempDir = process.env.PULLFROG_TEMP_DIR;
const tarballPath = join6(tempDir, "package.tgz"); const tarballPath = join7(tempDir, "package.tgz");
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org"; const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
let tarballUrl; let tarballUrl;
if (params.packageName.startsWith("@")) { if (params.packageName.startsWith("@")) {
@@ -136291,8 +136277,8 @@ async function installFromNpmTarball(params) {
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}` `Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
); );
} }
const extractedDir = join6(tempDir, "package"); const extractedDir = join7(tempDir, "package");
const cliPath = join6(extractedDir, params.executablePath); const cliPath = join7(extractedDir, params.executablePath);
if (!existsSync4(cliPath)) { if (!existsSync4(cliPath)) {
throw new Error(`Executable not found in extracted package at ${cliPath}`); throw new Error(`Executable not found in extracted package at ${cliPath}`);
} }
@@ -136354,10 +136340,10 @@ async function installFromGithub(params) {
const assetUrl = asset.browser_download_url; const assetUrl = asset.browser_download_url;
log.debug(`\xBB downloading asset from ${assetUrl}...`); log.debug(`\xBB downloading asset from ${assetUrl}...`);
const tempDirPrefix = `${params.owner}-${params.repo}-github-`; const tempDirPrefix = `${params.owner}-${params.repo}-github-`;
const tempDirPath = await mkdtemp(join6(tmpdir(), tempDirPrefix)); const tempDirPath = await mkdtemp(join7(tmpdir(), tempDirPrefix));
const urlPath = new URL(assetUrl).pathname; const urlPath = new URL(assetUrl).pathname;
const fileName3 = urlPath.split("/").pop() || "asset"; const fileName3 = urlPath.split("/").pop() || "asset";
const downloadPath = join6(tempDirPath, fileName3); const downloadPath = join7(tempDirPath, fileName3);
const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset"); const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset");
if (!assetResponse.body) throw new Error("Response body is null"); if (!assetResponse.body) throw new Error("Response body is null");
const fileStream = createWriteStream2(downloadPath); const fileStream = createWriteStream2(downloadPath);
@@ -136365,7 +136351,7 @@ async function installFromGithub(params) {
log.debug(`\xBB downloaded asset to ${downloadPath}`); log.debug(`\xBB downloaded asset to ${downloadPath}`);
let cliPath; let cliPath;
if (params.executablePath) { if (params.executablePath) {
cliPath = join6(tempDirPath, params.executablePath); cliPath = join7(tempDirPath, params.executablePath);
} else { } else {
cliPath = downloadPath; cliPath = downloadPath;
} }
@@ -136379,7 +136365,7 @@ async function installFromGithub(params) {
async function installFromCurl(params) { async function installFromCurl(params) {
log.info(`\xBB installing ${params.executableName}...`); log.info(`\xBB installing ${params.executableName}...`);
const tempDir = process.env.PULLFROG_TEMP_DIR; const tempDir = process.env.PULLFROG_TEMP_DIR;
const installScriptPath = join6(tempDir, "install.sh"); const installScriptPath = join7(tempDir, "install.sh");
log.debug(`\xBB downloading install script from ${params.installUrl}...`); log.debug(`\xBB downloading install script from ${params.installUrl}...`);
const installScriptResponse = await fetch(params.installUrl); const installScriptResponse = await fetch(params.installUrl);
if (!installScriptResponse.ok) { if (!installScriptResponse.ok) {
@@ -136398,7 +136384,7 @@ async function installFromCurl(params) {
// ensuring a fresh install for each run // ensuring a fresh install for each run
HOME: tempDir, HOME: tempDir,
// XDG_CONFIG_HOME must match HOME so CLI tools find config in the right place // XDG_CONFIG_HOME must match HOME so CLI tools find config in the right place
XDG_CONFIG_HOME: join6(tempDir, ".config"), XDG_CONFIG_HOME: join7(tempDir, ".config"),
SHELL: process.env.SHELL, SHELL: process.env.SHELL,
USER: process.env.USER USER: process.env.USER
}, },
@@ -136411,7 +136397,7 @@ async function installFromCurl(params) {
`Failed to install ${params.executableName}. Install script exited with code ${installResult.status}. Output: ${errorOutput}` `Failed to install ${params.executableName}. Install script exited with code ${installResult.status}. Output: ${errorOutput}`
); );
} }
const cliPath = join6(tempDir, ".local", "bin", params.executableName); const cliPath = join7(tempDir, ".local", "bin", params.executableName);
if (!existsSync4(cliPath)) { if (!existsSync4(cliPath)) {
throw new Error(`Executable not found at ${cliPath}`); throw new Error(`Executable not found at ${cliPath}`);
} }
@@ -136583,8 +136569,8 @@ var messageHandlers = {
}; };
// agents/codex.ts // agents/codex.ts
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs"; import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
import { join as join7 } from "node:path"; import { join as join8 } from "node:path";
// node_modules/.pnpm/@openai+codex-sdk@0.80.0/node_modules/@openai/codex-sdk/dist/index.js // node_modules/.pnpm/@openai+codex-sdk@0.80.0/node_modules/@openai/codex-sdk/dist/index.js
import { promises as fs3 } from "fs"; import { promises as fs3 } from "fs";
@@ -136944,9 +136930,9 @@ var codexReasoningEffort = {
max: "high" max: "high"
}; };
function writeCodexConfig(ctx) { function writeCodexConfig(ctx) {
const codexDir = join7(ctx.tmpdir, ".codex"); const codexDir = join8(ctx.tmpdir, ".codex");
mkdirSync3(codexDir, { recursive: true }); mkdirSync3(codexDir, { recursive: true });
const configPath = join7(codexDir, "config.toml"); const configPath = join8(codexDir, "config.toml");
log.info(`\xBB adding MCP server '${ghPullfrogMcpName}' at ${ctx.mcpServerUrl}`); log.info(`\xBB adding MCP server '${ghPullfrogMcpName}' at ${ctx.mcpServerUrl}`);
const mcpServerSections = [`[mcp_servers.${ghPullfrogMcpName}] const mcpServerSections = [`[mcp_servers.${ghPullfrogMcpName}]
url = "${ctx.mcpServerUrl}"`]; url = "${ctx.mcpServerUrl}"`];
@@ -136958,7 +136944,7 @@ url = "${ctx.mcpServerUrl}"`];
} }
const featuresSection = features.length > 0 ? `[features] const featuresSection = features.length > 0 ? `[features]
${features.join("\n")}` : ""; ${features.join("\n")}` : "";
writeFileSync2( writeFileSync3(
configPath, configPath,
`# written by pullfrog `# written by pullfrog
${featuresSection} ${featuresSection}
@@ -136981,7 +136967,7 @@ var codex = agent({
install: installCodex, install: installCodex,
run: async (ctx) => { run: async (ctx) => {
const cliPath = await installCodex(); const cliPath = await installCodex();
const configDir = join7(ctx.tmpdir, ".config", "codex"); const configDir = join8(ctx.tmpdir, ".config", "codex");
mkdirSync3(configDir, { recursive: true }); mkdirSync3(configDir, { recursive: true });
const codexDir = writeCodexConfig(ctx); const codexDir = writeCodexConfig(ctx);
process.env.HOME = ctx.tmpdir; process.env.HOME = ctx.tmpdir;
@@ -137126,9 +137112,9 @@ var messageHandlers2 = {
// agents/cursor.ts // agents/cursor.ts
import { spawn as spawn5 } from "node:child_process"; import { spawn as spawn5 } from "node:child_process";
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs"; import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
import { homedir as homedir2 } from "node:os"; import { homedir as homedir2 } from "node:os";
import { join as join8 } from "node:path"; import { join as join9 } from "node:path";
var cursorEffortModels = { var cursorEffortModels = {
mini: null, mini: null,
// use default (auto) // use default (auto)
@@ -137149,7 +137135,7 @@ var cursor = agent({
const cliPath = await installCursor(); const cliPath = await installCursor();
configureCursorMcpServers(ctx); configureCursorMcpServers(ctx);
configureCursorTools(ctx); configureCursorTools(ctx);
const projectCliConfigPath = join8(process.cwd(), ".cursor", "cli.json"); const projectCliConfigPath = join9(process.cwd(), ".cursor", "cli.json");
let modelOverride = null; let modelOverride = null;
if (existsSync5(projectCliConfigPath)) { if (existsSync5(projectCliConfigPath)) {
try { try {
@@ -137312,19 +137298,19 @@ var cursor = agent({
}); });
function configureCursorMcpServers(ctx) { function configureCursorMcpServers(ctx) {
const realHome = homedir2(); const realHome = homedir2();
const cursorConfigDir = join8(realHome, ".cursor"); const cursorConfigDir = join9(realHome, ".cursor");
const mcpConfigPath = join8(cursorConfigDir, "mcp.json"); const mcpConfigPath = join9(cursorConfigDir, "mcp.json");
mkdirSync4(cursorConfigDir, { recursive: true }); mkdirSync4(cursorConfigDir, { recursive: true });
const mcpServers = { const mcpServers = {
[ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl } [ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl }
}; };
writeFileSync3(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8"); writeFileSync4(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2), "utf-8");
log.info(`\xBB MCP config written to ${mcpConfigPath}`); log.info(`\xBB MCP config written to ${mcpConfigPath}`);
} }
function configureCursorTools(ctx) { function configureCursorTools(ctx) {
const realHome = homedir2(); const realHome = homedir2();
const cursorConfigDir = join8(realHome, ".config", "cursor"); const cursorConfigDir = join9(realHome, ".config", "cursor");
const cliConfigPath = join8(cursorConfigDir, "cli-config.json"); const cliConfigPath = join9(cursorConfigDir, "cli-config.json");
mkdirSync4(cursorConfigDir, { recursive: true }); mkdirSync4(cursorConfigDir, { recursive: true });
const bash = ctx.payload.bash; const bash = ctx.payload.bash;
const deny = []; const deny = [];
@@ -137343,14 +137329,14 @@ function configureCursorTools(ctx) {
networkAccess: "allowlist" networkAccess: "allowlist"
}; };
} }
writeFileSync3(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8"); writeFileSync4(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8");
log.info(`\xBB CLI config written to ${cliConfigPath}`, JSON.stringify(config4, null, 2)); log.info(`\xBB CLI config written to ${cliConfigPath}`, JSON.stringify(config4, null, 2));
} }
// agents/gemini.ts // agents/gemini.ts
import { mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs"; import { mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "node:fs";
import { homedir as homedir3 } from "node:os"; import { homedir as homedir3 } from "node:os";
import { join as join9 } from "node:path"; import { join as join10 } from "node:path";
var geminiEffortConfig = { var geminiEffortConfig = {
// https://ai.google.dev/gemini-api/docs/models // https://ai.google.dev/gemini-api/docs/models
// the docs mention needing to enable preview features for these models but if you // the docs mention needing to enable preview features for these models but if you
@@ -137521,8 +137507,8 @@ function configureGeminiSettings(ctx) {
const { model, thinkingLevel } = geminiEffortConfig[ctx.payload.effort]; const { model, thinkingLevel } = geminiEffortConfig[ctx.payload.effort];
log.info(`\xBB using model: ${model}, thinkingLevel: ${thinkingLevel}`); log.info(`\xBB using model: ${model}, thinkingLevel: ${thinkingLevel}`);
const realHome = homedir3(); const realHome = homedir3();
const geminiConfigDir = join9(realHome, ".gemini"); const geminiConfigDir = join10(realHome, ".gemini");
const settingsPath = join9(geminiConfigDir, "settings.json"); const settingsPath = join10(geminiConfigDir, "settings.json");
mkdirSync5(geminiConfigDir, { recursive: true }); mkdirSync5(geminiConfigDir, { recursive: true });
let existingSettings = {}; let existingSettings = {};
try { try {
@@ -137559,7 +137545,7 @@ function configureGeminiSettings(ctx) {
// v0.3.0+ nested format // v0.3.0+ nested format
...exclude.length > 0 && { tools: { exclude } } ...exclude.length > 0 && { tools: { exclude } }
}; };
writeFileSync4(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); writeFileSync5(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
log.info(`\xBB Gemini settings written to ${settingsPath}`); log.info(`\xBB Gemini settings written to ${settingsPath}`);
if (exclude.length > 0) { if (exclude.length > 0) {
log.info(`\xBB excluded tools: ${exclude.join(", ")}`); log.info(`\xBB excluded tools: ${exclude.join(", ")}`);
@@ -137568,8 +137554,8 @@ function configureGeminiSettings(ctx) {
} }
// agents/opencode.ts // agents/opencode.ts
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "node:fs"; import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "node:fs";
import { join as join10 } from "node:path"; import { join as join11 } from "node:path";
async function installOpencode() { async function installOpencode() {
return await installFromNpmTarball({ return await installFromNpmTarball({
packageName: "opencode-ai", packageName: "opencode-ai",
@@ -137584,7 +137570,7 @@ var opencode = agent({
run: async (ctx) => { run: async (ctx) => {
const cliPath = await installOpencode(); const cliPath = await installOpencode();
const tempHome = ctx.tmpdir; const tempHome = ctx.tmpdir;
const configDir = join10(tempHome, ".config", "opencode"); const configDir = join11(tempHome, ".config", "opencode");
mkdirSync6(configDir, { recursive: true }); mkdirSync6(configDir, { recursive: true });
configureOpenCode(ctx); configureOpenCode(ctx);
const args3 = ["run", ctx.instructions, "--format", "json"]; const args3 = ["run", ctx.instructions, "--format", "json"];
@@ -137592,7 +137578,7 @@ var opencode = agent({
const env3 = { const env3 = {
...process.env, ...process.env,
HOME: tempHome, HOME: tempHome,
XDG_CONFIG_HOME: join10(tempHome, ".config"), XDG_CONFIG_HOME: join11(tempHome, ".config"),
// set GOOGLE_GENERATIVE_AI_API_KEY alias for Google provider compatibility (if not already set) // set GOOGLE_GENERATIVE_AI_API_KEY alias for Google provider compatibility (if not already set)
GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY
}; };
@@ -137695,9 +137681,9 @@ var opencode = agent({
} }
}); });
function configureOpenCode(ctx) { function configureOpenCode(ctx) {
const configDir = join10(ctx.tmpdir, ".config", "opencode"); const configDir = join11(ctx.tmpdir, ".config", "opencode");
mkdirSync6(configDir, { recursive: true }); mkdirSync6(configDir, { recursive: true });
const configPath = join10(configDir, "opencode.json"); const configPath = join11(configDir, "opencode.json");
const opencodeMcpServers = { const opencodeMcpServers = {
[ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl } [ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl }
}; };
@@ -137715,7 +137701,7 @@ function configureOpenCode(ctx) {
}; };
const configJson = JSON.stringify(config4, null, 2); const configJson = JSON.stringify(config4, null, 2);
try { try {
writeFileSync5(configPath, configJson, "utf-8"); writeFileSync6(configPath, configJson, "utf-8");
} catch (error50) { } catch (error50) {
log.error( log.error(
`failed to write OpenCode config to ${configPath}: ${error50 instanceof Error ? error50.message : String(error50)}` `failed to write OpenCode config to ${configPath}: ${error50 instanceof Error ? error50.message : String(error50)}`
@@ -138388,9 +138374,9 @@ async function handleAgentResult(result) {
import { execSync as execSync2 } from "node:child_process"; import { execSync as execSync2 } from "node:child_process";
import { mkdtemp as mkdtemp2 } from "node:fs/promises"; import { mkdtemp as mkdtemp2 } from "node:fs/promises";
import { tmpdir as tmpdir2 } from "node:os"; import { tmpdir as tmpdir2 } from "node:os";
import { join as join11 } from "node:path"; import { join as join12 } from "node:path";
async function createTempDirectory() { async function createTempDirectory() {
const sharedTempDir = await mkdtemp2(join11(tmpdir2(), "pullfrog-")); const sharedTempDir = await mkdtemp2(join12(tmpdir2(), "pullfrog-"));
process.env.PULLFROG_TEMP_DIR = sharedTempDir; process.env.PULLFROG_TEMP_DIR = sharedTempDir;
log.info(`\xBB created temp dir at ${sharedTempDir}`); log.info(`\xBB created temp dir at ${sharedTempDir}`);
return sharedTempDir; return sharedTempDir;
+1 -18
View File
@@ -201,30 +201,13 @@ interface WorkflowDispatchEvent extends BasePayloadEvent {
trigger: "workflow_dispatch"; trigger: "workflow_dispatch";
} }
/** simplified review comment data for payload */
export interface ReviewCommentData {
id: number;
body: string;
path: string;
line: number | null;
user: string | null;
html_url: string;
in_reply_to_id: number | null;
}
interface FixReviewEvent extends BasePayloadEvent { interface FixReviewEvent extends BasePayloadEvent {
trigger: "fix_review"; trigger: "fix_review";
issue_number: number; issue_number: number;
is_pr: true; is_pr: true;
review_id: number; review_id: number;
/** username of the person who triggered this action */ /** username of the person who triggered this action - use with get_review_comments approved_by */
triggerer: string; triggerer: string;
/** "all" to fix all comments, or specific comment IDs to fix */
comment_ids: number[] | "all";
/** comments the triggerer approved (via thumbs up) - these should be addressed */
approved_comments: ReviewCommentData[];
/** other comments in the review - for context only, do not address unless asked */
unapproved_comments: ReviewCommentData[];
} }
interface ImplementPlanEvent extends BasePayloadEvent { interface ImplementPlanEvent extends BasePayloadEvent {
+30 -9
View File
@@ -1,9 +1,9 @@
import type { RestEndpointMethodTypes } from "@octokit/rest"; import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "./server.ts";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { deleteProgressComment } from "./comment.ts"; import { deleteProgressComment } from "./comment.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
// one-shot review tool // one-shot review tool
@@ -20,7 +20,7 @@ export const CreatePullRequestReview = type({
comments: type({ comments: type({
path: type.string.describe("The file path to comment on (relative to repo root)"), path: type.string.describe("The file path to comment on (relative to repo root)"),
line: type.number.describe( line: type.number.describe(
"Line number from the diff. Each code line shows 'OLD | NEW | TYPE | CODE'. Use the NEW column (second column)." "End line of the comment range (or the only line if no start_line). From diff format 'OLD | NEW | TYPE | CODE', use the NEW column."
), ),
side: type side: type
.enumerated("LEFT", "RIGHT") .enumerated("LEFT", "RIGHT")
@@ -28,11 +28,18 @@ export const CreatePullRequestReview = type({
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT." "Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT."
) )
.optional(), .optional(),
body: type.string.describe( body: type.string
"The comment text for this specific line. For issues appearing multiple times, comment on the first occurrence and reference others. When providing code suggestions, use GitHub's suggestion format with ```suggestion blocks to enable one-click apply. Only include explanatory text if the suggested code requires clarification." .describe("Explanatory comment text (optional if suggestion is provided)")
), .optional(),
suggestion: type.string
.describe(
"Full replacement code for the line range. Replaces the commented lines entirely - can be more, fewer, or same number of lines."
)
.optional(),
start_line: type.number start_line: type.number
.describe("Start line for multi-line comments (optional, for commenting on ranges)") .describe(
"Start line for multi-line comments/suggestions. The range [start_line, line] defines which lines get replaced."
)
.optional(), .optional(),
}) })
.array() .array()
@@ -49,7 +56,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
"Submit a review for an existing pull request. " + "Submit a review for an existing pull request. " +
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " + "IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. " + "Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. " +
"When suggesting code changes in comments, use GitHub's suggestion format (```suggestion blocks) to enable one-click apply.", "Use 'suggestion' field to suggest a FULL REPLACEMENT of the associated line range. This will show up for users as a one-click apply suggestion. It doesn't need to be the same length as the original range, but the suggested code must work when applied.",
parameters: CreatePullRequestReview, parameters: CreatePullRequestReview,
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => { execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
// set PR context // set PR context
@@ -78,8 +85,17 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
type ReviewComment = (typeof params.comments & {})[number]; type ReviewComment = (typeof params.comments & {})[number];
// convert comments to the format expected by GitHub API // convert comments to the format expected by GitHub API
params.comments = comments.map((comment) => { params.comments = comments.map((comment) => {
// build comment body with suggestion block if provided
let commentBody = comment.body || "";
if (comment.suggestion !== undefined) {
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
}
const reviewComment: ReviewComment = { const reviewComment: ReviewComment = {
...comment, path: comment.path,
line: comment.line,
body: commentBody,
}; };
reviewComment.side = comment.side || "RIGHT"; reviewComment.side = comment.side || "RIGHT";
if (comment.start_line) { if (comment.start_line) {
@@ -102,7 +118,12 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`; const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
workflowRun: { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId }, workflowRun: {
owner: ctx.repo.owner,
repo: ctx.repo.name,
runId: ctx.runId,
jobId: ctx.jobId,
},
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`], customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
}); });
+77 -158
View File
@@ -1,185 +1,106 @@
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { type } from "arktype"; import { type } from "arktype";
import { log } from "../utils/log.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
// graphql query to fetch all review threads with comments and replies
// note: diffSide and startDiffSide are on the thread, not the comment
const REVIEW_THREADS_QUERY = `
query ($owner: String!, $repo: String!, $pullNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pullNumber) {
reviewThreads(first: 100) {
nodes {
diffSide
startDiffSide
comments(first: 100) {
nodes {
id
databaseId
body
path
line
startLine
url
author {
login
}
createdAt
updatedAt
pullRequestReview {
databaseId
}
replyTo {
databaseId
}
}
}
}
}
}
}
}
`;
// graphql response types (nodes arrays can contain nulls per GitHub GraphQL spec)
type GraphQLReviewComment = {
id: string;
databaseId: number;
body: string;
path: string;
line: number | null;
startLine: number | null;
url: string;
author: {
login: string;
} | null;
createdAt: string;
updatedAt: string;
pullRequestReview: {
databaseId: number;
} | null;
replyTo: {
databaseId: number;
} | null;
};
type GraphQLReviewThread = {
diffSide: "LEFT" | "RIGHT";
startDiffSide: "LEFT" | "RIGHT" | null;
comments: {
nodes: (GraphQLReviewComment | null)[] | null;
} | null;
} | null;
type GraphQLResponse = {
repository: {
pullRequest: {
reviewThreads: {
nodes: (GraphQLReviewThread | null)[] | null;
} | null;
} | null;
} | null;
};
export const GetReviewComments = type({ export const GetReviewComments = type({
pull_number: type.number.describe("The pull request number"), pull_number: type.number.describe("The pull request number"),
review_id: type.number.describe("The review ID to get comments for"), review_id: type.number.describe("The review ID to get comments for"),
approved_by: type.string
.describe("Optional GitHub username - only return comments this user gave a 👍 to")
.optional(),
}); });
export function GetReviewCommentsTool(ctx: ToolContext) { export function GetReviewCommentsTool(ctx: ToolContext) {
return tool({ return tool({
name: "get_review_comments", name: "get_review_comments",
description: description:
"Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.", "Get review comments for a pull request review, including diff context. " +
"When approved_by is provided, only returns comments that user approved with 👍. " +
"Returns commentsPath pointing to a file with full comment details.",
parameters: GetReviewComments, parameters: GetReviewComments,
execute: execute(async ({ pull_number, review_id }) => { execute: execute(async ({ pull_number, review_id, approved_by }) => {
// fetch all review threads using graphql // fetch all review comments via REST API (includes diff_hunk)
const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, { const allComments = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviewComments, {
owner: ctx.repo.owner, owner: ctx.repo.owner,
repo: ctx.repo.name, repo: ctx.repo.name,
pullNumber: pull_number, pull_number,
}); });
const pullRequest = response.repository?.pullRequest; // filter to target review
if (!pullRequest) { let reviewComments = allComments.filter((c) => c.pull_request_review_id === review_id);
return {
review_id,
pull_number,
comments: [],
count: 0,
};
}
const threadNodes = pullRequest.reviewThreads?.nodes; // filter by thumbs up if approved_by is specified
if (!threadNodes) { if (approved_by) {
return { const approvedIds = new Set<number>();
review_id, for (const comment of reviewComments) {
pull_number, const reactions = await ctx.octokit.rest.reactions.listForPullRequestReviewComment({
comments: [], owner: ctx.repo.owner,
count: 0, repo: ctx.repo.name,
}; comment_id: comment.id,
} });
const hasThumbsUp = reactions.data.some(
const allComments: { (r) => r.content === "+1" && r.user?.login === approved_by
id: number;
body: string;
path: string;
line: number | null;
side: "LEFT" | "RIGHT";
start_line: number | null;
start_side: "LEFT" | "RIGHT" | null;
user: string | null;
created_at: string;
updated_at: string;
html_url: string;
in_reply_to_id: number | null;
pull_request_review_id: number | null;
}[] = [];
// iterate through all threads (filter out nulls)
for (const thread of threadNodes) {
if (!thread?.comments?.nodes) continue;
// filter out null comments
const threadComments = thread.comments.nodes.filter(
(c): c is GraphQLReviewComment => c !== null
); );
if (threadComments.length === 0) continue; if (hasThumbsUp) approvedIds.add(comment.id);
// find the root comment (the one with replyTo == null) to determine thread ownership
const rootComment = threadComments.find((c) => c.replyTo === null);
if (!rootComment) continue;
// check if this thread belongs to the target review using the root comment
const threadBelongsToReview = rootComment.pullRequestReview?.databaseId === review_id;
if (!threadBelongsToReview) continue;
// include all comments from this thread (original + replies)
// side info comes from thread level, not comment level
for (const comment of threadComments) {
allComments.push({
id: comment.databaseId,
body: comment.body,
path: comment.path,
line: comment.line,
start_line: comment.startLine,
side: thread.diffSide,
start_side: thread.startDiffSide,
user: comment.author?.login ?? null,
created_at: comment.createdAt,
updated_at: comment.updatedAt,
html_url: comment.url,
in_reply_to_id: comment.replyTo?.databaseId ?? null,
pull_request_review_id: comment.pullRequestReview?.databaseId ?? null,
});
} }
reviewComments = reviewComments.filter((c) => approvedIds.has(c.id));
} }
if (reviewComments.length === 0) {
return {
review_id,
pull_number,
count: 0,
commentsPath: null,
message: approved_by
? `No comments with 👍 from ${approved_by}`
: "No comments found for this review",
};
}
// format comments with diff context
const lines: string[] = [];
for (const comment of reviewComments) {
lines.push(`${"=".repeat(60)}`);
lines.push(`COMMENT #${comment.id} by @${comment.user?.login ?? "unknown"}`);
lines.push(`File: ${comment.path}:${comment.line ?? comment.original_line ?? "?"}`);
if (comment.in_reply_to_id) {
lines.push(`Reply to: #${comment.in_reply_to_id}`);
}
lines.push("");
if (comment.diff_hunk) {
lines.push("```diff");
lines.push(comment.diff_hunk);
lines.push("```");
lines.push("");
}
lines.push("Comment:");
lines.push(comment.body);
lines.push("");
}
const content = lines.join("\n");
// write to temp file
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) {
throw new Error("PULLFROG_TEMP_DIR not set");
}
const filename = approved_by
? `review-${review_id}-approved-by-${approved_by}.txt`
: `review-${review_id}-comments.txt`;
const commentsPath = join(tempDir, filename);
writeFileSync(commentsPath, content);
log.debug(`wrote ${reviewComments.length} comments to ${commentsPath}`);
return { return {
review_id, review_id,
pull_number, pull_number,
comments: allComments, count: reviewComments.length,
count: allComments.length, commentsPath,
}; };
}), }),
}); });
@@ -209,9 +130,7 @@ export function ListPullRequestReviewsTool(ctx: ToolContext) {
body: review.body, body: review.body,
state: review.state, state: review.state,
user: review.user?.login, user: review.user?.login,
commit_id: review.commit_id,
submitted_at: review.submitted_at, submitted_at: review.submitted_at,
html_url: review.html_url,
})), })),
count: reviews.length, count: reviews.length,
}; };