tests and better diffs (#163)
* refactor get_review_comments to use reviewThreads graphql api with full thread context and proper diff extraction * Improve get_review_comments output * Improve tests and diffs * GH_TOKEN * Added back approved_by * Fix CI
This commit is contained in:
committed by
pullfrog[bot]
parent
9a8db3e07c
commit
7621d6f0e5
@@ -106637,7 +106637,7 @@ var handleToolError = (error50) => {
|
||||
};
|
||||
};
|
||||
var execute = (fn2, toolName) => {
|
||||
return async (params) => {
|
||||
const _fn = async (params) => {
|
||||
try {
|
||||
const result = await fn2(params);
|
||||
return handleToolSuccess(result);
|
||||
@@ -106649,6 +106649,8 @@ var execute = (fn2, toolName) => {
|
||||
return handleToolError(error50);
|
||||
}
|
||||
};
|
||||
_fn.raw = fn2;
|
||||
return _fn;
|
||||
};
|
||||
function sanitizeSchema(schema2) {
|
||||
if (!schema2 || typeof schema2 !== "object") {
|
||||
@@ -136777,13 +136779,24 @@ function $(cmd, args3, options) {
|
||||
// mcp/checkout.ts
|
||||
function formatFilesWithLineNumbers(files) {
|
||||
const output = [];
|
||||
const tocEntries = [];
|
||||
const tocHeaderSize = 1 + files.length + 2;
|
||||
let currentLine = tocHeaderSize + 1;
|
||||
for (const file2 of files) {
|
||||
const fileStartLine = currentLine;
|
||||
output.push(`diff --git a/${file2.filename} b/${file2.filename}`);
|
||||
output.push(`--- a/${file2.filename}`);
|
||||
output.push(`+++ b/${file2.filename}`);
|
||||
currentLine += 3;
|
||||
if (!file2.patch) {
|
||||
output.push("(binary file or no changes)");
|
||||
output.push("");
|
||||
currentLine += 2;
|
||||
tocEntries.push({
|
||||
filename: file2.filename,
|
||||
startLine: fileStartLine,
|
||||
endLine: currentLine - 1
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const lines = file2.patch.split("\n");
|
||||
@@ -136795,6 +136808,7 @@ function formatFilesWithLineNumbers(files) {
|
||||
oldLine = parseInt(hunkMatch[1], 10);
|
||||
newLine = parseInt(hunkMatch[2], 10);
|
||||
output.push(line);
|
||||
currentLine++;
|
||||
continue;
|
||||
}
|
||||
const changeType = line[0] || " ";
|
||||
@@ -136816,10 +136830,26 @@ function formatFilesWithLineNumbers(files) {
|
||||
} else {
|
||||
output.push(line);
|
||||
}
|
||||
currentLine++;
|
||||
}
|
||||
output.push("");
|
||||
currentLine++;
|
||||
tocEntries.push({
|
||||
filename: file2.filename,
|
||||
startLine: fileStartLine,
|
||||
endLine: currentLine - 1
|
||||
});
|
||||
}
|
||||
return output.join("\n");
|
||||
const tocLines = [`## Files (${files.length})`];
|
||||
for (const entry of tocEntries) {
|
||||
tocLines.push(`- ${entry.filename} \u2192 lines ${entry.startLine}-${entry.endLine}`);
|
||||
}
|
||||
tocLines.push("");
|
||||
tocLines.push("---");
|
||||
tocLines.push("");
|
||||
const toc = tocLines.join("\n");
|
||||
const content = toc + output.join("\n");
|
||||
return { content, toc };
|
||||
}
|
||||
function padNum(n) {
|
||||
return n.toString().padStart(4, " ");
|
||||
@@ -136827,6 +136857,15 @@ function padNum(n) {
|
||||
var CheckoutPr = type({
|
||||
pull_number: type.number.describe("the pull request number to checkout")
|
||||
});
|
||||
async function fetchAndFormatPrDiff(params) {
|
||||
const filesResponse = await params.octokit.rest.pulls.listFiles({
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
pull_number: params.pullNumber,
|
||||
per_page: 100
|
||||
});
|
||||
return formatFilesWithLineNumbers(filesResponse.data);
|
||||
}
|
||||
async function checkoutPrBranch(params) {
|
||||
const { octokit, owner, name, token, pullNumber } = params;
|
||||
log.info(`\xBB checking out PR #${pullNumber}...`);
|
||||
@@ -136907,14 +136946,13 @@ function CheckoutPrTool(ctx) {
|
||||
if (!headRepo) {
|
||||
throw new Error(`PR #${pull_number} source repository was deleted`);
|
||||
}
|
||||
const filesResponse = await ctx.octokit.rest.pulls.listFiles({
|
||||
const formatResult = await fetchAndFormatPrDiff({
|
||||
octokit: ctx.octokit,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
per_page: 100
|
||||
pullNumber: pull_number
|
||||
});
|
||||
const diffContent = formatFilesWithLineNumbers(filesResponse.data);
|
||||
const diffPreview = diffContent.split("\n").slice(0, 100).join("\n");
|
||||
const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
|
||||
log.debug(`formatted diff preview (first 100 lines):
|
||||
${diffPreview}`);
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
@@ -136924,8 +136962,8 @@ ${diffPreview}`);
|
||||
);
|
||||
}
|
||||
const diffPath = join2(tempDir, `pr-${pull_number}.diff`);
|
||||
writeFileSync2(diffPath, diffContent);
|
||||
log.debug(`wrote diff to ${diffPath} (${diffContent.length} bytes)`);
|
||||
writeFileSync2(diffPath, formatResult.content);
|
||||
log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
|
||||
return {
|
||||
success: true,
|
||||
number: pr.data.number,
|
||||
@@ -138486,7 +138524,7 @@ function CommitInfoTool(ctx) {
|
||||
});
|
||||
const data = response.data;
|
||||
const files = data.files ?? [];
|
||||
const diffContent = formatFilesWithLineNumbers(files);
|
||||
const formatResult = formatFilesWithLineNumbers(files);
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
if (!tempDir) {
|
||||
throw new Error(
|
||||
@@ -138494,8 +138532,8 @@ function CommitInfoTool(ctx) {
|
||||
);
|
||||
}
|
||||
const diffFile = join5(tempDir, `commit-${sha.slice(0, 7)}.diff`);
|
||||
writeFileSync3(diffFile, diffContent);
|
||||
log.debug(`wrote commit diff to ${diffFile} (${diffContent.length} bytes)`);
|
||||
writeFileSync3(diffFile, formatResult.content);
|
||||
log.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`);
|
||||
return {
|
||||
sha: data.sha,
|
||||
message: data.commit.message,
|
||||
@@ -138688,56 +138726,41 @@ function CreatePullRequestReviewTool(ctx) {
|
||||
// mcp/reviewComments.ts
|
||||
import { writeFileSync as writeFileSync4 } from "node:fs";
|
||||
import { join as join6 } from "node:path";
|
||||
var REPLY_TO_FRAGMENT = `
|
||||
replyTo {
|
||||
databaseId
|
||||
body
|
||||
author { login }
|
||||
replyTo {
|
||||
databaseId
|
||||
body
|
||||
author { login }
|
||||
replyTo {
|
||||
databaseId
|
||||
body
|
||||
author { login }
|
||||
replyTo {
|
||||
databaseId
|
||||
body
|
||||
author { login }
|
||||
replyTo {
|
||||
databaseId
|
||||
body
|
||||
author { login }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
var REVIEW_QUERY = `
|
||||
query ($nodeId: ID!) {
|
||||
node(id: $nodeId) {
|
||||
... on PullRequestReview {
|
||||
databaseId
|
||||
author { login }
|
||||
comments(first: 100) {
|
||||
var REVIEW_THREADS_QUERY = `
|
||||
query ($owner: String!, $name: String!, $prNumber: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $prNumber) {
|
||||
reviewThreads(first: 100) {
|
||||
nodes {
|
||||
databaseId
|
||||
body
|
||||
id
|
||||
path
|
||||
line
|
||||
startLine
|
||||
diffHunk
|
||||
url
|
||||
author { login }
|
||||
createdAt
|
||||
${REPLY_TO_FRAGMENT}
|
||||
reactionGroups {
|
||||
content
|
||||
reactors(first: 10) {
|
||||
nodes {
|
||||
... on Actor { login }
|
||||
diffSide
|
||||
isResolved
|
||||
isOutdated
|
||||
comments(first: 50) {
|
||||
nodes {
|
||||
fullDatabaseId
|
||||
body
|
||||
createdAt
|
||||
diffHunk
|
||||
line
|
||||
startLine
|
||||
originalLine
|
||||
originalStartLine
|
||||
author { login }
|
||||
pullRequestReview {
|
||||
databaseId
|
||||
author { login }
|
||||
}
|
||||
reactionGroups {
|
||||
content
|
||||
reactors(first: 10) {
|
||||
nodes {
|
||||
... on Actor { login }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138747,123 +138770,306 @@ query ($nodeId: ID!) {
|
||||
}
|
||||
}
|
||||
`;
|
||||
var MAX_BODY_PREVIEW = 80;
|
||||
function truncateBody(body) {
|
||||
const oneLine = body.replace(/\n/g, " ").trim();
|
||||
if (oneLine.length <= MAX_BODY_PREVIEW) return oneLine;
|
||||
return oneLine.slice(0, MAX_BODY_PREVIEW - 3) + "...";
|
||||
var CONTEXT_PADDING = 3;
|
||||
function extractCommentedLines(diffHunk, startLine, endLine, side) {
|
||||
const lines = diffHunk.split("\n");
|
||||
if (lines.length <= 1) return diffHunk;
|
||||
const header = lines[0];
|
||||
const contentLines = lines.slice(1);
|
||||
const headerMatch = header.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (!headerMatch) return diffHunk;
|
||||
const hunkOldStart = parseInt(headerMatch[1], 10);
|
||||
const hunkNewStart = parseInt(headerMatch[2], 10);
|
||||
const hunkStart = side === "LEFT" ? hunkOldStart : hunkNewStart;
|
||||
const commentStart = startLine ?? endLine ?? hunkStart;
|
||||
const commentEnd = endLine ?? commentStart;
|
||||
const diffLines = [];
|
||||
let oldLineNum = hunkOldStart;
|
||||
let newLineNum = hunkNewStart;
|
||||
for (const line of contentLines) {
|
||||
const prefix = line[0];
|
||||
if (prefix === "-") {
|
||||
diffLines.push({ text: line, lineNum: side === "LEFT" ? oldLineNum : null });
|
||||
oldLineNum++;
|
||||
} else if (prefix === "+") {
|
||||
diffLines.push({ text: line, lineNum: side === "RIGHT" ? newLineNum : null });
|
||||
newLineNum++;
|
||||
} else {
|
||||
diffLines.push({ text: line, lineNum: side === "LEFT" ? oldLineNum : newLineNum });
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
const targetStart = commentStart - CONTEXT_PADDING;
|
||||
const targetEnd = commentEnd;
|
||||
const result = [];
|
||||
let truncatedBefore = 0;
|
||||
for (let i = 0; i < diffLines.length; i++) {
|
||||
const dl = diffLines[i];
|
||||
const inRange = dl.lineNum !== null && dl.lineNum >= targetStart && dl.lineNum <= targetEnd;
|
||||
const adjacentOtherSide = dl.lineNum === null && result.length > 0 && i < diffLines.length - 1;
|
||||
if (inRange || adjacentOtherSide) {
|
||||
result.push(dl.text);
|
||||
} else if (result.length === 0) {
|
||||
truncatedBefore++;
|
||||
}
|
||||
}
|
||||
if (truncatedBefore > 0) {
|
||||
return `${header}
|
||||
... (${truncatedBefore} lines above) ...
|
||||
${result.join("\n")}`;
|
||||
}
|
||||
return `${header}
|
||||
${result.join("\n")}`;
|
||||
}
|
||||
function escapeXml(text) {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
function parseFilePatches(patch) {
|
||||
const hunks = [];
|
||||
const lines = patch.split("\n");
|
||||
let currentHunk = null;
|
||||
for (const line of lines) {
|
||||
const hunkMatch = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
||||
if (hunkMatch) {
|
||||
if (currentHunk) hunks.push(currentHunk);
|
||||
currentHunk = {
|
||||
header: line,
|
||||
oldStart: parseInt(hunkMatch[1], 10),
|
||||
oldCount: parseInt(hunkMatch[2] ?? "1", 10),
|
||||
newStart: parseInt(hunkMatch[3], 10),
|
||||
newCount: parseInt(hunkMatch[4] ?? "1", 10),
|
||||
content: []
|
||||
};
|
||||
} else if (currentHunk) {
|
||||
currentHunk.content.push(line);
|
||||
}
|
||||
}
|
||||
if (currentHunk) hunks.push(currentHunk);
|
||||
return hunks;
|
||||
}
|
||||
function findOverlappingHunks(hunks, startLine, endLine, side) {
|
||||
return hunks.filter((hunk) => {
|
||||
const hunkStart = side === "LEFT" ? hunk.oldStart : hunk.newStart;
|
||||
const hunkCount = side === "LEFT" ? hunk.oldCount : hunk.newCount;
|
||||
const hunkEnd = hunkStart + hunkCount - 1;
|
||||
return startLine <= hunkEnd && hunkStart <= endLine;
|
||||
});
|
||||
}
|
||||
function extractFromFilePatches(hunks, startLine, endLine, side) {
|
||||
const overlapping = findOverlappingHunks(hunks, startLine, endLine, side);
|
||||
if (overlapping.length === 0) {
|
||||
return `(no diff hunks found for lines ${startLine}-${endLine})`;
|
||||
}
|
||||
if (overlapping.length === 1) {
|
||||
const hunk = overlapping[0];
|
||||
const fullHunk = hunk.header + "\n" + hunk.content.join("\n");
|
||||
return extractCommentedLines(fullHunk, startLine, endLine, side);
|
||||
}
|
||||
const result = [];
|
||||
let prevHunkEnd = 0;
|
||||
for (let i = 0; i < overlapping.length; i++) {
|
||||
const hunk = overlapping[i];
|
||||
const hunkStart = side === "LEFT" ? hunk.oldStart : hunk.newStart;
|
||||
const hunkCount = side === "LEFT" ? hunk.oldCount : hunk.newCount;
|
||||
const hunkEnd = hunkStart + hunkCount - 1;
|
||||
if (i > 0 && hunkStart > prevHunkEnd + 1) {
|
||||
const gapSize = hunkStart - prevHunkEnd - 1;
|
||||
result.push(`
|
||||
... (${gapSize} unchanged lines) ...
|
||||
`);
|
||||
}
|
||||
result.push(hunk.header);
|
||||
result.push(...hunk.content);
|
||||
prevHunkEnd = hunkEnd;
|
||||
}
|
||||
return result.join("\n");
|
||||
}
|
||||
var 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 threads where this user gave a \u{1F44D} to at least one comment"
|
||||
).optional()
|
||||
});
|
||||
function hasThumbsUpFrom(comment, username) {
|
||||
if (!comment.reactionGroups) return false;
|
||||
const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP");
|
||||
if (!thumbsUp?.reactors?.nodes) return false;
|
||||
return thumbsUp.reactors.nodes.some((r) => r?.login === username);
|
||||
}
|
||||
function flattenReplyToChain(replyTo) {
|
||||
if (!replyTo) return [];
|
||||
const parent = flattenReplyToChain(replyTo.replyTo ?? null);
|
||||
return [...parent, { body: replyTo.body, author: replyTo.author?.login ?? "unknown" }];
|
||||
function threadHasThumbsUpFrom(thread, username) {
|
||||
const comments = thread.comments?.nodes ?? [];
|
||||
return comments.some((c) => c && hasThumbsUpFrom(c, username));
|
||||
}
|
||||
function formatReviewThreads(threadBlocks, header) {
|
||||
const tocHeaderLines = 4;
|
||||
const tocFooterLines = 3;
|
||||
let currentLine = tocHeaderLines + threadBlocks.length + tocFooterLines + 1;
|
||||
const tocEntries = [];
|
||||
const threadLines = [];
|
||||
for (const block of threadBlocks) {
|
||||
const startLine = currentLine;
|
||||
const actualLineCount = block.content.reduce((sum, line) => sum + line.split("\n").length, 0);
|
||||
const endLine = currentLine + actualLineCount - 1;
|
||||
tocEntries.push(`- ${block.path}:${block.lineRange} \u2192 lines ${startLine}-${endLine}`);
|
||||
threadLines.push(...block.content);
|
||||
currentLine += actualLineCount;
|
||||
}
|
||||
const lines = [];
|
||||
lines.push(
|
||||
`# Review Threads (${threadBlocks.length}) for PR #${header.pullNumber} - Review ${header.reviewId} by ${header.reviewer}`
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("## TOC");
|
||||
lines.push("");
|
||||
lines.push(...tocEntries);
|
||||
lines.push("");
|
||||
lines.push("---");
|
||||
lines.push("");
|
||||
lines.push(...threadLines);
|
||||
return {
|
||||
toc: tocEntries.join("\n"),
|
||||
content: lines.join("\n")
|
||||
};
|
||||
}
|
||||
function buildThreadBlocks(threads, filePatchMap, reviewId) {
|
||||
const firstMatchingComment = threads[0]?.comments?.nodes?.find(
|
||||
(c) => c?.pullRequestReview?.databaseId === reviewId
|
||||
);
|
||||
const reviewer = firstMatchingComment?.pullRequestReview?.author?.login ?? "unknown";
|
||||
threads.sort((a, b) => {
|
||||
const pathCmp = a.path.localeCompare(b.path);
|
||||
if (pathCmp !== 0) return pathCmp;
|
||||
const aLine = a.startLine ?? a.line ?? 0;
|
||||
const bLine = b.startLine ?? b.line ?? 0;
|
||||
return aLine - bLine;
|
||||
});
|
||||
const threadBlocks = [];
|
||||
for (const thread of threads) {
|
||||
const allComments = (thread.comments?.nodes ?? []).filter(
|
||||
(c) => c !== null
|
||||
);
|
||||
if (allComments.length === 0) continue;
|
||||
const firstComment = allComments[0];
|
||||
const line = thread.line ?? firstComment?.line ?? firstComment?.originalLine ?? thread.startLine ?? 0;
|
||||
const startLine = thread.startLine ?? firstComment?.startLine ?? firstComment?.originalStartLine ?? line;
|
||||
const lineRange = startLine === line ? `${line}` : `${startLine}-${line}`;
|
||||
const block = [];
|
||||
const status = thread.isResolved ? " [RESOLVED]" : thread.isOutdated ? " [OUTDATED]" : "";
|
||||
block.push(`## ${thread.path}:${lineRange}${status}`);
|
||||
block.push("");
|
||||
for (const comment of allComments) {
|
||||
const author = comment.author?.login ?? "unknown";
|
||||
const isTargetReview = comment.pullRequestReview?.databaseId === reviewId;
|
||||
const marker = isTargetReview ? " *" : "";
|
||||
block.push(
|
||||
`\`\`\`\`comment author=${author} id=${comment.fullDatabaseId ?? "unknown"} review=${comment.pullRequestReview?.databaseId ?? "unknown"}${marker}`
|
||||
);
|
||||
block.push(comment.body || "(no comment body)");
|
||||
block.push("````");
|
||||
block.push("");
|
||||
}
|
||||
const fileHunks = filePatchMap.get(thread.path);
|
||||
const firstCommentWithHunk = allComments.find((c) => c.diffHunk);
|
||||
let diffContent = null;
|
||||
if (fileHunks && fileHunks.length > 0) {
|
||||
const overlapping = findOverlappingHunks(fileHunks, startLine, line, thread.diffSide);
|
||||
if (overlapping.length > 0) {
|
||||
diffContent = extractFromFilePatches(fileHunks, startLine, line, thread.diffSide);
|
||||
}
|
||||
}
|
||||
if (!diffContent && firstCommentWithHunk) {
|
||||
diffContent = extractCommentedLines(
|
||||
firstCommentWithHunk.diffHunk,
|
||||
startLine,
|
||||
line,
|
||||
thread.diffSide
|
||||
);
|
||||
}
|
||||
if (diffContent) {
|
||||
block.push(`\`\`\`diff file=${thread.path} lines=${lineRange} side=${thread.diffSide}`);
|
||||
block.push(diffContent);
|
||||
block.push("```");
|
||||
block.push("");
|
||||
} else {
|
||||
block.push(`\`\`\`diff file=${thread.path} lines=${lineRange} side=${thread.diffSide}`);
|
||||
block.push(`(no diff context available - comment on unchanged lines)`);
|
||||
block.push("```");
|
||||
block.push("");
|
||||
}
|
||||
threadBlocks.push({ path: thread.path, lineRange, content: block });
|
||||
}
|
||||
return { threadBlocks, reviewer };
|
||||
}
|
||||
var 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 \u{1F44D} to").optional()
|
||||
});
|
||||
function GetReviewCommentsTool(ctx) {
|
||||
return tool({
|
||||
name: "get_review_comments",
|
||||
description: "Get review comments for a pull request review, including thread 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 in XML format.",
|
||||
description: "Get review comments for a pull request review with full thread context. When approved_by is provided, only returns threads where that user gave a \u{1F44D} to at least one comment. Returns a TOC and commentsPath pointing to a markdown file with full comment details.",
|
||||
parameters: GetReviewComments,
|
||||
execute: execute(async ({ pull_number, review_id, approved_by }) => {
|
||||
const { data: review } = await ctx.octokit.rest.pulls.getReview({
|
||||
execute: execute(async (params) => {
|
||||
const response = await ctx.octokit.graphql(REVIEW_THREADS_QUERY, {
|
||||
owner: ctx.repo.owner,
|
||||
name: ctx.repo.name,
|
||||
prNumber: params.pull_number
|
||||
});
|
||||
const allThreads = response.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
||||
let threadsForReview = allThreads.filter((thread) => {
|
||||
if (!thread?.comments?.nodes) return false;
|
||||
return thread.comments.nodes.some(
|
||||
(c) => c?.pullRequestReview?.databaseId === params.review_id
|
||||
);
|
||||
});
|
||||
if (params.approved_by) {
|
||||
threadsForReview = threadsForReview.filter(
|
||||
(thread) => threadHasThumbsUpFrom(thread, params.approved_by)
|
||||
);
|
||||
}
|
||||
if (threadsForReview.length === 0) {
|
||||
return {
|
||||
review_id: params.review_id,
|
||||
pull_number: params.pull_number,
|
||||
reviewer: "unknown",
|
||||
threadCount: 0,
|
||||
commentsPath: null,
|
||||
toc: null,
|
||||
instructions: params.approved_by ? `no threads with \u{1F44D} from ${params.approved_by}` : "no threads found for this review"
|
||||
};
|
||||
}
|
||||
const prFilesResponse = await ctx.octokit.rest.pulls.listFiles({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
review_id
|
||||
pull_number: params.pull_number
|
||||
});
|
||||
const reviewer = review.user?.login ?? "unknown";
|
||||
const response = await ctx.octokit.graphql(REVIEW_QUERY, {
|
||||
nodeId: review.node_id
|
||||
});
|
||||
const reviewComments = response.node?.comments?.nodes;
|
||||
if (!reviewComments) {
|
||||
return {
|
||||
review_id,
|
||||
pull_number,
|
||||
reviewer,
|
||||
count: 0,
|
||||
commentsPath: null,
|
||||
message: "No comments found for this review"
|
||||
};
|
||||
}
|
||||
const allComments = reviewComments.filter((c) => c !== null);
|
||||
const comments = approved_by ? allComments.filter((c) => hasThumbsUpFrom(c, approved_by)) : allComments;
|
||||
if (comments.length === 0) {
|
||||
return {
|
||||
review_id,
|
||||
pull_number,
|
||||
reviewer,
|
||||
count: 0,
|
||||
commentsPath: null,
|
||||
message: approved_by ? `No comments with \u{1F44D} from ${approved_by}` : "No comments found for this review"
|
||||
};
|
||||
}
|
||||
const lines = [];
|
||||
lines.push(`<review_comments count="${comments.length}" reviewer="${escapeXml(reviewer)}">`);
|
||||
lines.push("");
|
||||
lines.push("<summary>");
|
||||
for (const comment of comments) {
|
||||
const line = comment.line ?? comment.startLine ?? 0;
|
||||
const preview = escapeXml(truncateBody(comment.body));
|
||||
lines.push(
|
||||
` <comment id="${comment.databaseId}" file="${escapeXml(comment.path)}" line="${line}">${preview}</comment>`
|
||||
);
|
||||
}
|
||||
lines.push("</summary>");
|
||||
lines.push("");
|
||||
for (const comment of comments) {
|
||||
const line = comment.line ?? comment.startLine ?? 0;
|
||||
const author = comment.author?.login ?? "unknown";
|
||||
lines.push(
|
||||
`<comment id="${comment.databaseId}" file="${escapeXml(comment.path)}" line="${line}" author="${escapeXml(author)}">`
|
||||
);
|
||||
const thread = flattenReplyToChain(comment.replyTo);
|
||||
if (thread.length > 0) {
|
||||
lines.push(" <thread>");
|
||||
for (const msg of thread) {
|
||||
lines.push(
|
||||
` <message author="${escapeXml(msg.author)}">${escapeXml(msg.body)}</message>`
|
||||
);
|
||||
}
|
||||
lines.push(" </thread>");
|
||||
const filePatchMap = /* @__PURE__ */ new Map();
|
||||
for (const file2 of prFilesResponse.data) {
|
||||
if (file2.patch) {
|
||||
filePatchMap.set(file2.filename, parseFilePatches(file2.patch));
|
||||
}
|
||||
lines.push(" <diff>");
|
||||
lines.push(escapeXml(comment.diffHunk));
|
||||
lines.push(" </diff>");
|
||||
lines.push(` <body>${escapeXml(comment.body)}</body>`);
|
||||
lines.push("</comment>");
|
||||
lines.push("");
|
||||
}
|
||||
lines.push("</review_comments>");
|
||||
const content = lines.join("\n");
|
||||
const { threadBlocks, reviewer } = buildThreadBlocks(
|
||||
threadsForReview,
|
||||
filePatchMap,
|
||||
params.review_id
|
||||
);
|
||||
const formatted = formatReviewThreads(threadBlocks, {
|
||||
pullNumber: params.pull_number,
|
||||
reviewId: params.review_id,
|
||||
reviewer
|
||||
});
|
||||
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}.xml` : `review-${review_id}-comments.xml`;
|
||||
const filename = `review-${params.review_id}-threads.md`;
|
||||
const commentsPath = join6(tempDir, filename);
|
||||
writeFileSync4(commentsPath, content);
|
||||
log.info(`wrote ${comments.length} comments to ${commentsPath}`);
|
||||
log.box(content);
|
||||
writeFileSync4(commentsPath, formatted.content);
|
||||
log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`);
|
||||
return {
|
||||
review_id,
|
||||
pull_number,
|
||||
review_id: params.review_id,
|
||||
pull_number: params.pull_number,
|
||||
reviewer,
|
||||
count: comments.length,
|
||||
commentsPath
|
||||
threadCount: threadBlocks.length,
|
||||
commentsPath,
|
||||
toc: formatted.toc,
|
||||
instructions: `the file at commentsPath contains ${threadBlocks.length} review threads with full conversation history. comments marked with * are from the target review (${params.review_id}). the TOC shows each thread's file:line and the line number where it appears in the file. to read a specific thread, use: grep -A 50 "^## <file:line>" ${commentsPath} (replace <file:line> with the path from the TOC, e.g. "^## action/utils/foo.ts:42"). address each thread in order, working through one file at a time.`
|
||||
};
|
||||
})
|
||||
});
|
||||
@@ -138876,14 +139082,14 @@ function ListPullRequestReviewsTool(ctx) {
|
||||
name: "list_pull_request_reviews",
|
||||
description: "List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
|
||||
parameters: ListPullRequestReviews,
|
||||
execute: execute(async ({ pull_number }) => {
|
||||
execute: execute(async (params) => {
|
||||
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number
|
||||
pull_number: params.pull_number
|
||||
});
|
||||
return {
|
||||
pull_number,
|
||||
pull_number: params.pull_number,
|
||||
reviews: reviews.map((review) => ({
|
||||
id: review.id,
|
||||
node_id: review.node_id,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`fetchAndFormatPrDiff > fetches PR files and generates TOC with formatted diff > content 1`] = `
|
||||
"## Files (3)
|
||||
- .github/workflows/test.yml → lines 7-47
|
||||
- index.test.ts → lines 48-110
|
||||
- index.ts → lines 111-132
|
||||
|
||||
---
|
||||
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
|
||||
--- a/.github/workflows/test.yml
|
||||
+++ b/.github/workflows/test.yml
|
||||
@@ -0,0 +1,36 @@
|
||||
| | 1 | + | name: Test
|
||||
| | 2 | + |
|
||||
| | 3 | + | on:
|
||||
| | 4 | + | push:
|
||||
| | 5 | + | branches: [main]
|
||||
| | 6 | + | pull_request:
|
||||
| | 7 | + | branches: [main]
|
||||
| | 8 | + |
|
||||
| | 9 | + | jobs:
|
||||
| | 10 | + | test:
|
||||
| | 11 | + | runs-on: ubuntu-latest
|
||||
| | 12 | + |
|
||||
| | 13 | + | strategy:
|
||||
| | 14 | + | matrix:
|
||||
| | 15 | + | node-version: [22.x]
|
||||
| | 16 | + |
|
||||
| | 17 | + | steps:
|
||||
| | 18 | + | - name: Checkout code
|
||||
| | 19 | + | uses: actions/checkout@v4
|
||||
| | 20 | + |
|
||||
| | 21 | + | - name: Setup pnpm
|
||||
| | 22 | + | uses: pnpm/action-setup@v2
|
||||
| | 23 | + | with:
|
||||
| | 24 | + | version: 8
|
||||
| | 25 | + |
|
||||
| | 26 | + | - name: Setup Node.js \${{ matrix.node-version }}
|
||||
| | 27 | + | uses: actions/setup-node@v4
|
||||
| | 28 | + | with:
|
||||
| | 29 | + | node-version: \${{ matrix.node-version }}
|
||||
| | 30 | + | cache: 'pnpm'
|
||||
| | 31 | + |
|
||||
| | 32 | + | - name: Install dependencies
|
||||
| | 33 | + | run: pnpm install
|
||||
| | 34 | + |
|
||||
| | 35 | + | - name: Run tests
|
||||
| | 36 | + | run: pnpm test
|
||||
|
||||
diff --git a/index.test.ts b/index.test.ts
|
||||
--- a/index.test.ts
|
||||
+++ b/index.test.ts
|
||||
@@ -1,5 +1,5 @@
|
||||
| 1 | 1 | | import { describe, it, expect } from 'vitest'
|
||||
| 2 | | - | import { add } from './index.js'
|
||||
| | 2 | + | import { add, multiply, subtract, divide } from './index.js'
|
||||
| 3 | 3 | |
|
||||
| 4 | 4 | | describe('add function', () => {
|
||||
| 5 | 5 | | it('should add two positive numbers correctly', () => {
|
||||
@@ -25,3 +25,51 @@ describe('add function', () => {
|
||||
| 25 | 25 | | expect(add(0.1, 0.2)).toBeCloseTo(0.3)
|
||||
| 26 | 26 | | })
|
||||
| 27 | 27 | | })
|
||||
| | 28 | + |
|
||||
| | 29 | + | describe('multiply function', () => {
|
||||
| | 30 | + | it('should multiply two positive numbers correctly', () => {
|
||||
| | 31 | + | expect(multiply(3, 4)).toBe(12)
|
||||
| | 32 | + | })
|
||||
| | 33 | + |
|
||||
| | 34 | + | it('should multiply negative numbers correctly', () => {
|
||||
| | 35 | + | expect(multiply(-2, 3)).toBe(-6)
|
||||
| | 36 | + | expect(multiply(-2, -3)).toBe(6)
|
||||
| | 37 | + | })
|
||||
| | 38 | + |
|
||||
| | 39 | + | it('should handle zero correctly', () => {
|
||||
| | 40 | + | expect(multiply(5, 0)).toBe(0)
|
||||
| | 41 | + | expect(multiply(0, 5)).toBe(0)
|
||||
| | 42 | + | })
|
||||
| | 43 | + | })
|
||||
| | 44 | + |
|
||||
| | 45 | + | describe('subtract function', () => {
|
||||
| | 46 | + | it('should subtract two positive numbers correctly', () => {
|
||||
| | 47 | + | expect(subtract(10, 3)).toBe(7)
|
||||
| | 48 | + | })
|
||||
| | 49 | + |
|
||||
| | 50 | + | it('should handle negative numbers correctly', () => {
|
||||
| | 51 | + | expect(subtract(5, -3)).toBe(8)
|
||||
| | 52 | + | expect(subtract(-5, 3)).toBe(-8)
|
||||
| | 53 | + | })
|
||||
| | 54 | + |
|
||||
| | 55 | + | it('should handle zero correctly', () => {
|
||||
| | 56 | + | expect(subtract(5, 0)).toBe(5)
|
||||
| | 57 | + | expect(subtract(0, 5)).toBe(-5)
|
||||
| | 58 | + | })
|
||||
| | 59 | + | })
|
||||
| | 60 | + |
|
||||
| | 61 | + | describe('divide function', () => {
|
||||
| | 62 | + | it('should divide two positive numbers correctly', () => {
|
||||
| | 63 | + | expect(divide(10, 2)).toBe(5)
|
||||
| | 64 | + | })
|
||||
| | 65 | + |
|
||||
| | 66 | + | it('should handle negative numbers correctly', () => {
|
||||
| | 67 | + | expect(divide(-10, 2)).toBe(-5)
|
||||
| | 68 | + | expect(divide(10, -2)).toBe(-5)
|
||||
| | 69 | + | })
|
||||
| | 70 | + |
|
||||
| | 71 | + | it('should handle decimal results correctly', () => {
|
||||
| | 72 | + | expect(divide(10, 3)).toBeCloseTo(3.333, 2)
|
||||
| | 73 | + | expect(divide(7, 2)).toBe(3.5)
|
||||
| | 74 | + | })
|
||||
| | 75 | + | })
|
||||
|
||||
diff --git a/index.ts b/index.ts
|
||||
--- a/index.ts
|
||||
+++ b/index.ts
|
||||
@@ -3,11 +3,13 @@ export function add(a: number, b: number) {
|
||||
| 3 | 3 | | }
|
||||
| 4 | 4 | |
|
||||
| 5 | 5 | | export function multiply(a: number, b: number) {
|
||||
| 6 | | - | // Bug: accidentally adding 1 to the result
|
||||
| 7 | | - | return a * b + 1;
|
||||
| | 6 | + | return a * b;
|
||||
| 8 | 7 | | }
|
||||
| 9 | 8 | |
|
||||
| 10 | 9 | | export function subtract(a: number, b: number) {
|
||||
| 11 | | - | // Bug: accidentally adding instead of subtracting
|
||||
| 12 | | - | return a + b;
|
||||
| | 10 | + | return a - b;
|
||||
| | 11 | + | }
|
||||
| | 12 | + |
|
||||
| | 13 | + | export function divide(a: number, b: number) {
|
||||
| | 14 | + | return a / b;
|
||||
| 13 | 15 | | }
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`fetchAndFormatPrDiff > fetches PR files and generates TOC with formatted diff > toc 1`] = `
|
||||
"## Files (3)
|
||||
- .github/workflows/test.yml → lines 7-47
|
||||
- index.test.ts → lines 48-110
|
||||
- index.ts → lines 111-132
|
||||
|
||||
---
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,42 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`formatReviewThreads > formats thread blocks with TOC and correct line numbers > content 1`] = `
|
||||
"# Review Threads (1) for PR #49 - Review 3485940013 by cursor
|
||||
|
||||
## TOC
|
||||
|
||||
- .github/workflows/test.yml:7 → lines 9-36
|
||||
|
||||
---
|
||||
|
||||
## .github/workflows/test.yml:7 [RESOLVED]
|
||||
|
||||
\`\`\`\`comment author=cursor id=2544544046 review=3485940013 *
|
||||
### Bug: GitHub Actions workflow triggered for wrong branch
|
||||
|
||||
<!-- **High Severity** -->
|
||||
|
||||
<!-- DESCRIPTION START -->
|
||||
The \`pull_request\` trigger specifies \`branches: [mainc]\`, but the \`push\` trigger specifies \`branches: [main]\`. This mismatch means pull requests will only trigger tests if targeting a non-existent \`mainc\` branch rather than the actual \`main\` development branch, preventing CI from running on most pull requests.
|
||||
<!-- DESCRIPTION END -->
|
||||
|
||||
<!-- LOCATIONS START
|
||||
.github/workflows/test.yml#L6-L7
|
||||
LOCATIONS END -->
|
||||
<a href="https://cursor.com/open?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MSJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9DVVJTT1IiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjllMTgyY2U2LWY0YWMtNDAwNS1hMzQ4LWIyYzJkZTk4OGM1ZSIsImVuY3J5cHRpb25LZXkiOiJmSW93NEdsUGUwYlYtd3M2UC1UNHdHT1JmMGZjakxfWVZEdC00SWNveXo0IiwiYnJhbmNoIjoiZGl2aWRlIn0sImlhdCI6MTc2MzYyMDgxOSwiZXhwIjoxNzY0MjI1NjE5fQ.BjkWsTqiNriojI5v10JcveUY2M50f9eflTNDgWAdjdW9w7E0EEY4GJfyzBrA72neco3qAlc34WipASNuEQbTD1fZvwtJY-TeNTDzoKmwA6gtwICB8t7qT87GPvcbDrdGGWdC8kW1jf-LntTmD0k7gt0AeENRAdRSiD3dbqYFN0huXHaB8f2Y48mpmLcnnUpoaaZe7By-Y0DnILyHppwx3AH75nKE_ZeAee3rQNGX4cwcHgB5emTSM93pMDQhT1vbIRYHMaFkOaW2-kDOA8H2QqxD4mT8VzY3skvxIo5HNZCvqE84NtEygHqkBv88g2EEijOPAAeskfsdp087yIzV9g"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/fix-in-cursor-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/fix-in-cursor-light.svg"><img alt="Fix in Cursor" src="https://cursor.com/fix-in-cursor.svg"></picture></a> <a href="https://cursor.com/agents?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MSJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9XRUIiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjllMTgyY2U2LWY0YWMtNDAwNS1hMzQ4LWIyYzJkZTk4OGM1ZSIsImVuY3J5cHRpb25LZXkiOiJmSW93NEdsUGUwYlYtd3M2UC1UNHdHT1JmMGZjakxfWVZEdC00SWNveXo0IiwiYnJhbmNoIjoiZGl2aWRlIiwicmVwb093bmVyIjoicHVsbGZyb2dhaSIsInJlcG9OYW1lIjoic2NyYXRjaCIsInByTnVtYmVyIjo0OSwiY29tbWl0U2hhIjoiNThiOGJmNmQ1MWE1Mjg4OGFjNGFkNzA5YWVmYTk2MWFkZDMyNDBiMSJ9LCJpYXQiOjE3NjM2MjA4MTksImV4cCI6MTc2NDIyNTYxOX0.SFDZe8R9uwhPjS55J4i_mV2ybsSZoQYM6YzdUOava4IKy1IK2OrVkVsG3-8p4rRaMBXdDZZ4ObPbtk70KqdAiLEDKBqaFcWqELc49lr0XRKUmu4F6EhESFQOvt7MLSVDIOgee8YRlhS6xtoPDqsRiV2KGOwyLEdCeYdrYz9i1DanIswWSoMRVvkjxZ6GUBYVAUg_JsgAXoKVJ-L9Q5Ygho6acVAr5NlGeBp2f6g49GX4GfDOPeV3SORQS1CjxQVRbjI-g0rW55NIisBEl8279VwG6-dTISNbyasZOB6R3eEmC4vmyAAGJjUsMwqhMPw1oaMMmYNSbtZLDESxME9IUg"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/fix-in-web-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/fix-in-web-light.svg"><img alt="Fix in Web" src="https://cursor.com/fix-in-web.svg"></picture></a>
|
||||
|
||||
|
||||
\`\`\`\`
|
||||
|
||||
\`\`\`diff file=.github/workflows/test.yml lines=7 side=RIGHT
|
||||
@@ -0,0 +1,36 @@
|
||||
... (3 lines above) ...
|
||||
+ push:
|
||||
+ branches: [main]
|
||||
+ pull_request:
|
||||
+ branches: [main]
|
||||
\`\`\`
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`formatReviewThreads > formats thread blocks with TOC and correct line numbers > toc 1`] = `"- .github/workflows/test.yml:7 → lines 9-36"`;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fetchAndFormatPrDiff } from "./checkout.ts";
|
||||
|
||||
describe("fetchAndFormatPrDiff", () => {
|
||||
it("fetches PR files and generates TOC with formatted diff", async () => {
|
||||
const token = process.env.GH_TOKEN;
|
||||
if (!token) {
|
||||
throw new Error("GH_TOKEN not set in .env");
|
||||
}
|
||||
|
||||
const octokit = new Octokit({ auth: token });
|
||||
const result = await fetchAndFormatPrDiff({
|
||||
octokit,
|
||||
owner: "pullfrog",
|
||||
repo: "scratch",
|
||||
pullNumber: 49,
|
||||
});
|
||||
|
||||
// verify TOC structure
|
||||
expect(result.toc).toContain("## Files");
|
||||
expect(result.toc).toContain("→ lines");
|
||||
|
||||
// verify content includes TOC at the start
|
||||
expect(result.content.startsWith(result.toc)).toBe(true);
|
||||
|
||||
// verify content includes diff headers
|
||||
expect(result.content).toContain("diff --git");
|
||||
expect(result.content).toContain("---");
|
||||
expect(result.content).toContain("+++");
|
||||
|
||||
// snapshot the full output
|
||||
expect(result.toc).toMatchSnapshot("toc");
|
||||
expect(result.content).toMatchSnapshot("content");
|
||||
});
|
||||
});
|
||||
+70
-9
@@ -9,23 +9,43 @@ import { execute, tool } from "./shared.ts";
|
||||
|
||||
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
|
||||
|
||||
export type FormatFilesResult = {
|
||||
content: string;
|
||||
toc: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* formats PR files with explicit line numbers for each code line.
|
||||
* preserves all original diff info (file headers, hunk headers) and adds:
|
||||
* | OLD | NEW | TYPE | code
|
||||
* returns both the formatted content and a TOC with line ranges per file.
|
||||
*/
|
||||
export function formatFilesWithLineNumbers(files: PullFile[]): string {
|
||||
export function formatFilesWithLineNumbers(files: PullFile[]): FormatFilesResult {
|
||||
const output: string[] = [];
|
||||
const tocEntries: Array<{ filename: string; startLine: number; endLine: number }> = [];
|
||||
|
||||
// calculate TOC header size: "## Files (N)\n" + N entries + "\n---\n\n"
|
||||
const tocHeaderSize = 1 + files.length + 2;
|
||||
let currentLine = tocHeaderSize + 1;
|
||||
|
||||
for (const file of files) {
|
||||
const fileStartLine = currentLine;
|
||||
|
||||
// file header
|
||||
output.push(`diff --git a/${file.filename} b/${file.filename}`);
|
||||
output.push(`--- a/${file.filename}`);
|
||||
output.push(`+++ b/${file.filename}`);
|
||||
currentLine += 3;
|
||||
|
||||
if (!file.patch) {
|
||||
output.push("(binary file or no changes)");
|
||||
output.push("");
|
||||
currentLine += 2;
|
||||
tocEntries.push({
|
||||
filename: file.filename,
|
||||
startLine: fileStartLine,
|
||||
endLine: currentLine - 1,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -41,6 +61,7 @@ export function formatFilesWithLineNumbers(files: PullFile[]): string {
|
||||
oldLine = parseInt(hunkMatch[1], 10);
|
||||
newLine = parseInt(hunkMatch[2], 10);
|
||||
output.push(line); // pass through unchanged
|
||||
currentLine++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -69,11 +90,31 @@ export function formatFilesWithLineNumbers(files: PullFile[]): string {
|
||||
// unknown line type, pass through
|
||||
output.push(line);
|
||||
}
|
||||
currentLine++;
|
||||
}
|
||||
output.push(""); // blank line between files
|
||||
currentLine++;
|
||||
|
||||
tocEntries.push({
|
||||
filename: file.filename,
|
||||
startLine: fileStartLine,
|
||||
endLine: currentLine - 1,
|
||||
});
|
||||
}
|
||||
|
||||
return output.join("\n");
|
||||
// build TOC
|
||||
const tocLines = [`## Files (${files.length})`];
|
||||
for (const entry of tocEntries) {
|
||||
tocLines.push(`- ${entry.filename} → lines ${entry.startLine}-${entry.endLine}`);
|
||||
}
|
||||
tocLines.push("");
|
||||
tocLines.push("---");
|
||||
tocLines.push("");
|
||||
|
||||
const toc = tocLines.join("\n");
|
||||
const content = toc + output.join("\n");
|
||||
|
||||
return { content, toc };
|
||||
}
|
||||
|
||||
function padNum(n: number): string {
|
||||
@@ -97,6 +138,27 @@ export type CheckoutPrResult = {
|
||||
diffPath: string;
|
||||
};
|
||||
|
||||
type FetchPrDiffParams = {
|
||||
octokit: Octokit;
|
||||
owner: string;
|
||||
repo: string;
|
||||
pullNumber: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* fetches PR files from GitHub and formats them with line numbers and TOC.
|
||||
* this is the core diff formatting logic, extracted for testability.
|
||||
*/
|
||||
export async function fetchAndFormatPrDiff(params: FetchPrDiffParams): Promise<FormatFilesResult> {
|
||||
const filesResponse = await params.octokit.rest.pulls.listFiles({
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
pull_number: params.pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return formatFilesWithLineNumbers(filesResponse.data);
|
||||
}
|
||||
|
||||
interface CheckoutPrBranchParams {
|
||||
octokit: Octokit;
|
||||
owner: string;
|
||||
@@ -243,14 +305,13 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
}
|
||||
|
||||
// fetch PR files and format with line numbers
|
||||
const filesResponse = await ctx.octokit.rest.pulls.listFiles({
|
||||
const formatResult = await fetchAndFormatPrDiff({
|
||||
octokit: ctx.octokit,
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
per_page: 100,
|
||||
pullNumber: pull_number,
|
||||
});
|
||||
const diffContent = formatFilesWithLineNumbers(filesResponse.data);
|
||||
const diffPreview = diffContent.split("\n").slice(0, 100).join("\n");
|
||||
const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
|
||||
log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
if (!tempDir) {
|
||||
@@ -259,8 +320,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
);
|
||||
}
|
||||
const diffPath = join(tempDir, `pr-${pull_number}.diff`);
|
||||
writeFileSync(diffPath, diffContent);
|
||||
log.debug(`wrote diff to ${diffPath} (${diffContent.length} bytes)`);
|
||||
writeFileSync(diffPath, formatResult.content);
|
||||
log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
+3
-3
@@ -28,7 +28,7 @@ export function CommitInfoTool(ctx: ToolContext) {
|
||||
const files = data.files ?? [];
|
||||
|
||||
// format diff with line numbers and write to file
|
||||
const diffContent = formatFilesWithLineNumbers(files);
|
||||
const formatResult = formatFilesWithLineNumbers(files);
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
if (!tempDir) {
|
||||
throw new Error(
|
||||
@@ -36,8 +36,8 @@ export function CommitInfoTool(ctx: ToolContext) {
|
||||
);
|
||||
}
|
||||
const diffFile = join(tempDir, `commit-${sha.slice(0, 7)}.diff`);
|
||||
writeFileSync(diffFile, diffContent);
|
||||
log.debug(`wrote commit diff to ${diffFile} (${diffContent.length} bytes)`);
|
||||
writeFileSync(diffFile, formatResult.content);
|
||||
log.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`);
|
||||
|
||||
return {
|
||||
sha: data.sha,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildThreadBlocks,
|
||||
formatReviewThreads,
|
||||
type ParsedHunk,
|
||||
parseFilePatches,
|
||||
REVIEW_THREADS_QUERY,
|
||||
type ReviewThread,
|
||||
type ReviewThreadsQueryResponse,
|
||||
} from "./reviewComments.ts";
|
||||
|
||||
describe("formatReviewThreads", () => {
|
||||
it("formats thread blocks with TOC and correct line numbers", async () => {
|
||||
const token = process.env.GH_TOKEN;
|
||||
if (!token) {
|
||||
throw new Error("GH_TOKEN is not set");
|
||||
}
|
||||
|
||||
const octokit = new Octokit({ auth: token });
|
||||
const pullNumber = 49;
|
||||
const reviewId = 3485940013;
|
||||
|
||||
// fetch review threads via GraphQL
|
||||
const response = await octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
|
||||
owner: "pullfrog",
|
||||
name: "scratch",
|
||||
prNumber: pullNumber,
|
||||
});
|
||||
|
||||
const allThreads = response.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
||||
const threadsForReview = allThreads.filter((thread): thread is ReviewThread => {
|
||||
if (!thread?.comments?.nodes) return false;
|
||||
return thread.comments.nodes.some((c) => c?.pullRequestReview?.databaseId === reviewId);
|
||||
});
|
||||
|
||||
// fetch file patches
|
||||
const prFilesResponse = await octokit.rest.pulls.listFiles({
|
||||
owner: "pullfrog",
|
||||
repo: "scratch",
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
const filePatchMap = new Map<string, ParsedHunk[]>();
|
||||
for (const file of prFilesResponse.data) {
|
||||
if (file.patch) {
|
||||
filePatchMap.set(file.filename, parseFilePatches(file.patch));
|
||||
}
|
||||
}
|
||||
|
||||
// build and format
|
||||
const { threadBlocks, reviewer } = buildThreadBlocks(threadsForReview, filePatchMap, reviewId);
|
||||
const result = formatReviewThreads(threadBlocks, { pullNumber, reviewId, reviewer });
|
||||
|
||||
expect(result.toc).toMatchSnapshot("toc");
|
||||
expect(result.content).toMatchSnapshot("content");
|
||||
});
|
||||
});
|
||||
+462
-198
@@ -5,64 +5,42 @@ import { log } from "../utils/log.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
// fragment for nested replyTo (5 levels deep covers most threads)
|
||||
// because in_reply_to_id generally points to the top-level comment
|
||||
// this doesn't actually work as expected
|
||||
// TOD: implement an API endpoint with aggressive caching that fetches the PR's reviewThreads via graphql
|
||||
// separately fetch all the comments associated with the particular review
|
||||
// merge them, then construct the full thread context
|
||||
const REPLY_TO_FRAGMENT = `
|
||||
replyTo {
|
||||
databaseId
|
||||
body
|
||||
author { login }
|
||||
replyTo {
|
||||
databaseId
|
||||
body
|
||||
author { login }
|
||||
replyTo {
|
||||
databaseId
|
||||
body
|
||||
author { login }
|
||||
replyTo {
|
||||
databaseId
|
||||
body
|
||||
author { login }
|
||||
replyTo {
|
||||
databaseId
|
||||
body
|
||||
author { login }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// fetch specific review by node ID with nested thread context (single efficient query)
|
||||
const REVIEW_QUERY = `
|
||||
query ($nodeId: ID!) {
|
||||
node(id: $nodeId) {
|
||||
... on PullRequestReview {
|
||||
databaseId
|
||||
author { login }
|
||||
comments(first: 100) {
|
||||
// GraphQL query to fetch all review threads for a PR with full comment history
|
||||
export const REVIEW_THREADS_QUERY = `
|
||||
query ($owner: String!, $name: String!, $prNumber: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $prNumber) {
|
||||
reviewThreads(first: 100) {
|
||||
nodes {
|
||||
databaseId
|
||||
body
|
||||
id
|
||||
path
|
||||
line
|
||||
startLine
|
||||
diffHunk
|
||||
url
|
||||
author { login }
|
||||
createdAt
|
||||
${REPLY_TO_FRAGMENT}
|
||||
reactionGroups {
|
||||
content
|
||||
reactors(first: 10) {
|
||||
nodes {
|
||||
... on Actor { login }
|
||||
diffSide
|
||||
isResolved
|
||||
isOutdated
|
||||
comments(first: 50) {
|
||||
nodes {
|
||||
fullDatabaseId
|
||||
body
|
||||
createdAt
|
||||
diffHunk
|
||||
line
|
||||
startLine
|
||||
originalLine
|
||||
originalStartLine
|
||||
author { login }
|
||||
pullRequestReview {
|
||||
databaseId
|
||||
author { login }
|
||||
}
|
||||
reactionGroups {
|
||||
content
|
||||
reactors(first: 10) {
|
||||
nodes {
|
||||
... on Actor { login }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,208 +51,494 @@ query ($nodeId: ID!) {
|
||||
}
|
||||
`;
|
||||
|
||||
// nested replyTo type (recursive up to 5 levels)
|
||||
type NestedReplyTo = {
|
||||
databaseId: number;
|
||||
export type ReviewThreadComment = {
|
||||
fullDatabaseId: string | null;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
diffHunk: string;
|
||||
line: number | null;
|
||||
startLine: number | null;
|
||||
originalLine: number | null;
|
||||
originalStartLine: number | null;
|
||||
author: { login: string } | null;
|
||||
replyTo?: NestedReplyTo | null;
|
||||
} | null;
|
||||
pullRequestReview: {
|
||||
databaseId: number | null;
|
||||
author: { login: string } | null;
|
||||
} | null;
|
||||
reactionGroups: Array<{
|
||||
content: string;
|
||||
reactors: { nodes: Array<{ login: string } | null> | null } | null;
|
||||
}> | null;
|
||||
};
|
||||
|
||||
type ReviewComment = {
|
||||
databaseId: number;
|
||||
body: string;
|
||||
export type ReviewThread = {
|
||||
id: string;
|
||||
path: string;
|
||||
line: number | null;
|
||||
startLine: number | null;
|
||||
diffHunk: string;
|
||||
url: string;
|
||||
author: { login: string } | null;
|
||||
createdAt: string;
|
||||
replyTo: NestedReplyTo;
|
||||
reactionGroups:
|
||||
| {
|
||||
content: string;
|
||||
reactors: { nodes: ({ login: string } | null)[] | null };
|
||||
}[]
|
||||
| null;
|
||||
diffSide: "LEFT" | "RIGHT";
|
||||
isResolved: boolean;
|
||||
isOutdated: boolean;
|
||||
comments: {
|
||||
nodes: (ReviewThreadComment | null)[] | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type ReviewQueryResponse = {
|
||||
node: {
|
||||
databaseId: number;
|
||||
author: { login: string } | null;
|
||||
comments: {
|
||||
nodes: (ReviewComment | null)[] | null;
|
||||
export type ReviewThreadsQueryResponse = {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
reviewThreads: {
|
||||
nodes: (ReviewThread | null)[] | null;
|
||||
} | null;
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const MAX_BODY_PREVIEW = 80;
|
||||
// extract exactly the commented line range from diffHunk, plus context
|
||||
const CONTEXT_PADDING = 3;
|
||||
|
||||
function truncateBody(body: string): string {
|
||||
const oneLine = body.replace(/\n/g, " ").trim();
|
||||
if (oneLine.length <= MAX_BODY_PREVIEW) return oneLine;
|
||||
return oneLine.slice(0, MAX_BODY_PREVIEW - 3) + "...";
|
||||
function extractCommentedLines(
|
||||
diffHunk: string,
|
||||
startLine: number | null,
|
||||
endLine: number | null,
|
||||
side: "LEFT" | "RIGHT"
|
||||
): string {
|
||||
const lines = diffHunk.split("\n");
|
||||
if (lines.length <= 1) return diffHunk;
|
||||
|
||||
const header = lines[0];
|
||||
const contentLines = lines.slice(1);
|
||||
|
||||
// parse header: @@ -old_start,old_count +new_start,new_count @@
|
||||
const headerMatch = header.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (!headerMatch) return diffHunk;
|
||||
|
||||
const hunkOldStart = parseInt(headerMatch[1], 10);
|
||||
const hunkNewStart = parseInt(headerMatch[2], 10);
|
||||
|
||||
// LEFT = old file (deletions), RIGHT = new file (additions)
|
||||
const hunkStart = side === "LEFT" ? hunkOldStart : hunkNewStart;
|
||||
const commentStart = startLine ?? endLine ?? hunkStart;
|
||||
const commentEnd = endLine ?? commentStart;
|
||||
|
||||
// walk through diff lines, tracking line numbers for both old and new files
|
||||
// - lines: old file only (LEFT)
|
||||
// + lines: new file only (RIGHT)
|
||||
// context lines: both files
|
||||
type DiffLine = { text: string; lineNum: number | null };
|
||||
const diffLines: DiffLine[] = [];
|
||||
let oldLineNum = hunkOldStart;
|
||||
let newLineNum = hunkNewStart;
|
||||
|
||||
for (const line of contentLines) {
|
||||
const prefix = line[0];
|
||||
if (prefix === "-") {
|
||||
// deletion - only has old line number
|
||||
diffLines.push({ text: line, lineNum: side === "LEFT" ? oldLineNum : null });
|
||||
oldLineNum++;
|
||||
} else if (prefix === "+") {
|
||||
// addition - only has new line number
|
||||
diffLines.push({ text: line, lineNum: side === "RIGHT" ? newLineNum : null });
|
||||
newLineNum++;
|
||||
} else {
|
||||
// context - has both line numbers
|
||||
diffLines.push({ text: line, lineNum: side === "LEFT" ? oldLineNum : newLineNum });
|
||||
oldLineNum++;
|
||||
newLineNum++;
|
||||
}
|
||||
}
|
||||
|
||||
// find lines for comment range with context
|
||||
const targetStart = commentStart - CONTEXT_PADDING;
|
||||
const targetEnd = commentEnd;
|
||||
|
||||
const result: string[] = [];
|
||||
let truncatedBefore = 0;
|
||||
|
||||
for (let i = 0; i < diffLines.length; i++) {
|
||||
const dl = diffLines[i];
|
||||
// include if: within target range, OR it's an "other side" line adjacent to included lines
|
||||
const inRange = dl.lineNum !== null && dl.lineNum >= targetStart && dl.lineNum <= targetEnd;
|
||||
// include opposite-side lines if they're between included lines
|
||||
const adjacentOtherSide = dl.lineNum === null && result.length > 0 && i < diffLines.length - 1;
|
||||
|
||||
if (inRange || adjacentOtherSide) {
|
||||
result.push(dl.text);
|
||||
} else if (result.length === 0) {
|
||||
truncatedBefore++;
|
||||
}
|
||||
}
|
||||
|
||||
if (truncatedBefore > 0) {
|
||||
return `${header}\n... (${truncatedBefore} lines above) ...\n${result.join("\n")}`;
|
||||
}
|
||||
return `${header}\n${result.join("\n")}`;
|
||||
}
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
// parsed hunk from a unified diff
|
||||
export type ParsedHunk = {
|
||||
header: string;
|
||||
oldStart: number;
|
||||
oldCount: number;
|
||||
newStart: number;
|
||||
newCount: number;
|
||||
content: string[];
|
||||
};
|
||||
|
||||
// parse a full file patch into individual hunks
|
||||
export function parseFilePatches(patch: string): ParsedHunk[] {
|
||||
const hunks: ParsedHunk[] = [];
|
||||
const lines = patch.split("\n");
|
||||
|
||||
let currentHunk: ParsedHunk | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const hunkMatch = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
||||
if (hunkMatch) {
|
||||
if (currentHunk) hunks.push(currentHunk);
|
||||
currentHunk = {
|
||||
header: line,
|
||||
oldStart: parseInt(hunkMatch[1], 10),
|
||||
oldCount: parseInt(hunkMatch[2] ?? "1", 10),
|
||||
newStart: parseInt(hunkMatch[3], 10),
|
||||
newCount: parseInt(hunkMatch[4] ?? "1", 10),
|
||||
content: [],
|
||||
};
|
||||
} else if (currentHunk) {
|
||||
currentHunk.content.push(line);
|
||||
}
|
||||
}
|
||||
if (currentHunk) hunks.push(currentHunk);
|
||||
|
||||
return hunks;
|
||||
}
|
||||
|
||||
function hasThumbsUpFrom(comment: ReviewComment, username: string): boolean {
|
||||
if (!comment.reactionGroups) return false;
|
||||
const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP");
|
||||
if (!thumbsUp?.reactors?.nodes) return false;
|
||||
return thumbsUp.reactors.nodes.some((r) => r?.login === username);
|
||||
// find hunks that overlap with a line range (for LEFT or RIGHT side)
|
||||
function findOverlappingHunks(
|
||||
hunks: ParsedHunk[],
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
side: "LEFT" | "RIGHT"
|
||||
): ParsedHunk[] {
|
||||
return hunks.filter((hunk) => {
|
||||
const hunkStart = side === "LEFT" ? hunk.oldStart : hunk.newStart;
|
||||
const hunkCount = side === "LEFT" ? hunk.oldCount : hunk.newCount;
|
||||
const hunkEnd = hunkStart + hunkCount - 1;
|
||||
|
||||
// check for overlap: ranges overlap if start1 <= end2 && start2 <= end1
|
||||
return startLine <= hunkEnd && hunkStart <= endLine;
|
||||
});
|
||||
}
|
||||
|
||||
// flatten nested replyTo chain into array (oldest first)
|
||||
function flattenReplyToChain(replyTo: NestedReplyTo): Array<{ body: string; author: string }> {
|
||||
if (!replyTo) return [];
|
||||
const parent = flattenReplyToChain(replyTo.replyTo ?? null);
|
||||
return [...parent, { body: replyTo.body, author: replyTo.author?.login ?? "unknown" }];
|
||||
// extract diff content from multiple hunks for a comment range
|
||||
function extractFromFilePatches(
|
||||
hunks: ParsedHunk[],
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
side: "LEFT" | "RIGHT"
|
||||
): string {
|
||||
const overlapping = findOverlappingHunks(hunks, startLine, endLine, side);
|
||||
|
||||
if (overlapping.length === 0) {
|
||||
return `(no diff hunks found for lines ${startLine}-${endLine})`;
|
||||
}
|
||||
|
||||
if (overlapping.length === 1) {
|
||||
// single hunk - use existing extraction logic
|
||||
const hunk = overlapping[0];
|
||||
const fullHunk = hunk.header + "\n" + hunk.content.join("\n");
|
||||
return extractCommentedLines(fullHunk, startLine, endLine, side);
|
||||
}
|
||||
|
||||
// multiple hunks - combine them with gap indicators
|
||||
const result: string[] = [];
|
||||
let prevHunkEnd = 0;
|
||||
|
||||
for (let i = 0; i < overlapping.length; i++) {
|
||||
const hunk = overlapping[i];
|
||||
const hunkStart = side === "LEFT" ? hunk.oldStart : hunk.newStart;
|
||||
const hunkCount = side === "LEFT" ? hunk.oldCount : hunk.newCount;
|
||||
const hunkEnd = hunkStart + hunkCount - 1;
|
||||
|
||||
// add gap indicator if there's a gap between hunks
|
||||
if (i > 0 && hunkStart > prevHunkEnd + 1) {
|
||||
const gapSize = hunkStart - prevHunkEnd - 1;
|
||||
result.push(`\n... (${gapSize} unchanged lines) ...\n`);
|
||||
}
|
||||
|
||||
// add the hunk header and content
|
||||
result.push(hunk.header);
|
||||
result.push(...hunk.content);
|
||||
|
||||
prevHunkEnd = hunkEnd;
|
||||
}
|
||||
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
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")
|
||||
.describe(
|
||||
"Optional GitHub username - only return threads where this user gave a 👍 to at least one comment"
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function hasThumbsUpFrom(comment: ReviewThreadComment, username: string): boolean {
|
||||
if (!comment.reactionGroups) return false;
|
||||
const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP");
|
||||
if (!thumbsUp?.reactors?.nodes) return false;
|
||||
return thumbsUp.reactors.nodes.some((r) => r?.login === username);
|
||||
}
|
||||
|
||||
function threadHasThumbsUpFrom(thread: ReviewThread, username: string): boolean {
|
||||
const comments = thread.comments?.nodes ?? [];
|
||||
return comments.some((c) => c && hasThumbsUpFrom(c, username));
|
||||
}
|
||||
|
||||
/**
|
||||
* formats thread blocks into markdown with TOC and line numbers.
|
||||
* extracted for testability.
|
||||
*/
|
||||
export function formatReviewThreads(
|
||||
threadBlocks: Array<{ path: string; lineRange: string; content: string[] }>,
|
||||
header: { pullNumber: number; reviewId: number; reviewer: string }
|
||||
) {
|
||||
// header section takes: title (1) + blank (1) + "## TOC" (1) + blank (1) + N TOC entries + blank (1) + "---" (1) + blank (1)
|
||||
const tocHeaderLines = 4;
|
||||
const tocFooterLines = 3;
|
||||
let currentLine = tocHeaderLines + threadBlocks.length + tocFooterLines + 1;
|
||||
|
||||
const tocEntries: string[] = [];
|
||||
const threadLines: string[] = [];
|
||||
|
||||
for (const block of threadBlocks) {
|
||||
const startLine = currentLine;
|
||||
const actualLineCount = block.content.reduce((sum, line) => sum + line.split("\n").length, 0);
|
||||
const endLine = currentLine + actualLineCount - 1;
|
||||
tocEntries.push(`- ${block.path}:${block.lineRange} → lines ${startLine}-${endLine}`);
|
||||
threadLines.push(...block.content);
|
||||
currentLine += actualLineCount;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
`# Review Threads (${threadBlocks.length}) for PR #${header.pullNumber} - Review ${header.reviewId} by ${header.reviewer}`
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("## TOC");
|
||||
lines.push("");
|
||||
lines.push(...tocEntries);
|
||||
lines.push("");
|
||||
lines.push("---");
|
||||
lines.push("");
|
||||
lines.push(...threadLines);
|
||||
|
||||
return {
|
||||
toc: tocEntries.join("\n"),
|
||||
content: lines.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* builds thread blocks from review threads and file patches.
|
||||
* extracted for testability.
|
||||
*/
|
||||
export function buildThreadBlocks(
|
||||
threads: ReviewThread[],
|
||||
filePatchMap: Map<string, ParsedHunk[]>,
|
||||
reviewId: number
|
||||
) {
|
||||
// get reviewer from first matching comment
|
||||
const firstMatchingComment = threads[0]?.comments?.nodes?.find(
|
||||
(c) => c?.pullRequestReview?.databaseId === reviewId
|
||||
);
|
||||
const reviewer = firstMatchingComment?.pullRequestReview?.author?.login ?? "unknown";
|
||||
|
||||
// sort threads by file path, then by line number
|
||||
threads.sort((a, b) => {
|
||||
const pathCmp = a.path.localeCompare(b.path);
|
||||
if (pathCmp !== 0) return pathCmp;
|
||||
const aLine = a.startLine ?? a.line ?? 0;
|
||||
const bLine = b.startLine ?? b.line ?? 0;
|
||||
return aLine - bLine;
|
||||
});
|
||||
|
||||
const threadBlocks: Array<{ path: string; lineRange: string; content: string[] }> = [];
|
||||
|
||||
for (const thread of threads) {
|
||||
const allComments = (thread.comments?.nodes ?? []).filter(
|
||||
(c): c is ReviewThreadComment => c !== null
|
||||
);
|
||||
if (allComments.length === 0) continue;
|
||||
|
||||
// get line info from thread, or fall back to first comment's line info
|
||||
const firstComment = allComments[0];
|
||||
const line =
|
||||
thread.line ?? firstComment?.line ?? firstComment?.originalLine ?? thread.startLine ?? 0;
|
||||
const startLine =
|
||||
thread.startLine ?? firstComment?.startLine ?? firstComment?.originalStartLine ?? line;
|
||||
const lineRange = startLine === line ? `${line}` : `${startLine}-${line}`;
|
||||
const block: string[] = [];
|
||||
|
||||
// header with file:line range and status
|
||||
const status = thread.isResolved ? " [RESOLVED]" : thread.isOutdated ? " [OUTDATED]" : "";
|
||||
block.push(`## ${thread.path}:${lineRange}${status}`);
|
||||
block.push("");
|
||||
|
||||
// show all comments in the thread (full conversation history)
|
||||
for (const comment of allComments) {
|
||||
const author = comment.author?.login ?? "unknown";
|
||||
const isTargetReview = comment.pullRequestReview?.databaseId === reviewId;
|
||||
const marker = isTargetReview ? " *" : "";
|
||||
|
||||
block.push(
|
||||
`\`\`\`\`comment author=${author} id=${comment.fullDatabaseId ?? "unknown"} review=${comment.pullRequestReview?.databaseId ?? "unknown"}${marker}`
|
||||
);
|
||||
block.push(comment.body || "(no comment body)");
|
||||
block.push("````");
|
||||
block.push("");
|
||||
}
|
||||
|
||||
// diff context
|
||||
const fileHunks = filePatchMap.get(thread.path);
|
||||
const firstCommentWithHunk = allComments.find((c) => c.diffHunk);
|
||||
let diffContent: string | null = null;
|
||||
|
||||
if (fileHunks && fileHunks.length > 0) {
|
||||
const overlapping = findOverlappingHunks(fileHunks, startLine, line, thread.diffSide);
|
||||
if (overlapping.length > 0) {
|
||||
diffContent = extractFromFilePatches(fileHunks, startLine, line, thread.diffSide);
|
||||
}
|
||||
}
|
||||
|
||||
if (!diffContent && firstCommentWithHunk) {
|
||||
diffContent = extractCommentedLines(
|
||||
firstCommentWithHunk.diffHunk,
|
||||
startLine,
|
||||
line,
|
||||
thread.diffSide
|
||||
);
|
||||
}
|
||||
|
||||
if (diffContent) {
|
||||
block.push(`\`\`\`diff file=${thread.path} lines=${lineRange} side=${thread.diffSide}`);
|
||||
block.push(diffContent);
|
||||
block.push("```");
|
||||
block.push("");
|
||||
} else {
|
||||
block.push(`\`\`\`diff file=${thread.path} lines=${lineRange} side=${thread.diffSide}`);
|
||||
block.push(`(no diff context available - comment on unchanged lines)`);
|
||||
block.push("```");
|
||||
block.push("");
|
||||
}
|
||||
|
||||
threadBlocks.push({ path: thread.path, lineRange, content: block });
|
||||
}
|
||||
|
||||
return { threadBlocks, reviewer };
|
||||
}
|
||||
|
||||
export function GetReviewCommentsTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "get_review_comments",
|
||||
description:
|
||||
"Get review comments for a pull request review, including thread context. " +
|
||||
"When approved_by is provided, only returns comments that user approved with 👍. " +
|
||||
"Returns commentsPath pointing to a file with full comment details in XML format.",
|
||||
"Get review comments for a pull request review with full thread context. " +
|
||||
"When approved_by is provided, only returns threads where that user gave a 👍 to at least one comment. " +
|
||||
"Returns a TOC and commentsPath pointing to a markdown file with full comment details.",
|
||||
parameters: GetReviewComments,
|
||||
execute: execute(async ({ pull_number, review_id, approved_by }) => {
|
||||
// fetch the review to get node_id and reviewer
|
||||
const { data: review } = await ctx.octokit.rest.pulls.getReview({
|
||||
execute: execute(async (params) => {
|
||||
// fetch all review threads for the PR via GraphQL
|
||||
const response = await ctx.octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
review_id,
|
||||
name: ctx.repo.name,
|
||||
prNumber: params.pull_number,
|
||||
});
|
||||
|
||||
const reviewer = review.user?.login ?? "unknown";
|
||||
const allThreads = response.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
||||
|
||||
// fetch comments with nested thread context via GraphQL
|
||||
const response = await ctx.octokit.graphql<ReviewQueryResponse>(REVIEW_QUERY, {
|
||||
nodeId: review.node_id,
|
||||
// filter to threads where at least one comment belongs to the target review
|
||||
let threadsForReview = allThreads.filter((thread): thread is ReviewThread => {
|
||||
if (!thread?.comments?.nodes) return false;
|
||||
return thread.comments.nodes.some(
|
||||
(c) => c?.pullRequestReview?.databaseId === params.review_id
|
||||
);
|
||||
});
|
||||
|
||||
const reviewComments = response.node?.comments?.nodes;
|
||||
if (!reviewComments) {
|
||||
return {
|
||||
review_id,
|
||||
pull_number,
|
||||
reviewer,
|
||||
count: 0,
|
||||
commentsPath: null,
|
||||
message: "No comments found for this review",
|
||||
};
|
||||
}
|
||||
|
||||
const allComments = reviewComments.filter((c): c is ReviewComment => c !== null);
|
||||
|
||||
// filter by approved_by if specified
|
||||
const comments = approved_by
|
||||
? allComments.filter((c) => hasThumbsUpFrom(c, approved_by))
|
||||
: allComments;
|
||||
if (params.approved_by) {
|
||||
threadsForReview = threadsForReview.filter((thread) =>
|
||||
threadHasThumbsUpFrom(thread, params.approved_by as string)
|
||||
);
|
||||
}
|
||||
|
||||
if (comments.length === 0) {
|
||||
if (threadsForReview.length === 0) {
|
||||
return {
|
||||
review_id,
|
||||
pull_number,
|
||||
reviewer,
|
||||
count: 0,
|
||||
review_id: params.review_id,
|
||||
pull_number: params.pull_number,
|
||||
reviewer: "unknown",
|
||||
threadCount: 0,
|
||||
commentsPath: null,
|
||||
message: approved_by
|
||||
? `No comments with 👍 from ${approved_by}`
|
||||
: "No comments found for this review",
|
||||
toc: null,
|
||||
instructions: params.approved_by
|
||||
? `no threads with 👍 from ${params.approved_by}`
|
||||
: "no threads found for this review",
|
||||
};
|
||||
}
|
||||
|
||||
// build XML output
|
||||
const lines: string[] = [];
|
||||
lines.push(`<review_comments count="${comments.length}" reviewer="${escapeXml(reviewer)}">`);
|
||||
lines.push("");
|
||||
|
||||
// summary section
|
||||
lines.push("<summary>");
|
||||
for (const comment of comments) {
|
||||
const line = comment.line ?? comment.startLine ?? 0;
|
||||
const preview = escapeXml(truncateBody(comment.body));
|
||||
lines.push(
|
||||
` <comment id="${comment.databaseId}" file="${escapeXml(comment.path)}" line="${line}">${preview}</comment>`
|
||||
);
|
||||
}
|
||||
lines.push("</summary>");
|
||||
lines.push("");
|
||||
|
||||
// detailed comments with thread context
|
||||
for (const comment of comments) {
|
||||
const line = comment.line ?? comment.startLine ?? 0;
|
||||
const author = comment.author?.login ?? "unknown";
|
||||
lines.push(
|
||||
`<comment id="${comment.databaseId}" file="${escapeXml(comment.path)}" line="${line}" author="${escapeXml(author)}">`
|
||||
);
|
||||
|
||||
// thread history (parent comments from nested replyTo)
|
||||
const thread = flattenReplyToChain(comment.replyTo);
|
||||
if (thread.length > 0) {
|
||||
lines.push(" <thread>");
|
||||
for (const msg of thread) {
|
||||
lines.push(
|
||||
` <message author="${escapeXml(msg.author)}">${escapeXml(msg.body)}</message>`
|
||||
);
|
||||
}
|
||||
lines.push(" </thread>");
|
||||
// fetch full file patches for better multi-hunk context
|
||||
const prFilesResponse = await ctx.octokit.rest.pulls.listFiles({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number: params.pull_number,
|
||||
});
|
||||
const filePatchMap = new Map<string, ParsedHunk[]>();
|
||||
for (const file of prFilesResponse.data) {
|
||||
if (file.patch) {
|
||||
filePatchMap.set(file.filename, parseFilePatches(file.patch));
|
||||
}
|
||||
|
||||
// diff context
|
||||
lines.push(" <diff>");
|
||||
lines.push(escapeXml(comment.diffHunk));
|
||||
lines.push(" </diff>");
|
||||
|
||||
// the actual comment body to address
|
||||
lines.push(` <body>${escapeXml(comment.body)}</body>`);
|
||||
lines.push("</comment>");
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("</review_comments>");
|
||||
// build thread blocks
|
||||
const { threadBlocks, reviewer } = buildThreadBlocks(
|
||||
threadsForReview,
|
||||
filePatchMap,
|
||||
params.review_id
|
||||
);
|
||||
|
||||
const content = lines.join("\n");
|
||||
// format thread blocks into markdown with TOC
|
||||
const formatted = formatReviewThreads(threadBlocks, {
|
||||
pullNumber: params.pull_number,
|
||||
reviewId: params.review_id,
|
||||
reviewer,
|
||||
});
|
||||
|
||||
// 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}.xml`
|
||||
: `review-${review_id}-comments.xml`;
|
||||
const filename = `review-${params.review_id}-threads.md`;
|
||||
const commentsPath = join(tempDir, filename);
|
||||
writeFileSync(commentsPath, content);
|
||||
log.info(`wrote ${comments.length} comments to ${commentsPath}`);
|
||||
log.box(content);
|
||||
writeFileSync(commentsPath, formatted.content);
|
||||
log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`);
|
||||
|
||||
return {
|
||||
review_id,
|
||||
pull_number,
|
||||
review_id: params.review_id,
|
||||
pull_number: params.pull_number,
|
||||
reviewer,
|
||||
count: comments.length,
|
||||
threadCount: threadBlocks.length,
|
||||
commentsPath,
|
||||
toc: formatted.toc,
|
||||
instructions:
|
||||
`the file at commentsPath contains ${threadBlocks.length} review threads with full conversation history. ` +
|
||||
`comments marked with * are from the target review (${params.review_id}). ` +
|
||||
`the TOC shows each thread's file:line and the line number where it appears in the file. ` +
|
||||
`to read a specific thread, use: grep -A 50 "^## <file:line>" ${commentsPath} ` +
|
||||
`(replace <file:line> with the path from the TOC, e.g. "^## action/utils/foo.ts:42"). ` +
|
||||
`address each thread in order, working through one file at a time.`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -290,15 +554,15 @@ export function ListPullRequestReviewsTool(ctx: ToolContext) {
|
||||
description:
|
||||
"List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
|
||||
parameters: ListPullRequestReviews,
|
||||
execute: execute(async ({ pull_number }) => {
|
||||
execute: execute(async (params) => {
|
||||
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number,
|
||||
pull_number: params.pull_number,
|
||||
});
|
||||
|
||||
return {
|
||||
pull_number,
|
||||
pull_number: params.pull_number,
|
||||
reviews: reviews.map((review) => ({
|
||||
id: review.id,
|
||||
node_id: review.node_id,
|
||||
|
||||
+8
-4
@@ -4,7 +4,9 @@ import type { FastMCP, Tool } from "fastmcp";
|
||||
import { formatJsonValue, log } from "../utils/cli.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
|
||||
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
|
||||
export const tool = <const params>(
|
||||
toolDef: Tool<any, StandardSchemaV1<params>>
|
||||
): Tool<any, StandardSchemaV1<params>> => toolDef;
|
||||
|
||||
export interface ToolResult {
|
||||
content: {
|
||||
@@ -40,11 +42,11 @@ export const handleToolError = (error: unknown): ToolResult => {
|
||||
* @param fn - the function to execute
|
||||
* @param toolName - optional tool name for error logging
|
||||
*/
|
||||
export const execute = <T>(
|
||||
fn: (params: T) => Promise<Record<string, any> | string>,
|
||||
export const execute = <T, R extends Record<string, any> | string>(
|
||||
fn: (params: T) => Promise<R>,
|
||||
toolName?: string
|
||||
) => {
|
||||
return async (params: T): Promise<ToolResult> => {
|
||||
const _fn = async (params: T): Promise<ToolResult> => {
|
||||
try {
|
||||
const result = await fn(params);
|
||||
return handleToolSuccess(result);
|
||||
@@ -56,6 +58,8 @@ export const execute = <T>(
|
||||
return handleToolError(error);
|
||||
}
|
||||
};
|
||||
(_fn as any).raw = fn;
|
||||
return _fn;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+4
-3
@@ -1,9 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
exclude: ['node_modules', '.temp'],
|
||||
environment: "node",
|
||||
exclude: ["**/node_modules/**", "**/.temp/**", "**/.pnpm-store/**"],
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { resolve } from "node:path";
|
||||
import { config } from "dotenv";
|
||||
|
||||
config({ path: resolve(import.meta.dirname, "../.env") });
|
||||
|
||||
// alias GITHUB_TOKEN to GH_TOKEN for tests
|
||||
if (!process.env.GH_TOKEN && process.env.GITHUB_TOKEN) {
|
||||
process.env.GH_TOKEN = process.env.GITHUB_TOKEN;
|
||||
}
|
||||
Reference in New Issue
Block a user