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:
Colin McDonnell
2026-01-23 06:28:22 +00:00
committed by pullfrog[bot]
parent 9a8db3e07c
commit 7621d6f0e5
11 changed files with 1196 additions and 370 deletions
+359 -153
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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,
+146
View File
@@ -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>&nbsp;<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"`;
+36
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+57
View File
@@ -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
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
// 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
View File
@@ -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
View File
@@ -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"],
},
});
+9
View File
@@ -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;
}