Files
shockbot/mcp/review.ts
T

402 lines
16 KiB
TypeScript

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<number>();
const left = new Set<number>();
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<Map<string, CommentableLines>> {
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<string, CommentableLines>();
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<string, CommentableLines>
): { 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<Awaited<ReturnType<typeof ctx.gitea.rest.repository.repoCreatePullReview>>>(
() =>
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}`
);
}