import type { RestEndpointMethodTypes } from "@octokit/rest"; import { type } from "arktype"; import type { ToolContext } from "../main.ts"; import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts"; import { log } from "../utils/cli.ts"; import { deleteProgressComment } from "./comment.ts"; import { execute, tool } from "./shared.ts"; // graphql mutation to add a comment thread to a pending review // note: REST API doesn't support adding comments to an existing pending review const ADD_PULL_REQUEST_REVIEW_THREAD = ` mutation AddPullRequestReviewThread($pullRequestReviewId: ID!, $path: String!, $line: Int!, $body: String!, $side: DiffSide) { addPullRequestReviewThread(input: { pullRequestReviewId: $pullRequestReviewId, path: $path, line: $line, body: $body, side: $side }) { thread { id } } } `; type AddPullRequestReviewThreadResponse = { addPullRequestReviewThread: { thread: { id: string; }; }; }; // helper to find existing pending review for the authenticated user async function findPendingReview( ctx: ToolContext, pull_number: number ): Promise<{ id: number; node_id: string } | null> { const reviews = await ctx.octokit.rest.pulls.listReviews({ owner: ctx.owner, repo: ctx.name, pull_number, per_page: 100, }); // find a PENDING review from our bot // note: authenticated user is the GitHub App, reviews show as "pullfrog[bot]" const pendingReview = reviews.data.find((r) => r.state === "PENDING"); if (pendingReview) { return { id: pendingReview.id, node_id: pendingReview.node_id }; } return null; } // start_review tool export const StartReview = type({ pull_number: type.number.describe("The pull request number to review"), }); export function StartReviewTool(ctx: ToolContext) { return tool({ name: "start_review", description: "Start a new review session for a pull request. Creates a pending review on GitHub. Must be called before add_review_comment.", parameters: StartReview, execute: execute(async ({ pull_number }) => { // check if review already started in this session if (ctx.toolState.review) { throw new Error( `Review session already in progress. Call submit_review first to finish it.` ); } // get the PR to get head commit SHA const pr = await ctx.octokit.rest.pulls.get({ owner: ctx.owner, repo: ctx.name, pull_number, }); let reviewId: number; let reviewNodeId: string; // try to create a new pending review (omitting 'event' creates PENDING state) log.debug(`creating pending review for PR #${pull_number}...`); try { const result = await ctx.octokit.rest.pulls.createReview({ owner: ctx.owner, repo: ctx.name, pull_number, commit_id: pr.data.head.sha, // no 'event' = PENDING review }); reviewId = result.data.id; reviewNodeId = result.data.node_id; log.debug(`created new pending review: id=${reviewId}`); } catch (error) { // check for "already has pending review" error const errorMessage = error instanceof Error ? error.message : String(error); log.debug(`createReview failed: ${errorMessage}`); if (errorMessage.includes("pending review")) { // find the existing pending review log.debug(`pending review already exists, fetching existing review...`); const existing = await findPendingReview(ctx, pull_number); if (!existing) { throw new Error( "GitHub says a pending review exists but we couldn't find it. Try again or check the PR reviews." ); } reviewId = existing.id; reviewNodeId = existing.node_id; log.debug(`reusing existing pending review: id=${reviewId}`); } else { throw error; } } // set PR context and review state ctx.toolState.prNumber = pull_number; ctx.toolState.review = { nodeId: reviewNodeId, id: reviewId, }; return { message: `Review session started for PR #${pull_number}.`, guidance: { analyze: [ "What does this PR change? Summarize in 1-2 sentences.", "Is the approach sound? If not, stop and comment on approach first.", "What bugs, edge cases, or security issues exist?", ], beforeCommenting: [ "Skip nitpicks unless explicitly requested.", "Would the codebase maintainer care about this feedback, based on what you can infer about the code quality standards in this repo?", ], }, }; }), }); } // add_review_comment tool export const AddReviewComment = type({ path: type.string.describe("The file path to comment on (relative to repo root)"), line: type.number.describe( "The line number in the file (use line numbers from the diff - the NEW file line number)" ), body: type.string.describe("The comment text for this specific line"), side: type .enumerated("LEFT", "RIGHT") .describe("Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT.") .optional(), }); export function AddReviewCommentTool(ctx: ToolContext) { return tool({ name: "add_review_comment", description: "Add a comment to the current review session. Must call start_review first. Comments are stored in draft state until submit_review is called.", parameters: AddReviewComment, execute: execute(async ({ path, line, body, side }) => { // check if review started if (!ctx.toolState.review) { throw new Error("No review session started. Call start_review first."); } // add comment thread via GraphQL (REST doesn't support adding to existing pending review) await ctx.octokit.graphql( ADD_PULL_REQUEST_REVIEW_THREAD, { pullRequestReviewId: ctx.toolState.review.nodeId, path, line, body, side: side || "RIGHT", } ); return { success: true, message: `Comment added to ${path}:${line}`, }; }), }); } // submit_review tool export const SubmitReview = type({ body: type.string .describe( "Review body text. Typically 1-3 sentences with high-level overview and urgency level. Action links are auto-appended." ) .optional(), }); export function SubmitReviewTool(ctx: ToolContext) { return tool({ name: "submit_review", description: "Submit the current review session. All comments added via add_review_comment will be published. Must call start_review first.", parameters: SubmitReview, execute: execute(async ({ body }) => { // check if review started if (!ctx.toolState.review) { throw new Error("No review session started. Call start_review first."); } if (ctx.toolState.prNumber === undefined) { throw new Error("No PR context. Call checkout_pr or start_review first."); } const reviewId = ctx.toolState.review.id; // build quick links footer const apiUrl = process.env.API_URL || "https://pullfrog.com"; const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${ctx.toolState.prNumber}?action=fix&review_id=${reviewId}`; const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${ctx.toolState.prNumber}?action=fix-approved&review_id=${reviewId}`; const footer = buildPullfrogFooter({ workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId }, customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`], }); const bodyWithFooter = (body || "") + footer; // submit the pending review via REST const result = await ctx.octokit.rest.pulls.submitReview({ owner: ctx.owner, repo: ctx.name, pull_number: ctx.toolState.prNumber, review_id: reviewId, event: "COMMENT", body: bodyWithFooter, }); // clear review state delete ctx.toolState.review; // delete progress comment await deleteProgressComment(ctx); return { success: true, reviewId: result.data.id, html_url: result.data.html_url, state: result.data.state, }; }), }); } // legacy tool - kept for backwards compatibility export const Review = type({ pull_number: type.number.describe("The pull request number to review"), body: type.string .describe( "1-2 sentence high-level summary ONLY. Include urgency level and critical callouts (e.g., API key leak). ALL specific feedback MUST go in 'comments' array instead." ) .optional(), commit_id: type.string .describe("Optional SHA of the commit being reviewed. Defaults to latest.") .optional(), comments: type({ path: type.string.describe("The file path to comment on (relative to repo root)"), line: type.number.describe( "The line number in the file (use line numbers from the diff - usually the RIGHT side/new code)" ), side: type .enumerated("LEFT", "RIGHT") .describe( "Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT if not provided." ) .optional(), body: type.string.describe( "The comment text for this specific line. For issues appearing multiple times, comment on the first occurrence and reference others." ), start_line: type.number .describe("Start line for multi-line comments (optional, for commenting on ranges)") .optional(), }) .array() .describe( // FORK PR NOTE: checkout_pr returns the diff via GitHub API - use that for line numbers "PRIMARY location for ALL feedback. 95%+ of review content should be here. Use the diff returned from checkout_pr to find correct line numbers (RIGHT side for new code, LEFT for old)." ) .optional(), }); export function ReviewTool(ctx: ToolContext) { return tool({ name: "submit_pull_request_review", description: "DEPRECATED: Use start_review, add_review_comment, and submit_review instead for iterative review workflow. " + "Submit a review for an existing pull request. " + "IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " + "Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.", parameters: Review, execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => { // set PR context ctx.toolState.prNumber = pull_number; // get the PR to determine the head commit if commit_id not provided const pr = await ctx.octokit.rest.pulls.get({ owner: ctx.owner, repo: ctx.name, pull_number, }); // compose the request const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = { owner: ctx.owner, repo: ctx.name, pull_number, event: "COMMENT", }; if (body) params.body = body; if (commit_id) { params.commit_id = commit_id; } else { params.commit_id = pr.data.head.sha; } if (comments.length > 0) { type ReviewComment = (typeof params.comments & {})[number]; // convert comments to the format expected by GitHub API params.comments = comments.map((comment) => { const reviewComment: ReviewComment = { ...comment, }; reviewComment.side = comment.side || "RIGHT"; if (comment.start_line) { reviewComment.start_line = comment.start_line; reviewComment.start_side = comment.side || "RIGHT"; } return reviewComment; }); } const result = await ctx.octokit.rest.pulls.createReview(params); const reviewId = result.data.id; // build quick links footer and update the review body const apiUrl = process.env.API_URL || "https://pullfrog.com"; const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix&review_id=${reviewId}`; const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`; const footer = buildPullfrogFooter({ workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId }, customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`], }); const updatedBody = (body || "") + footer; // update the review with the footer await ctx.octokit.rest.pulls.updateReview({ owner: ctx.owner, repo: ctx.name, pull_number, review_id: reviewId, body: updatedBody, }); await deleteProgressComment(ctx); return { success: true, reviewId, html_url: result.data.html_url, state: result.data.state, user: result.data.user?.login, submitted_at: result.data.submitted_at, }; }), }); }