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) => {
|
var execute = (fn2, toolName) => {
|
||||||
return async (params) => {
|
const _fn = async (params) => {
|
||||||
try {
|
try {
|
||||||
const result = await fn2(params);
|
const result = await fn2(params);
|
||||||
return handleToolSuccess(result);
|
return handleToolSuccess(result);
|
||||||
@@ -106649,6 +106649,8 @@ var execute = (fn2, toolName) => {
|
|||||||
return handleToolError(error50);
|
return handleToolError(error50);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
_fn.raw = fn2;
|
||||||
|
return _fn;
|
||||||
};
|
};
|
||||||
function sanitizeSchema(schema2) {
|
function sanitizeSchema(schema2) {
|
||||||
if (!schema2 || typeof schema2 !== "object") {
|
if (!schema2 || typeof schema2 !== "object") {
|
||||||
@@ -136777,13 +136779,24 @@ function $(cmd, args3, options) {
|
|||||||
// mcp/checkout.ts
|
// mcp/checkout.ts
|
||||||
function formatFilesWithLineNumbers(files) {
|
function formatFilesWithLineNumbers(files) {
|
||||||
const output = [];
|
const output = [];
|
||||||
|
const tocEntries = [];
|
||||||
|
const tocHeaderSize = 1 + files.length + 2;
|
||||||
|
let currentLine = tocHeaderSize + 1;
|
||||||
for (const file2 of files) {
|
for (const file2 of files) {
|
||||||
|
const fileStartLine = currentLine;
|
||||||
output.push(`diff --git a/${file2.filename} b/${file2.filename}`);
|
output.push(`diff --git a/${file2.filename} b/${file2.filename}`);
|
||||||
output.push(`--- a/${file2.filename}`);
|
output.push(`--- a/${file2.filename}`);
|
||||||
output.push(`+++ b/${file2.filename}`);
|
output.push(`+++ b/${file2.filename}`);
|
||||||
|
currentLine += 3;
|
||||||
if (!file2.patch) {
|
if (!file2.patch) {
|
||||||
output.push("(binary file or no changes)");
|
output.push("(binary file or no changes)");
|
||||||
output.push("");
|
output.push("");
|
||||||
|
currentLine += 2;
|
||||||
|
tocEntries.push({
|
||||||
|
filename: file2.filename,
|
||||||
|
startLine: fileStartLine,
|
||||||
|
endLine: currentLine - 1
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const lines = file2.patch.split("\n");
|
const lines = file2.patch.split("\n");
|
||||||
@@ -136795,6 +136808,7 @@ function formatFilesWithLineNumbers(files) {
|
|||||||
oldLine = parseInt(hunkMatch[1], 10);
|
oldLine = parseInt(hunkMatch[1], 10);
|
||||||
newLine = parseInt(hunkMatch[2], 10);
|
newLine = parseInt(hunkMatch[2], 10);
|
||||||
output.push(line);
|
output.push(line);
|
||||||
|
currentLine++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const changeType = line[0] || " ";
|
const changeType = line[0] || " ";
|
||||||
@@ -136816,10 +136830,26 @@ function formatFilesWithLineNumbers(files) {
|
|||||||
} else {
|
} else {
|
||||||
output.push(line);
|
output.push(line);
|
||||||
}
|
}
|
||||||
|
currentLine++;
|
||||||
}
|
}
|
||||||
output.push("");
|
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) {
|
function padNum(n) {
|
||||||
return n.toString().padStart(4, " ");
|
return n.toString().padStart(4, " ");
|
||||||
@@ -136827,6 +136857,15 @@ function padNum(n) {
|
|||||||
var CheckoutPr = type({
|
var CheckoutPr = type({
|
||||||
pull_number: type.number.describe("the pull request number to checkout")
|
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) {
|
async function checkoutPrBranch(params) {
|
||||||
const { octokit, owner, name, token, pullNumber } = params;
|
const { octokit, owner, name, token, pullNumber } = params;
|
||||||
log.info(`\xBB checking out PR #${pullNumber}...`);
|
log.info(`\xBB checking out PR #${pullNumber}...`);
|
||||||
@@ -136907,14 +136946,13 @@ function CheckoutPrTool(ctx) {
|
|||||||
if (!headRepo) {
|
if (!headRepo) {
|
||||||
throw new Error(`PR #${pull_number} source repository was deleted`);
|
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,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pullNumber: pull_number
|
||||||
per_page: 100
|
|
||||||
});
|
});
|
||||||
const diffContent = formatFilesWithLineNumbers(filesResponse.data);
|
const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
|
||||||
const diffPreview = diffContent.split("\n").slice(0, 100).join("\n");
|
|
||||||
log.debug(`formatted diff preview (first 100 lines):
|
log.debug(`formatted diff preview (first 100 lines):
|
||||||
${diffPreview}`);
|
${diffPreview}`);
|
||||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||||
@@ -136924,8 +136962,8 @@ ${diffPreview}`);
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const diffPath = join2(tempDir, `pr-${pull_number}.diff`);
|
const diffPath = join2(tempDir, `pr-${pull_number}.diff`);
|
||||||
writeFileSync2(diffPath, diffContent);
|
writeFileSync2(diffPath, formatResult.content);
|
||||||
log.debug(`wrote diff to ${diffPath} (${diffContent.length} bytes)`);
|
log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
number: pr.data.number,
|
number: pr.data.number,
|
||||||
@@ -138486,7 +138524,7 @@ function CommitInfoTool(ctx) {
|
|||||||
});
|
});
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
const files = data.files ?? [];
|
const files = data.files ?? [];
|
||||||
const diffContent = formatFilesWithLineNumbers(files);
|
const formatResult = formatFilesWithLineNumbers(files);
|
||||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||||
if (!tempDir) {
|
if (!tempDir) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -138494,8 +138532,8 @@ function CommitInfoTool(ctx) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const diffFile = join5(tempDir, `commit-${sha.slice(0, 7)}.diff`);
|
const diffFile = join5(tempDir, `commit-${sha.slice(0, 7)}.diff`);
|
||||||
writeFileSync3(diffFile, diffContent);
|
writeFileSync3(diffFile, formatResult.content);
|
||||||
log.debug(`wrote commit diff to ${diffFile} (${diffContent.length} bytes)`);
|
log.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`);
|
||||||
return {
|
return {
|
||||||
sha: data.sha,
|
sha: data.sha,
|
||||||
message: data.commit.message,
|
message: data.commit.message,
|
||||||
@@ -138688,56 +138726,41 @@ function CreatePullRequestReviewTool(ctx) {
|
|||||||
// mcp/reviewComments.ts
|
// mcp/reviewComments.ts
|
||||||
import { writeFileSync as writeFileSync4 } from "node:fs";
|
import { writeFileSync as writeFileSync4 } from "node:fs";
|
||||||
import { join as join6 } from "node:path";
|
import { join as join6 } from "node:path";
|
||||||
var REPLY_TO_FRAGMENT = `
|
var REVIEW_THREADS_QUERY = `
|
||||||
replyTo {
|
query ($owner: String!, $name: String!, $prNumber: Int!) {
|
||||||
databaseId
|
repository(owner: $owner, name: $name) {
|
||||||
body
|
pullRequest(number: $prNumber) {
|
||||||
author { login }
|
reviewThreads(first: 100) {
|
||||||
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) {
|
|
||||||
nodes {
|
nodes {
|
||||||
databaseId
|
id
|
||||||
body
|
|
||||||
path
|
path
|
||||||
line
|
line
|
||||||
startLine
|
startLine
|
||||||
diffHunk
|
diffSide
|
||||||
url
|
isResolved
|
||||||
author { login }
|
isOutdated
|
||||||
createdAt
|
comments(first: 50) {
|
||||||
${REPLY_TO_FRAGMENT}
|
nodes {
|
||||||
reactionGroups {
|
fullDatabaseId
|
||||||
content
|
body
|
||||||
reactors(first: 10) {
|
createdAt
|
||||||
nodes {
|
diffHunk
|
||||||
... on Actor { login }
|
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;
|
var CONTEXT_PADDING = 3;
|
||||||
function truncateBody(body) {
|
function extractCommentedLines(diffHunk, startLine, endLine, side) {
|
||||||
const oneLine = body.replace(/\n/g, " ").trim();
|
const lines = diffHunk.split("\n");
|
||||||
if (oneLine.length <= MAX_BODY_PREVIEW) return oneLine;
|
if (lines.length <= 1) return diffHunk;
|
||||||
return oneLine.slice(0, MAX_BODY_PREVIEW - 3) + "...";
|
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) {
|
function parseFilePatches(patch) {
|
||||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
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) {
|
function hasThumbsUpFrom(comment, username) {
|
||||||
if (!comment.reactionGroups) return false;
|
if (!comment.reactionGroups) return false;
|
||||||
const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP");
|
const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP");
|
||||||
if (!thumbsUp?.reactors?.nodes) return false;
|
if (!thumbsUp?.reactors?.nodes) return false;
|
||||||
return thumbsUp.reactors.nodes.some((r) => r?.login === username);
|
return thumbsUp.reactors.nodes.some((r) => r?.login === username);
|
||||||
}
|
}
|
||||||
function flattenReplyToChain(replyTo) {
|
function threadHasThumbsUpFrom(thread, username) {
|
||||||
if (!replyTo) return [];
|
const comments = thread.comments?.nodes ?? [];
|
||||||
const parent = flattenReplyToChain(replyTo.replyTo ?? null);
|
return comments.some((c) => c && hasThumbsUpFrom(c, username));
|
||||||
return [...parent, { body: replyTo.body, author: replyTo.author?.login ?? "unknown" }];
|
}
|
||||||
|
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) {
|
function GetReviewCommentsTool(ctx) {
|
||||||
return tool({
|
return tool({
|
||||||
name: "get_review_comments",
|
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,
|
parameters: GetReviewComments,
|
||||||
execute: execute(async ({ pull_number, review_id, approved_by }) => {
|
execute: execute(async (params) => {
|
||||||
const { data: review } = await ctx.octokit.rest.pulls.getReview({
|
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,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number: params.pull_number
|
||||||
review_id
|
|
||||||
});
|
});
|
||||||
const reviewer = review.user?.login ?? "unknown";
|
const filePatchMap = /* @__PURE__ */ new Map();
|
||||||
const response = await ctx.octokit.graphql(REVIEW_QUERY, {
|
for (const file2 of prFilesResponse.data) {
|
||||||
nodeId: review.node_id
|
if (file2.patch) {
|
||||||
});
|
filePatchMap.set(file2.filename, parseFilePatches(file2.patch));
|
||||||
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>");
|
|
||||||
}
|
}
|
||||||
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 { threadBlocks, reviewer } = buildThreadBlocks(
|
||||||
const content = lines.join("\n");
|
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;
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||||
if (!tempDir) {
|
if (!tempDir) {
|
||||||
throw new Error("PULLFROG_TEMP_DIR not set");
|
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);
|
const commentsPath = join6(tempDir, filename);
|
||||||
writeFileSync4(commentsPath, content);
|
writeFileSync4(commentsPath, formatted.content);
|
||||||
log.info(`wrote ${comments.length} comments to ${commentsPath}`);
|
log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`);
|
||||||
log.box(content);
|
|
||||||
return {
|
return {
|
||||||
review_id,
|
review_id: params.review_id,
|
||||||
pull_number,
|
pull_number: params.pull_number,
|
||||||
reviewer,
|
reviewer,
|
||||||
count: comments.length,
|
threadCount: threadBlocks.length,
|
||||||
commentsPath
|
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",
|
name: "list_pull_request_reviews",
|
||||||
description: "List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
|
description: "List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
|
||||||
parameters: ListPullRequestReviews,
|
parameters: ListPullRequestReviews,
|
||||||
execute: execute(async ({ pull_number }) => {
|
execute: execute(async (params) => {
|
||||||
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
||||||
owner: ctx.repo.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
pull_number
|
pull_number: params.pull_number
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
pull_number,
|
pull_number: params.pull_number,
|
||||||
reviews: reviews.map((review) => ({
|
reviews: reviews.map((review) => ({
|
||||||
id: review.id,
|
id: review.id,
|
||||||
node_id: review.node_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];
|
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.
|
* formats PR files with explicit line numbers for each code line.
|
||||||
* preserves all original diff info (file headers, hunk headers) and adds:
|
* preserves all original diff info (file headers, hunk headers) and adds:
|
||||||
* | OLD | NEW | TYPE | code
|
* | 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 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) {
|
for (const file of files) {
|
||||||
|
const fileStartLine = currentLine;
|
||||||
|
|
||||||
// file header
|
// file header
|
||||||
output.push(`diff --git a/${file.filename} b/${file.filename}`);
|
output.push(`diff --git a/${file.filename} b/${file.filename}`);
|
||||||
output.push(`--- a/${file.filename}`);
|
output.push(`--- a/${file.filename}`);
|
||||||
output.push(`+++ b/${file.filename}`);
|
output.push(`+++ b/${file.filename}`);
|
||||||
|
currentLine += 3;
|
||||||
|
|
||||||
if (!file.patch) {
|
if (!file.patch) {
|
||||||
output.push("(binary file or no changes)");
|
output.push("(binary file or no changes)");
|
||||||
output.push("");
|
output.push("");
|
||||||
|
currentLine += 2;
|
||||||
|
tocEntries.push({
|
||||||
|
filename: file.filename,
|
||||||
|
startLine: fileStartLine,
|
||||||
|
endLine: currentLine - 1,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +61,7 @@ export function formatFilesWithLineNumbers(files: PullFile[]): string {
|
|||||||
oldLine = parseInt(hunkMatch[1], 10);
|
oldLine = parseInt(hunkMatch[1], 10);
|
||||||
newLine = parseInt(hunkMatch[2], 10);
|
newLine = parseInt(hunkMatch[2], 10);
|
||||||
output.push(line); // pass through unchanged
|
output.push(line); // pass through unchanged
|
||||||
|
currentLine++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,11 +90,31 @@ export function formatFilesWithLineNumbers(files: PullFile[]): string {
|
|||||||
// unknown line type, pass through
|
// unknown line type, pass through
|
||||||
output.push(line);
|
output.push(line);
|
||||||
}
|
}
|
||||||
|
currentLine++;
|
||||||
}
|
}
|
||||||
output.push(""); // blank line between files
|
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 {
|
function padNum(n: number): string {
|
||||||
@@ -97,6 +138,27 @@ export type CheckoutPrResult = {
|
|||||||
diffPath: string;
|
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 {
|
interface CheckoutPrBranchParams {
|
||||||
octokit: Octokit;
|
octokit: Octokit;
|
||||||
owner: string;
|
owner: string;
|
||||||
@@ -243,14 +305,13 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetch PR files and format with line numbers
|
// 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,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pullNumber: pull_number,
|
||||||
per_page: 100,
|
|
||||||
});
|
});
|
||||||
const diffContent = formatFilesWithLineNumbers(filesResponse.data);
|
const diffPreview = formatResult.content.split("\n").slice(0, 100).join("\n");
|
||||||
const diffPreview = diffContent.split("\n").slice(0, 100).join("\n");
|
|
||||||
log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
|
log.debug(`formatted diff preview (first 100 lines):\n${diffPreview}`);
|
||||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||||
if (!tempDir) {
|
if (!tempDir) {
|
||||||
@@ -259,8 +320,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const diffPath = join(tempDir, `pr-${pull_number}.diff`);
|
const diffPath = join(tempDir, `pr-${pull_number}.diff`);
|
||||||
writeFileSync(diffPath, diffContent);
|
writeFileSync(diffPath, formatResult.content);
|
||||||
log.debug(`wrote diff to ${diffPath} (${diffContent.length} bytes)`);
|
log.debug(`wrote diff to ${diffPath} (${formatResult.content.length} bytes)`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
+3
-3
@@ -28,7 +28,7 @@ export function CommitInfoTool(ctx: ToolContext) {
|
|||||||
const files = data.files ?? [];
|
const files = data.files ?? [];
|
||||||
|
|
||||||
// format diff with line numbers and write to file
|
// format diff with line numbers and write to file
|
||||||
const diffContent = formatFilesWithLineNumbers(files);
|
const formatResult = formatFilesWithLineNumbers(files);
|
||||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||||
if (!tempDir) {
|
if (!tempDir) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -36,8 +36,8 @@ export function CommitInfoTool(ctx: ToolContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const diffFile = join(tempDir, `commit-${sha.slice(0, 7)}.diff`);
|
const diffFile = join(tempDir, `commit-${sha.slice(0, 7)}.diff`);
|
||||||
writeFileSync(diffFile, diffContent);
|
writeFileSync(diffFile, formatResult.content);
|
||||||
log.debug(`wrote commit diff to ${diffFile} (${diffContent.length} bytes)`);
|
log.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sha: data.sha,
|
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 type { ToolContext } from "./server.ts";
|
||||||
import { execute, tool } from "./shared.ts";
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
// fragment for nested replyTo (5 levels deep covers most threads)
|
// GraphQL query to fetch all review threads for a PR with full comment history
|
||||||
// because in_reply_to_id generally points to the top-level comment
|
export const REVIEW_THREADS_QUERY = `
|
||||||
// this doesn't actually work as expected
|
query ($owner: String!, $name: String!, $prNumber: Int!) {
|
||||||
// TOD: implement an API endpoint with aggressive caching that fetches the PR's reviewThreads via graphql
|
repository(owner: $owner, name: $name) {
|
||||||
// separately fetch all the comments associated with the particular review
|
pullRequest(number: $prNumber) {
|
||||||
// merge them, then construct the full thread context
|
reviewThreads(first: 100) {
|
||||||
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) {
|
|
||||||
nodes {
|
nodes {
|
||||||
databaseId
|
id
|
||||||
body
|
|
||||||
path
|
path
|
||||||
line
|
line
|
||||||
startLine
|
startLine
|
||||||
diffHunk
|
diffSide
|
||||||
url
|
isResolved
|
||||||
author { login }
|
isOutdated
|
||||||
createdAt
|
comments(first: 50) {
|
||||||
${REPLY_TO_FRAGMENT}
|
nodes {
|
||||||
reactionGroups {
|
fullDatabaseId
|
||||||
content
|
body
|
||||||
reactors(first: 10) {
|
createdAt
|
||||||
nodes {
|
diffHunk
|
||||||
... on Actor { login }
|
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)
|
export type ReviewThreadComment = {
|
||||||
type NestedReplyTo = {
|
fullDatabaseId: string | null;
|
||||||
databaseId: number;
|
|
||||||
body: string;
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
diffHunk: string;
|
||||||
|
line: number | null;
|
||||||
|
startLine: number | null;
|
||||||
|
originalLine: number | null;
|
||||||
|
originalStartLine: number | null;
|
||||||
author: { login: string } | null;
|
author: { login: string } | null;
|
||||||
replyTo?: NestedReplyTo | null;
|
pullRequestReview: {
|
||||||
} | null;
|
databaseId: number | null;
|
||||||
|
author: { login: string } | null;
|
||||||
|
} | null;
|
||||||
|
reactionGroups: Array<{
|
||||||
|
content: string;
|
||||||
|
reactors: { nodes: Array<{ login: string } | null> | null } | null;
|
||||||
|
}> | null;
|
||||||
|
};
|
||||||
|
|
||||||
type ReviewComment = {
|
export type ReviewThread = {
|
||||||
databaseId: number;
|
id: string;
|
||||||
body: string;
|
|
||||||
path: string;
|
path: string;
|
||||||
line: number | null;
|
line: number | null;
|
||||||
startLine: number | null;
|
startLine: number | null;
|
||||||
diffHunk: string;
|
diffSide: "LEFT" | "RIGHT";
|
||||||
url: string;
|
isResolved: boolean;
|
||||||
author: { login: string } | null;
|
isOutdated: boolean;
|
||||||
createdAt: string;
|
comments: {
|
||||||
replyTo: NestedReplyTo;
|
nodes: (ReviewThreadComment | null)[] | null;
|
||||||
reactionGroups:
|
} | null;
|
||||||
| {
|
|
||||||
content: string;
|
|
||||||
reactors: { nodes: ({ login: string } | null)[] | null };
|
|
||||||
}[]
|
|
||||||
| null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReviewQueryResponse = {
|
export type ReviewThreadsQueryResponse = {
|
||||||
node: {
|
repository: {
|
||||||
databaseId: number;
|
pullRequest: {
|
||||||
author: { login: string } | null;
|
reviewThreads: {
|
||||||
comments: {
|
nodes: (ReviewThread | null)[] | null;
|
||||||
nodes: (ReviewComment | null)[] | null;
|
} | 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 {
|
function extractCommentedLines(
|
||||||
const oneLine = body.replace(/\n/g, " ").trim();
|
diffHunk: string,
|
||||||
if (oneLine.length <= MAX_BODY_PREVIEW) return oneLine;
|
startLine: number | null,
|
||||||
return oneLine.slice(0, MAX_BODY_PREVIEW - 3) + "...";
|
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 {
|
// parsed hunk from a unified diff
|
||||||
return text
|
export type ParsedHunk = {
|
||||||
.replace(/&/g, "&")
|
header: string;
|
||||||
.replace(/</g, "<")
|
oldStart: number;
|
||||||
.replace(/>/g, ">")
|
oldCount: number;
|
||||||
.replace(/"/g, """);
|
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 {
|
// find hunks that overlap with a line range (for LEFT or RIGHT side)
|
||||||
if (!comment.reactionGroups) return false;
|
function findOverlappingHunks(
|
||||||
const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP");
|
hunks: ParsedHunk[],
|
||||||
if (!thumbsUp?.reactors?.nodes) return false;
|
startLine: number,
|
||||||
return thumbsUp.reactors.nodes.some((r) => r?.login === username);
|
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)
|
// extract diff content from multiple hunks for a comment range
|
||||||
function flattenReplyToChain(replyTo: NestedReplyTo): Array<{ body: string; author: string }> {
|
function extractFromFilePatches(
|
||||||
if (!replyTo) return [];
|
hunks: ParsedHunk[],
|
||||||
const parent = flattenReplyToChain(replyTo.replyTo ?? null);
|
startLine: number,
|
||||||
return [...parent, { body: replyTo.body, author: replyTo.author?.login ?? "unknown" }];
|
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({
|
export const GetReviewComments = type({
|
||||||
pull_number: type.number.describe("The pull request number"),
|
pull_number: type.number.describe("The pull request number"),
|
||||||
review_id: type.number.describe("The review ID to get comments for"),
|
review_id: type.number.describe("The review ID to get comments for"),
|
||||||
approved_by: type.string
|
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(),
|
.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) {
|
export function GetReviewCommentsTool(ctx: ToolContext) {
|
||||||
return tool({
|
return tool({
|
||||||
name: "get_review_comments",
|
name: "get_review_comments",
|
||||||
description:
|
description:
|
||||||
"Get review comments for a pull request review, including thread context. " +
|
"Get review comments for a pull request review with full thread context. " +
|
||||||
"When approved_by is provided, only returns comments that user approved with 👍. " +
|
"When approved_by is provided, only returns threads where that user gave a 👍 to at least one comment. " +
|
||||||
"Returns commentsPath pointing to a file with full comment details in XML format.",
|
"Returns a TOC and commentsPath pointing to a markdown file with full comment details.",
|
||||||
parameters: GetReviewComments,
|
parameters: GetReviewComments,
|
||||||
execute: execute(async ({ pull_number, review_id, approved_by }) => {
|
execute: execute(async (params) => {
|
||||||
// fetch the review to get node_id and reviewer
|
// fetch all review threads for the PR via GraphQL
|
||||||
const { data: review } = await ctx.octokit.rest.pulls.getReview({
|
const response = await ctx.octokit.graphql<ReviewThreadsQueryResponse>(REVIEW_THREADS_QUERY, {
|
||||||
owner: ctx.repo.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.repo.name,
|
name: ctx.repo.name,
|
||||||
pull_number,
|
prNumber: params.pull_number,
|
||||||
review_id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const reviewer = review.user?.login ?? "unknown";
|
const allThreads = response.repository?.pullRequest?.reviewThreads?.nodes ?? [];
|
||||||
|
|
||||||
// fetch comments with nested thread context via GraphQL
|
// filter to threads where at least one comment belongs to the target review
|
||||||
const response = await ctx.octokit.graphql<ReviewQueryResponse>(REVIEW_QUERY, {
|
let threadsForReview = allThreads.filter((thread): thread is ReviewThread => {
|
||||||
nodeId: review.node_id,
|
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
|
// filter by approved_by if specified
|
||||||
const comments = approved_by
|
if (params.approved_by) {
|
||||||
? allComments.filter((c) => hasThumbsUpFrom(c, approved_by))
|
threadsForReview = threadsForReview.filter((thread) =>
|
||||||
: allComments;
|
threadHasThumbsUpFrom(thread, params.approved_by as string)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (comments.length === 0) {
|
if (threadsForReview.length === 0) {
|
||||||
return {
|
return {
|
||||||
review_id,
|
review_id: params.review_id,
|
||||||
pull_number,
|
pull_number: params.pull_number,
|
||||||
reviewer,
|
reviewer: "unknown",
|
||||||
count: 0,
|
threadCount: 0,
|
||||||
commentsPath: null,
|
commentsPath: null,
|
||||||
message: approved_by
|
toc: null,
|
||||||
? `No comments with 👍 from ${approved_by}`
|
instructions: params.approved_by
|
||||||
: "No comments found for this review",
|
? `no threads with 👍 from ${params.approved_by}`
|
||||||
|
: "no threads found for this review",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// build XML output
|
// fetch full file patches for better multi-hunk context
|
||||||
const lines: string[] = [];
|
const prFilesResponse = await ctx.octokit.rest.pulls.listFiles({
|
||||||
lines.push(`<review_comments count="${comments.length}" reviewer="${escapeXml(reviewer)}">`);
|
owner: ctx.repo.owner,
|
||||||
lines.push("");
|
repo: ctx.repo.name,
|
||||||
|
pull_number: params.pull_number,
|
||||||
// summary section
|
});
|
||||||
lines.push("<summary>");
|
const filePatchMap = new Map<string, ParsedHunk[]>();
|
||||||
for (const comment of comments) {
|
for (const file of prFilesResponse.data) {
|
||||||
const line = comment.line ?? comment.startLine ?? 0;
|
if (file.patch) {
|
||||||
const preview = escapeXml(truncateBody(comment.body));
|
filePatchMap.set(file.filename, parseFilePatches(file.patch));
|
||||||
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>");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// write to temp file
|
||||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||||
if (!tempDir) {
|
if (!tempDir) {
|
||||||
throw new Error("PULLFROG_TEMP_DIR not set");
|
throw new Error("PULLFROG_TEMP_DIR not set");
|
||||||
}
|
}
|
||||||
const filename = approved_by
|
const filename = `review-${params.review_id}-threads.md`;
|
||||||
? `review-${review_id}-approved-by-${approved_by}.xml`
|
|
||||||
: `review-${review_id}-comments.xml`;
|
|
||||||
const commentsPath = join(tempDir, filename);
|
const commentsPath = join(tempDir, filename);
|
||||||
writeFileSync(commentsPath, content);
|
writeFileSync(commentsPath, formatted.content);
|
||||||
log.info(`wrote ${comments.length} comments to ${commentsPath}`);
|
log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`);
|
||||||
log.box(content);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
review_id,
|
review_id: params.review_id,
|
||||||
pull_number,
|
pull_number: params.pull_number,
|
||||||
reviewer,
|
reviewer,
|
||||||
count: comments.length,
|
threadCount: threadBlocks.length,
|
||||||
commentsPath,
|
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:
|
description:
|
||||||
"List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
|
"List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
|
||||||
parameters: ListPullRequestReviews,
|
parameters: ListPullRequestReviews,
|
||||||
execute: execute(async ({ pull_number }) => {
|
execute: execute(async (params) => {
|
||||||
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
||||||
owner: ctx.repo.owner,
|
owner: ctx.repo.owner,
|
||||||
repo: ctx.repo.name,
|
repo: ctx.repo.name,
|
||||||
pull_number,
|
pull_number: params.pull_number,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pull_number,
|
pull_number: params.pull_number,
|
||||||
reviews: reviews.map((review) => ({
|
reviews: reviews.map((review) => ({
|
||||||
id: review.id,
|
id: review.id,
|
||||||
node_id: review.node_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 { formatJsonValue, log } from "../utils/cli.ts";
|
||||||
import type { ToolContext } from "./server.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 {
|
export interface ToolResult {
|
||||||
content: {
|
content: {
|
||||||
@@ -40,11 +42,11 @@ export const handleToolError = (error: unknown): ToolResult => {
|
|||||||
* @param fn - the function to execute
|
* @param fn - the function to execute
|
||||||
* @param toolName - optional tool name for error logging
|
* @param toolName - optional tool name for error logging
|
||||||
*/
|
*/
|
||||||
export const execute = <T>(
|
export const execute = <T, R extends Record<string, any> | string>(
|
||||||
fn: (params: T) => Promise<Record<string, any> | string>,
|
fn: (params: T) => Promise<R>,
|
||||||
toolName?: string
|
toolName?: string
|
||||||
) => {
|
) => {
|
||||||
return async (params: T): Promise<ToolResult> => {
|
const _fn = async (params: T): Promise<ToolResult> => {
|
||||||
try {
|
try {
|
||||||
const result = await fn(params);
|
const result = await fn(params);
|
||||||
return handleToolSuccess(result);
|
return handleToolSuccess(result);
|
||||||
@@ -56,6 +58,8 @@ export const execute = <T>(
|
|||||||
return handleToolError(error);
|
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({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: "node",
|
||||||
exclude: ['node_modules', '.temp'],
|
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