import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { type } from "arktype"; import { log } from "../utils/log.ts"; import type { ToolContext } from "./server.ts"; import { execute, tool } from "./shared.ts"; // graphql query to fetch all review threads with comments, replies, and reactions const REVIEW_THREADS_QUERY = ` query ($owner: String!, $repo: String!, $pullNumber: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $pullNumber) { reviewThreads(first: 100) { nodes { diffSide startDiffSide comments(first: 100) { nodes { id databaseId body path line startLine url author { login } createdAt updatedAt pullRequestReview { databaseId } replyTo { databaseId } reactionGroups { content reactors(first: 10) { nodes { ... on Actor { login } } } } } } } } } } } `; type GraphQLReviewComment = { id: string; databaseId: number; body: string; path: string; line: number | null; startLine: number | null; url: string; author: { login: string; } | null; createdAt: string; updatedAt: string; pullRequestReview: { databaseId: number; } | null; replyTo: { databaseId: number; } | null; reactionGroups: | { content: string; reactors: { nodes: ({ login: string } | null)[] | null; }; }[] | null; }; type GraphQLReviewThread = { diffSide: "LEFT" | "RIGHT"; startDiffSide: "LEFT" | "RIGHT" | null; comments: { nodes: (GraphQLReviewComment | null)[] | null; } | null; } | null; type GraphQLResponse = { repository: { pullRequest: { reviewThreads: { nodes: (GraphQLReviewThread | null)[] | null; } | null; } | null; } | null; }; const MAX_BODY_PREVIEW = 80; 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 escapeXml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function hasThumbsUpFrom(comment: GraphQLReviewComment, 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); } export const GetReviewComments = type({ pull_number: type.number.describe("The pull request number"), review_id: type.number.describe("The review ID to get comments for"), approved_by: type.string .describe("Optional GitHub username - only return comments this user gave a 👍 to") .optional(), }); export function GetReviewCommentsTool(ctx: ToolContext) { return tool({ name: "get_review_comments", description: "Get 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.", parameters: GetReviewComments, execute: execute(async ({ pull_number, review_id, approved_by }) => { // fetch the review to get reviewer username const review = await ctx.octokit.rest.pulls.getReview({ owner: ctx.repo.owner, repo: ctx.repo.name, pull_number, review_id, }); const reviewer = review.data.user?.login ?? "unknown"; // fetch all review threads using graphql (single API call) 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", }; } // collect leaf comments (from target review) with their thread context type LeafComment = { comment: GraphQLReviewComment; thread: GraphQLReviewComment[]; // parent comments in order (oldest first) side: "LEFT" | "RIGHT"; }; const leafComments: LeafComment[] = []; for (const thread of pullRequest.reviewThreads.nodes) { if (!thread?.comments?.nodes) continue; const threadComments = thread.comments.nodes.filter( (c): c is GraphQLReviewComment => c !== null ); if (threadComments.length === 0) continue; // find comments from the target review (these are the "leaf" comments to address) for (const comment of threadComments) { if (comment.pullRequestReview?.databaseId !== review_id) continue; // filter by approved_by if specified if (approved_by && !hasThumbsUpFrom(comment, approved_by)) continue; // get thread context (all comments before this one in the thread) 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 👍 from ${approved_by}` : "No comments found for this review", }; } // build XML output const lines: string[] = []; lines.push( `` ); lines.push(""); // summary section lines.push(""); for (const leaf of leafComments) { const line = leaf.comment.line ?? leaf.comment.startLine ?? 0; const preview = escapeXml(truncateBody(leaf.comment.body)); lines.push( ` ${preview}` ); } lines.push(""); lines.push(""); // detailed comments with thread context for (const leaf of leafComments) { const line = leaf.comment.line ?? leaf.comment.startLine ?? 0; const author = leaf.comment.author?.login ?? "unknown"; lines.push( `` ); // thread history (parent comments) if (leaf.thread.length > 0) { lines.push(" "); for (const msg of leaf.thread) { const msgAuthor = msg.author?.login ?? "unknown"; lines.push( ` ${escapeXml(msg.body)}` ); } lines.push(" "); } // the actual comment body to address lines.push(` ${escapeXml(leaf.comment.body)}`); lines.push(""); lines.push(""); } lines.push(""); const content = lines.join("\n"); // write to temp file const tempDir = process.env.PULLFROG_TEMP_DIR; if (!tempDir) { throw new Error("PULLFROG_TEMP_DIR not set"); } const filename = approved_by ? `review-${review_id}-approved-by-${approved_by}.xml` : `review-${review_id}-comments.xml`; const commentsPath = join(tempDir, filename); writeFileSync(commentsPath, content); log.debug(`wrote ${leafComments.length} comments to ${commentsPath}`); log.debug(`content: ${content}`); return { review_id, pull_number, reviewer, count: leafComments.length, commentsPath, }; }), }); } export const ListPullRequestReviews = type({ pull_number: type.number.describe("The pull request number to list reviews for"), }); export function ListPullRequestReviewsTool(ctx: ToolContext) { return tool({ 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 }) => { const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, { owner: ctx.repo.owner, repo: ctx.repo.name, pull_number, }); return { pull_number, reviews: reviews.map((review) => ({ id: review.id, body: review.body, state: review.state, user: review.user?.login, submitted_at: review.submitted_at, })), count: reviews.length, }; }), }); }