import { type } from "arktype"; import { formatMcpToolRef } from "../external.ts"; import type { CommentableLines } from "../toolState.ts"; import { buildShockbotFooter } from "../utils/buildShockbotFooter.ts"; import { log } from "../utils/cli.ts"; import { countLinesInRanges, getDiffCoverageBreakdown, renderDiffCoverageBreakdown, } from "../utils/diffCoverage.ts"; import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts"; import type { ChangedFileWithPatch } from "../utils/gitea.ts"; import { retry } from "../utils/retry.ts"; import { deleteProgressComment } from "./comment.ts"; import type { ToolContext } from "./server.ts"; import { execute, tool } from "./shared.ts"; export type { CommentableLines }; /** * Parse a PR file's patch to determine which line numbers on each side are * valid anchors for inline comments. */ export function commentableLinesForFile(patch: string | undefined): CommentableLines { const right = new Set(); const left = new Set(); if (!patch) return { RIGHT: right, LEFT: left }; let oldLine = 0; let newLine = 0; for (const line of patch.split("\n")) { const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); if (hunk) { oldLine = parseInt(hunk[1], 10); newLine = parseInt(hunk[2], 10); continue; } const changeType = line[0]; if (changeType === "+") { right.add(newLine); newLine++; } else if (changeType === "-") { left.add(oldLine); oldLine++; } else if (changeType === " ") { right.add(newLine); left.add(oldLine); newLine++; oldLine++; } } return { RIGHT: right, LEFT: left }; } export async function buildCommentableMap( ctx: ToolContext, pullNumber: number ): Promise> { const cached = ctx.toolState.commentableLinesByFile; const cachedFor = ctx.toolState.commentableLinesPullNumber; const cachedSha = ctx.toolState.commentableLinesCheckoutSha; const currentSha = ctx.toolState.checkoutSha; if (cached && cachedFor === pullNumber && cachedSha && cachedSha === currentSha) return cached; const files = (await ctx.gitea.paginate(ctx.gitea.rest.repository.repoGetPullRequestFiles, { owner: ctx.repo.owner, repo: ctx.repo.name, index: pullNumber, })) as ChangedFileWithPatch[]; const map = new Map(); for (const file of files) { if (file.filename) { map.set(file.filename, commentableLinesForFile(file.patch)); } } return map; } export interface ReviewCommentInput { path: string; line: number; side?: "LEFT" | "RIGHT" | undefined; body?: string | undefined; suggestion?: string | undefined; start_line?: number | undefined; } export interface DroppedComment { path: string; line: number; startLine?: number | undefined; side: "LEFT" | "RIGHT"; reason: string; } export function validateInlineComments( comments: ReviewCommentInput[], map: Map ): { valid: ReviewCommentInput[]; dropped: DroppedComment[] } { const valid: ReviewCommentInput[] = []; const dropped: DroppedComment[] = []; for (const c of comments) { const side = c.side === "LEFT" ? "LEFT" : "RIGHT"; const line = c.line ?? 0; const startLine = c.start_line ?? line; const lines = map.get(c.path); const record = (reason: string): void => { const entry: DroppedComment = { path: c.path, line, side, reason }; if (c.start_line != null) entry.startLine = c.start_line; dropped.push(entry); }; if (!lines) { record(`file not in PR diff`); continue; } if (lines.LEFT.size === 0 && lines.RIGHT.size === 0) { record(`file has no textual diff (binary, pure rename, or mode change)`); continue; } const anchors = lines[side]; if (!anchors.has(line)) { record(`line ${line} (${side}) is not inside a diff hunk`); continue; } if (c.start_line != null && c.start_line > line) { record(`start_line ${c.start_line} is after line ${line}`); continue; } if (startLine !== line && !anchors.has(startLine)) { record(`start_line ${startLine} (${side}) is not inside a diff hunk`); continue; } valid.push(c); } return { valid, dropped }; } export const MAX_DROPPED_COMMENT_LINES = 50; export type ReviewSkipDecision = | { kind: "no-issues"; reason: string } | { kind: "empty-downgraded-approve"; reason: string }; export type DuplicateReviewDecision = { kind: "already-submitted"; reviewId: number; reason: string }; export function duplicateReviewDecision(params: { existing: { id: number; reviewedSha: string | undefined } | undefined; currentCheckoutSha: string | undefined; }): DuplicateReviewDecision | null { const existing = params.existing; if (!existing) return null; if (params.currentCheckoutSha && existing.reviewedSha && params.currentCheckoutSha !== existing.reviewedSha) return null; return { kind: "already-submitted", reviewId: existing.id, reason: `review ${existing.id} was already submitted in this session; ignoring duplicate call`, }; } export function reviewSkipDecision(params: { approved: boolean; body: string | null | undefined; hasComments: boolean; prApproveEnabled: boolean; }): ReviewSkipDecision | null { if (params.body || params.hasComments) return null; if (!params.approved) return { kind: "no-issues", reason: "no issues found — nothing to post" }; if (!params.prApproveEnabled) return { kind: "empty-downgraded-approve", reason: "approve requested but prApproveEnabled is disabled", }; return null; } export function formatDroppedCommentsNote(dropped: DroppedComment[]): string { const renderEntry = (d: DroppedComment): string => { const range = d.startLine != null && d.startLine !== d.line ? `${d.startLine}-${d.line}` : `${d.line}`; return `- \`${d.path}:${range}\` (${d.side}) — ${d.reason}`; }; const shown = dropped.slice(0, MAX_DROPPED_COMMENT_LINES).map(renderEntry); const remainder = dropped.length - shown.length; if (remainder > 0) shown.push(`- …and ${remainder} more dropped comment(s) not shown`); return ( `\n\n---\n\n` + `**Note:** ${dropped.length} inline comment(s) dropped because they did not anchor to lines inside the PR diff:\n` + shown.join("\n") ); } export const CreatePullRequestReview = type({ pull_number: type.number.describe("The pull request number to review"), body: type.string.describe("1-2 sentence high-level summary").optional(), approved: type.boolean.describe("Set to true to submit as an approval.").optional(), commit_id: type.string.describe("Optional SHA of the commit being reviewed.").optional(), comments: type({ path: type.string.describe("The file path to comment on (must appear in the PR diff)."), line: type.number.describe("Line number to comment on (end line for multi-line ranges)."), side: type.enumerated("LEFT", "RIGHT").describe("LEFT (old code) or RIGHT (new code). Defaults to RIGHT.").optional(), body: type.string.describe("Explanatory comment text").optional(), suggestion: type.string.describe("Full replacement code. MUST preserve exact indentation.").optional(), start_line: type.number.describe("Start line for multi-line ranges.").optional(), }) .array() .describe("Inline comments anchored to diff hunk lines.") .optional(), }); export function CreatePullRequestReviewTool(ctx: ToolContext) { return tool({ name: "create_pull_request_review", description: "Submit a review for an existing pull request. " + "Reviews with no body AND no comments are silently skipped. " + "IMPORTANT: 95%+ of feedback should be in 'comments' with file paths and line numbers. " + "Only use 'body' for a 1-2 sentence summary. Use 'suggestion' to propose replacement code. " + "The first submission may error once with a diff-coverage nudge — retry with the same arguments. " + "Inline comments can ONLY target files and lines that appear in the PR diff.", parameters: CreatePullRequestReview, execute: execute(async ({ pull_number, body, approved, commit_id, comments = [] }) => { if (body) body = fixDoubleEscapedString(body); ctx.toolState.issueNumber = pull_number; const dup = duplicateReviewDecision({ existing: ctx.toolState.review, currentCheckoutSha: ctx.toolState.checkoutSha, }); if (dup) { log.info(`skipping duplicate review: ${dup.reason}`); return { success: true, skipped: true, reason: dup.reason, reviewId: dup.reviewId }; } const skip = reviewSkipDecision({ approved: approved ?? false, body, hasComments: comments.length > 0, prApproveEnabled: ctx.prApproveEnabled, }); if (skip) { log.info(`skipping review: ${skip.reason}`); return { success: true, skipped: true, reason: skip.reason }; } // SDK event names: "APPROVED" | "COMMENT" | "REQUEST_CHANGES" let event: "APPROVED" | "COMMENT" = approved ? "APPROVED" : "COMMENT"; if (event === "APPROVED" && !ctx.prApproveEnabled) { log.info("prApproveEnabled is disabled — downgrading APPROVED to COMMENT"); event = "COMMENT"; } let latestHeadSha: string | undefined; let effectiveCommitId = commit_id; if (!effectiveCommitId) { try { const pr = await ctx.gitea.rest.repository.repoGetPullRequest({ owner: ctx.repo.owner, repo: ctx.repo.name, index: pull_number, }); latestHeadSha = pr.data.head?.sha; effectiveCommitId = ctx.toolState.checkoutSha ?? latestHeadSha; } catch { effectiveCommitId = ctx.toolState.checkoutSha; } } runDiffCoveragePreflight({ ctx }); // Build review comments const reviewComments: ReviewCommentInput[] = comments.map((c) => { let commentBody = fixDoubleEscapedString(c.body || ""); if (c.suggestion !== undefined) { const block = "```suggestion\n" + c.suggestion + "\n```"; commentBody = commentBody ? `${commentBody}\n\n${block}` : block; } return { path: c.path, line: c.line, side: c.side || "RIGHT", body: commentBody, start_line: c.start_line }; }); let droppedComments: DroppedComment[] = []; let validComments: ReviewCommentInput[] = []; if (reviewComments.length > 0) { const commentableMap = await buildCommentableMap(ctx, pull_number); const validation = validateInlineComments(reviewComments, commentableMap); droppedComments = validation.dropped; validComments = validation.valid; if (droppedComments.length > 0) { log.info(`dropping ${droppedComments.length}/${reviewComments.length} invalid inline comments`); } } if (droppedComments.length > 0) { const note = formatDroppedCommentsNote(droppedComments); body = body ? body + note : note.replace(/^\n\n/, ""); } if (!approved && !body && !validComments.length) { log.info("review has no body and all inline comments were dropped — skipping"); return { success: true, skipped: true, reason: "all inline comments were invalid", droppedComments }; } // Convert to SDK's CreatePullReviewComment format const sdkComments = validComments.map((c) => ({ path: c.path, body: c.body ?? "", new_position: c.side !== "LEFT" ? c.line : undefined, old_position: c.side === "LEFT" ? c.line : undefined, })); const footer = buildShockbotFooter({ model: ctx.toolState.model }); const fullBody = body ? `${body}${footer}` : footer.trimStart(); const result = await retry>>( () => ctx.gitea.rest.repository.repoCreatePullReview({ owner: ctx.repo.owner, repo: ctx.repo.name, index: pull_number, body: { body: fullBody, commit_id: effectiveCommitId, event, comments: sdkComments, }, }), { delaysMs: [1_000, 3_000], shouldRetry: (err) => /internal error|500|503/i.test(err instanceof Error ? err.message : String(err)), label: "review submission", } ); const reviewId = result.data.id!; log.info(`» created review ${reviewId} on pull request #${pull_number}`); ctx.toolState.review = { id: reviewId, nodeId: String(reviewId), reviewedSha: ctx.toolState.checkoutSha ?? effectiveCommitId, }; ctx.toolState.wasUpdated = true; await deleteProgressComment(ctx).catch((err) => { log.debug(`progress comment cleanup after review failed: ${err}`); }); if (ctx.toolState.checkoutSha && latestHeadSha && latestHeadSha !== ctx.toolState.checkoutSha) { const fromSha = ctx.toolState.checkoutSha; const toSha = latestHeadSha; ctx.toolState.beforeSha = fromSha; ctx.toolState.checkoutSha = toSha; log.info(`new commits detected during review: ${fromSha.slice(0, 7)}..${toSha.slice(0, 7)}`); return { success: true, reviewId, html_url: result.data.html_url, state: result.data.state, droppedComments: droppedComments.length > 0 ? droppedComments : undefined, newCommits: { from: fromSha, to: toSha, instructions: `new commits were pushed while you were reviewing. call \`${formatMcpToolRef(ctx.agentId, "checkout_pr")}\` again to fetch the latest version and submit another review covering only the new changes.`, }, }; } return { success: true, reviewId, html_url: result.data.html_url, state: result.data.state, droppedComments: droppedComments.length > 0 ? droppedComments : undefined, }; }), }); } function runDiffCoveragePreflight(params: { ctx: ToolContext }): void { const coverageState = params.ctx.toolState.diffCoverage; if (!coverageState || coverageState.coveragePreflightRan) { log.debug("diff coverage pre-flight skipped"); return; } coverageState.coveragePreflightRan = true; const breakdown = getDiffCoverageBreakdown({ state: coverageState }); const unread: Array<{ path: string; ranges: string; unreadLines: number }> = []; let unreadLines = 0; for (const file of breakdown.files) { if (file.unreadRanges.length === 0) continue; const rangesText = file.unreadRanges.map((r) => `${r.startLine}-${r.endLine}`).join(", "); const fileUnreadLines = countLinesInRanges({ ranges: file.unreadRanges }); unread.push({ path: file.filename, ranges: rangesText, unreadLines: fileUnreadLines }); unreadLines += fileUnreadLines; } coverageState.lastBreakdown = renderDiffCoverageBreakdown({ diffPath: coverageState.diffPath, breakdown }); if (unreadLines === 0) return; log.info(`diff coverage pre-flight nudge: unread lines=${unreadLines}, files=${unread.length}`); const unreadText = unread.map((e) => `- ${e.path} (${e.unreadLines} lines, ${e.ranges})`).join("\n"); throw new Error( `diff coverage pre-flight: some TOC regions were not read before review submission. ` + `this is a one-time nudge — read the ranges below from ${coverageState.diffPath} on a best-effort basis, then call create_pull_request_review again. ` + `you are NOT obligated to read generated artifacts (lockfiles, codegen output, snapshot dirs). ` + `this pre-flight will not block again this session.\n\n` + `unread TOC regions:\n${unreadText}\n\n` + `${coverageState.lastBreakdown}` ); }