Switch back to grpahql for review threads

This commit is contained in:
Colin McDonnell
2026-01-20 23:59:52 +00:00
committed by pullfrog[bot]
parent a5fffc97a5
commit d98f6c8029
5 changed files with 431 additions and 93 deletions
+149 -40
View File
@@ -121223,6 +121223,67 @@ function CreatePullRequestReviewTool(ctx) {
// mcp/reviewComments.ts
import { writeFileSync as writeFileSync3 } from "node:fs";
import { join as join5 } from "node:path";
var REVIEW_THREADS_QUERY = `
query ($owner: String!, $repo: String!, $pullNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pullNumber) {
reviewThreads(first: 100) {
nodes {
diffSide
startDiffSide
comments(first: 100) {
nodes {
id
databaseId
body
path
line
startLine
url
author {
login
}
createdAt
updatedAt
pullRequestReview {
databaseId
}
replyTo {
databaseId
}
reactionGroups {
content
reactors(first: 10) {
nodes {
... on Actor {
login
}
}
}
}
}
}
}
}
}
}
}
`;
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) + "...";
}
function escapeXml(text) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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);
}
var GetReviewComments = type({
pull_number: type.number.describe("The pull request number"),
review_id: type.number.describe("The review ID to get comments for"),
@@ -121231,71 +121292,112 @@ var GetReviewComments = type({
function GetReviewCommentsTool(ctx) {
return tool({
name: "get_review_comments",
description: "Get review comments for a pull request review, including diff context. When approved_by is provided, only returns comments that user approved with \u{1F44D}. Returns commentsPath pointing to a file with full comment details.",
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.",
parameters: GetReviewComments,
execute: execute(async ({ pull_number, review_id, approved_by }) => {
const allComments = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviewComments, {
const review = await ctx.octokit.rest.pulls.getReview({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number
pull_number,
review_id
});
let reviewComments = allComments.filter((c) => c.pull_request_review_id === review_id);
if (approved_by) {
const approvedIds = /* @__PURE__ */ new Set();
for (const comment of reviewComments) {
const reactions = await ctx.octokit.rest.reactions.listForPullRequestReviewComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: comment.id
});
const hasThumbsUp = reactions.data.some(
(r) => r.content === "+1" && r.user?.login === approved_by
);
if (hasThumbsUp) approvedIds.add(comment.id);
}
reviewComments = reviewComments.filter((c) => approvedIds.has(c.id));
}
if (reviewComments.length === 0) {
const reviewer = review.data.user?.login ?? "unknown";
const response = await ctx.octokit.graphql(REVIEW_THREADS_QUERY, {
owner: ctx.repo.owner,
repo: ctx.repo.name,
pullNumber: pull_number
});
const pullRequest = response.repository?.pullRequest;
if (!pullRequest?.reviewThreads?.nodes) {
return {
review_id,
pull_number,
reviewer,
count: 0,
commentsPath: null,
message: "No review threads found"
};
}
const leafComments = [];
for (const thread of pullRequest.reviewThreads.nodes) {
if (!thread?.comments?.nodes) continue;
const threadComments = thread.comments.nodes.filter(
(c) => c !== null
);
if (threadComments.length === 0) continue;
for (const comment of threadComments) {
if (comment.pullRequestReview?.databaseId !== review_id) continue;
if (approved_by && !hasThumbsUpFrom(comment, approved_by)) continue;
const threadContext = threadComments.filter(
(c) => c.createdAt < comment.createdAt || c.createdAt === comment.createdAt && c.databaseId < comment.databaseId
);
leafComments.push({
comment,
thread: threadContext,
side: thread.diffSide
});
}
}
if (leafComments.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 = [];
for (const comment of reviewComments) {
lines.push(`${"=".repeat(60)}`);
lines.push(`COMMENT #${comment.id} by @${comment.user?.login ?? "unknown"}`);
lines.push(`File: ${comment.path}:${comment.line ?? comment.original_line ?? "?"}`);
if (comment.in_reply_to_id) {
lines.push(`Reply to: #${comment.in_reply_to_id}`);
lines.push(
`<review_comments count="${leafComments.length}" reviewer="${escapeXml(reviewer)}">`
);
lines.push("");
lines.push("<summary>");
for (const leaf of leafComments) {
const line = leaf.comment.line ?? leaf.comment.startLine ?? 0;
const preview = escapeXml(truncateBody(leaf.comment.body));
lines.push(
` <comment id="${leaf.comment.databaseId}" file="${escapeXml(leaf.comment.path)}" line="${line}">${preview}</comment>`
);
}
lines.push("</summary>");
lines.push("");
for (const leaf of leafComments) {
const line = leaf.comment.line ?? leaf.comment.startLine ?? 0;
const author = leaf.comment.author?.login ?? "unknown";
lines.push(
`<comment id="${leaf.comment.databaseId}" file="${escapeXml(leaf.comment.path)}" line="${line}" author="${escapeXml(author)}">`
);
if (leaf.thread.length > 0) {
lines.push(" <thread>");
for (const msg of leaf.thread) {
const msgAuthor = msg.author?.login ?? "unknown";
lines.push(
` <message id="${msg.databaseId}" author="${escapeXml(msgAuthor)}">${escapeXml(msg.body)}</message>`
);
}
lines.push(" </thread>");
}
lines.push("");
if (comment.diff_hunk) {
lines.push("```diff");
lines.push(comment.diff_hunk);
lines.push("```");
lines.push("");
}
lines.push("Comment:");
lines.push(comment.body);
lines.push(` <body>${escapeXml(leaf.comment.body)}</body>`);
lines.push("</comment>");
lines.push("");
}
lines.push("</review_comments>");
const content = lines.join("\n");
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) {
throw new Error("PULLFROG_TEMP_DIR not set");
}
const filename = approved_by ? `review-${review_id}-approved-by-${approved_by}.txt` : `review-${review_id}-comments.txt`;
const filename = approved_by ? `review-${review_id}-approved-by-${approved_by}.xml` : `review-${review_id}-comments.xml`;
const commentsPath = join5(tempDir, filename);
writeFileSync3(commentsPath, content);
log.debug(`wrote ${reviewComments.length} comments to ${commentsPath}`);
log.debug(`wrote ${leafComments.length} comments to ${commentsPath}`);
return {
review_id,
pull_number,
count: reviewComments.length,
reviewer,
count: leafComments.length,
commentsPath
};
})
@@ -121534,7 +121636,8 @@ function computeModes() {
3. Fetch review comments using ${ghPullfrogMcpName}/get_review_comments with \`pull_number\` and \`review_id\` from EVENT DATA. This returns \`commentsPath\` - read that file for full comment details with diff context. If EVENT DATA contains a \`triggerer\` field (indicating who requested fixes), you can pass \`approved_by\` to filter to only comments they approved with \u{1F44D}.
4. Review the feedback provided. Understand each review comment and what changes are being requested.
4. Review the feedback provided. Understand each review comment and what changes are being requested.
- **If there are no actionable comments** (e.g., the review is an approval with no specific feedback to address), do NOT post a progress comment. Simply exit without taking action.
5. If the request requires understanding the codebase structure or conventions, gather relevant context. Read AGENTS.md if it exists.
@@ -139997,12 +140100,18 @@ ${ctx.error}` : ctx.error;
}
const repoContext = parseRepoContext();
const octokit = createOctokit(getGitHubInstallationToken());
const runId = process.env.GITHUB_RUN_ID;
const footer = buildPullfrogFooter({
triggeredBy: true,
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : void 0
});
await octokit.rest.issues.updateComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: commentId,
body: formattedError
body: `${formattedError}${footer}`
});
ctx.toolState.progressComment.wasUpdated = true;
}
// utils/instructions.ts