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({
path: type.string.describe("The file path to comment on (relative to repo root)"),
line: type.number.describe(
"Line number from the diff. Each code line shows 'OLD | NEW | TYPE | CODE'. Use the NEW column (second column)."
"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 of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT."
).optional(),
body: type.string.describe(
"The comment text for this specific line. For issues appearing multiple times, comment on the first occurrence and reference others. 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."
),
start_line: type.number.describe("Start line for multi-line comments (optional, for commenting on ranges)").optional()
body: type.string.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.describe(
"Start line for multi-line comments/suggestions. The range [start_line, line] defines which lines get replaced."
).optional()
}).array().describe(
"Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead."
).optional()
@@ -119151,7 +119154,7 @@ var CreatePullRequestReview = type({
function CreatePullRequestReviewTool(ctx) {
return tool({
name: "create_pull_request_review",
description: "Submit a review for an existing pull request. IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. 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,
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
ctx.toolState.prNumber = pull_number;
@@ -119174,8 +119177,15 @@ function CreatePullRequestReviewTool(ctx) {
}
if (comments.length > 0) {
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 = {
...comment
path: comment.path,
line: comment.line,
body: commentBody
};
reviewComment.side = comment.side || "RIGHT";
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 fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
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})`]
});
const updatedBody = (body || "") + footer;
@@ -119220,109 +119235,82 @@ function CreatePullRequestReviewTool(ctx) {
}
// mcp/reviewComments.ts
var 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
}
}
}
}
}
}
}
}
`;
import { writeFileSync as writeFileSync2 } from "node:fs";
import { join as join4 } from "node:path";
var GetReviewComments = type({
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) {
return tool({
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,
execute: execute(async ({ pull_number, review_id }) => {
const response = await ctx.octokit.graphql(REVIEW_THREADS_QUERY, {
execute: execute(async ({ pull_number, review_id, approved_by }) => {
const allComments = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviewComments, {
owner: ctx.repo.owner,
repo: ctx.repo.name,
pullNumber: pull_number
pull_number
});
const pullRequest = response.repository?.pullRequest;
if (!pullRequest) {
return {
review_id,
pull_number,
comments: [],
count: 0
};
}
const threadNodes = pullRequest.reviewThreads?.nodes;
if (!threadNodes) {
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;
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
let reviewComments = allComments.filter((c) => c.pull_request_review_id === review_id);
if (approved_by) {
const approvedIds = /* @__PURE__ */ new Set();
for (const comment of reviewComments) {
const reactions = await ctx.octokit.rest.reactions.listForPullRequestReviewComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: comment.id
});
const hasThumbsUp = reactions.data.some(
(r) => r.content === "+1" && r.user?.login === approved_by
);
if (hasThumbsUp) approvedIds.add(comment.id);
}
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 \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,
comments: allComments,
count: allComments.length
count: reviewComments.length,
commentsPath
};
})
});
@@ -119348,9 +119336,7 @@ function ListPullRequestReviewsTool(ctx) {
body: review.body,
state: review.state,
user: review.user?.login,
commit_id: review.commit_id,
submitted_at: review.submitted_at,
html_url: review.html_url
submitted_at: review.submitted_at
})),
count: reviews.length
};
@@ -119631,7 +119617,7 @@ import { spawn as spawn3 } from "child_process";
import { createInterface } from "readline";
import * as fs2 from "fs";
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 { dirname, join as join22 } from "path";
import { cwd } from "process";
@@ -126333,7 +126319,7 @@ function shouldShowDebugMessage(message, filter) {
return shouldShowDebugCategories(categories, filter);
}
function getClaudeConfigHomeDir() {
return process.env.CLAUDE_CONFIG_DIR ?? join4(homedir(), ".claude");
return process.env.CLAUDE_CONFIG_DIR ?? join6(homedir(), ".claude");
}
function isEnvTruthy(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 { mkdtemp } from "node:fs/promises";
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";
async function installFromNpmTarball(params) {
let resolvedVersion = params.version;
@@ -136262,7 +136248,7 @@ async function installFromNpmTarball(params) {
}
log.debug(`\xBB installing ${params.packageName}@${resolvedVersion}...`);
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";
let tarballUrl;
if (params.packageName.startsWith("@")) {
@@ -136291,8 +136277,8 @@ async function installFromNpmTarball(params) {
`Failed to extract tarball: ${extractResult.stderr || extractResult.stdout || "Unknown error"}`
);
}
const extractedDir = join6(tempDir, "package");
const cliPath = join6(extractedDir, params.executablePath);
const extractedDir = join7(tempDir, "package");
const cliPath = join7(extractedDir, params.executablePath);
if (!existsSync4(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;
log.debug(`\xBB downloading asset from ${assetUrl}...`);
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 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");
if (!assetResponse.body) throw new Error("Response body is null");
const fileStream = createWriteStream2(downloadPath);
@@ -136365,7 +136351,7 @@ async function installFromGithub(params) {
log.debug(`\xBB downloaded asset to ${downloadPath}`);
let cliPath;
if (params.executablePath) {
cliPath = join6(tempDirPath, params.executablePath);
cliPath = join7(tempDirPath, params.executablePath);
} else {
cliPath = downloadPath;
}
@@ -136379,7 +136365,7 @@ async function installFromGithub(params) {
async function installFromCurl(params) {
log.info(`\xBB installing ${params.executableName}...`);
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}...`);
const installScriptResponse = await fetch(params.installUrl);
if (!installScriptResponse.ok) {
@@ -136398,7 +136384,7 @@ async function installFromCurl(params) {
// ensuring a fresh install for each run
HOME: tempDir,
// 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,
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}`
);
}
const cliPath = join6(tempDir, ".local", "bin", params.executableName);
const cliPath = join7(tempDir, ".local", "bin", params.executableName);
if (!existsSync4(cliPath)) {
throw new Error(`Executable not found at ${cliPath}`);
}
@@ -136583,8 +136569,8 @@ var messageHandlers = {
};
// agents/codex.ts
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
import { join as join7 } from "node:path";
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
import { join as join8 } from "node:path";
// node_modules/.pnpm/@openai+codex-sdk@0.80.0/node_modules/@openai/codex-sdk/dist/index.js
import { promises as fs3 } from "fs";
@@ -136944,9 +136930,9 @@ var codexReasoningEffort = {
max: "high"
};
function writeCodexConfig(ctx) {
const codexDir = join7(ctx.tmpdir, ".codex");
const codexDir = join8(ctx.tmpdir, ".codex");
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}`);
const mcpServerSections = [`[mcp_servers.${ghPullfrogMcpName}]
url = "${ctx.mcpServerUrl}"`];
@@ -136958,7 +136944,7 @@ url = "${ctx.mcpServerUrl}"`];
}
const featuresSection = features.length > 0 ? `[features]
${features.join("\n")}` : "";
writeFileSync2(
writeFileSync3(
configPath,
`# written by pullfrog
${featuresSection}
@@ -136981,7 +136967,7 @@ var codex = agent({
install: installCodex,
run: async (ctx) => {
const cliPath = await installCodex();
const configDir = join7(ctx.tmpdir, ".config", "codex");
const configDir = join8(ctx.tmpdir, ".config", "codex");
mkdirSync3(configDir, { recursive: true });
const codexDir = writeCodexConfig(ctx);
process.env.HOME = ctx.tmpdir;
@@ -137126,9 +137112,9 @@ var messageHandlers2 = {
// agents/cursor.ts
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 { join as join8 } from "node:path";
import { join as join9 } from "node:path";
var cursorEffortModels = {
mini: null,
// use default (auto)
@@ -137149,7 +137135,7 @@ var cursor = agent({
const cliPath = await installCursor();
configureCursorMcpServers(ctx);
configureCursorTools(ctx);
const projectCliConfigPath = join8(process.cwd(), ".cursor", "cli.json");
const projectCliConfigPath = join9(process.cwd(), ".cursor", "cli.json");
let modelOverride = null;
if (existsSync5(projectCliConfigPath)) {
try {
@@ -137312,19 +137298,19 @@ var cursor = agent({
});
function configureCursorMcpServers(ctx) {
const realHome = homedir2();
const cursorConfigDir = join8(realHome, ".cursor");
const mcpConfigPath = join8(cursorConfigDir, "mcp.json");
const cursorConfigDir = join9(realHome, ".cursor");
const mcpConfigPath = join9(cursorConfigDir, "mcp.json");
mkdirSync4(cursorConfigDir, { recursive: true });
const mcpServers = {
[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}`);
}
function configureCursorTools(ctx) {
const realHome = homedir2();
const cursorConfigDir = join8(realHome, ".config", "cursor");
const cliConfigPath = join8(cursorConfigDir, "cli-config.json");
const cursorConfigDir = join9(realHome, ".config", "cursor");
const cliConfigPath = join9(cursorConfigDir, "cli-config.json");
mkdirSync4(cursorConfigDir, { recursive: true });
const bash = ctx.payload.bash;
const deny = [];
@@ -137343,14 +137329,14 @@ function configureCursorTools(ctx) {
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));
}
// 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 { join as join9 } from "node:path";
import { join as join10 } from "node:path";
var geminiEffortConfig = {
// https://ai.google.dev/gemini-api/docs/models
// 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];
log.info(`\xBB using model: ${model}, thinkingLevel: ${thinkingLevel}`);
const realHome = homedir3();
const geminiConfigDir = join9(realHome, ".gemini");
const settingsPath = join9(geminiConfigDir, "settings.json");
const geminiConfigDir = join10(realHome, ".gemini");
const settingsPath = join10(geminiConfigDir, "settings.json");
mkdirSync5(geminiConfigDir, { recursive: true });
let existingSettings = {};
try {
@@ -137559,7 +137545,7 @@ function configureGeminiSettings(ctx) {
// v0.3.0+ nested format
...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}`);
if (exclude.length > 0) {
log.info(`\xBB excluded tools: ${exclude.join(", ")}`);
@@ -137568,8 +137554,8 @@ function configureGeminiSettings(ctx) {
}
// agents/opencode.ts
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "node:fs";
import { join as join10 } from "node:path";
import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6 } from "node:fs";
import { join as join11 } from "node:path";
async function installOpencode() {
return await installFromNpmTarball({
packageName: "opencode-ai",
@@ -137584,7 +137570,7 @@ var opencode = agent({
run: async (ctx) => {
const cliPath = await installOpencode();
const tempHome = ctx.tmpdir;
const configDir = join10(tempHome, ".config", "opencode");
const configDir = join11(tempHome, ".config", "opencode");
mkdirSync6(configDir, { recursive: true });
configureOpenCode(ctx);
const args3 = ["run", ctx.instructions, "--format", "json"];
@@ -137592,7 +137578,7 @@ var opencode = agent({
const env3 = {
...process.env,
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)
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) {
const configDir = join10(ctx.tmpdir, ".config", "opencode");
const configDir = join11(ctx.tmpdir, ".config", "opencode");
mkdirSync6(configDir, { recursive: true });
const configPath = join10(configDir, "opencode.json");
const configPath = join11(configDir, "opencode.json");
const opencodeMcpServers = {
[ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl }
};
@@ -137715,7 +137701,7 @@ function configureOpenCode(ctx) {
};
const configJson = JSON.stringify(config4, null, 2);
try {
writeFileSync5(configPath, configJson, "utf-8");
writeFileSync6(configPath, configJson, "utf-8");
} catch (error50) {
log.error(
`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 { mkdtemp as mkdtemp2 } from "node:fs/promises";
import { tmpdir as tmpdir2 } from "node:os";
import { join as join11 } from "node:path";
import { join as join12 } from "node:path";
async function createTempDirectory() {
const sharedTempDir = await mkdtemp2(join11(tmpdir2(), "pullfrog-"));
const sharedTempDir = await mkdtemp2(join12(tmpdir2(), "pullfrog-"));
process.env.PULLFROG_TEMP_DIR = sharedTempDir;
log.info(`\xBB created temp dir at ${sharedTempDir}`);
return sharedTempDir;
+1 -18
View File
@@ -201,30 +201,13 @@ interface WorkflowDispatchEvent extends BasePayloadEvent {
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 {
trigger: "fix_review";
issue_number: number;
is_pr: true;
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;
/** "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 {
+30 -9
View File
@@ -1,9 +1,9 @@
import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype";
import type { ToolContext } from "./server.ts";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { deleteProgressComment } from "./comment.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
// one-shot review tool
@@ -20,7 +20,7 @@ export const CreatePullRequestReview = type({
comments: type({
path: type.string.describe("The file path to comment on (relative to repo root)"),
line: type.number.describe(
"Line number from the diff. Each code line shows 'OLD | NEW | TYPE | CODE'. Use the NEW column (second column)."
"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")
@@ -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."
)
.optional(),
body: type.string.describe(
"The comment text for this specific line. For issues appearing multiple times, comment on the first occurrence and reference others. 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."
),
body: type.string
.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
.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(),
})
.array()
@@ -49,7 +56,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
"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.",
"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,
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
// set PR context
@@ -78,8 +85,17 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
type ReviewComment = (typeof params.comments & {})[number];
// convert comments to the format expected by GitHub API
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 = {
...comment,
path: comment.path,
line: comment.line,
body: commentBody,
};
reviewComment.side = comment.side || "RIGHT";
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 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})`],
});
+82 -163
View File
@@ -1,187 +1,108 @@
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { type } from "arktype";
import { log } from "../utils/log.ts";
import type { ToolContext } from "./server.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({
pull_number: type.number.describe("The pull request number"),
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) {
return tool({
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.",
"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,
execute: execute(async ({ pull_number, review_id }) => {
// fetch all review threads using graphql
const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, {
owner: ctx.repo.owner,
repo: ctx.repo.name,
pullNumber: pull_number,
});
execute: execute(async ({ pull_number, review_id, approved_by }) => {
// fetch all review comments via REST API (includes diff_hunk)
const allComments = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviewComments, {
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
});
const pullRequest = response.repository?.pullRequest;
if (!pullRequest) {
return {
review_id,
pull_number,
comments: [],
count: 0,
};
}
// filter to target review
let reviewComments = allComments.filter((c) => c.pull_request_review_id === review_id);
const threadNodes = pullRequest.reviewThreads?.nodes;
if (!threadNodes) {
return {
review_id,
pull_number,
comments: [],
count: 0,
};
}
const allComments: {
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
// filter by thumbs up if approved_by is specified
if (approved_by) {
const approvedIds = new Set<number>();
for (const comment of reviewComments) {
const reactions = await ctx.octokit.rest.reactions.listForPullRequestReviewComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: comment.id,
});
const hasThumbsUp = reactions.data.some(
(r) => r.content === "+1" && r.user?.login === approved_by
);
if (threadComments.length === 0) continue;
// 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,
});
}
if (hasThumbsUp) approvedIds.add(comment.id);
}
reviewComments = reviewComments.filter((c) => approvedIds.has(c.id));
}
if (reviewComments.length === 0) {
return {
review_id,
pull_number,
comments: allComments,
count: allComments.length,
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 {
review_id,
pull_number,
count: reviewComments.length,
commentsPath,
};
}),
});
}
@@ -209,9 +130,7 @@ export function ListPullRequestReviewsTool(ctx: ToolContext) {
body: review.body,
state: review.state,
user: review.user?.login,
commit_id: review.commit_id,
submitted_at: review.submitted_at,
html_url: review.html_url,
})),
count: reviews.length,
};