Files
shockbot/mcp/review.ts
T

498 lines
20 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 { parseDiffToFiles } from "./checkout.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 r = await ctx.gitea.rest.repository.repoDownloadPullDiffOrPatch({
owner: ctx.repo.owner,
repo: ctx.repo.name,
index: pullNumber,
diffType: "diff",
});
const files = parseDiffToFiles(r.data);
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"),
preamble: type.string.describe(
"One sentence describing what was reviewed (e.g. 'This PR adds device management behind a feature flag'). " +
"The server prepends '**Reviewed changes** — ' automatically. " +
"When provided, do NOT repeat the preamble inside 'body'."
).optional(),
changes: type.string.array().describe(
"Bullet list of substantive changes — neutral descriptions of what the PR added/changed/removed. " +
"Each entry is one formatted bullet, e.g. '**Feature X** — 1 sentence description'. " +
"These must describe CHANGES only, not findings or issues."
).optional(),
body: type.string.describe(
"When 'preamble'/'changes' are used: include ONLY the metadata HTML comment and any non-anchored ### sections. " +
"When used alone (legacy): full review body including preamble."
).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(
"Optional replacement code shown as a fenced code block below the comment body. " +
"Prefer putting the fix directly in 'body' as a markdown code block instead."
).optional(),
start_line: type.number.describe("Start line for multi-line ranges.").optional(),
})
.array()
.describe("Inline comments anchored to diff hunk lines.")
.optional(),
});
/** Assemble the **Reviewed changes** preamble block from structured params. */
function assemblePreamble(preamble: string, changes: string[]): string {
const lines = [`**Reviewed changes** — ${preamble}`];
if (changes.length > 0) {
lines.push("");
for (const change of changes) {
lines.push(change.startsWith("- ") ? change : `- ${change}`);
}
}
return lines.join("\n");
}
/**
* Strip body ### sections whose file paths are already covered by inline
* comments. Prevents the model from duplicating inline findings in the body.
*/
function postProcessBody(
body: string,
validComments: ReviewCommentInput[],
): string {
if (validComments.length === 0) return body;
const commentedPaths = new Set(validComments.map((c) => c.path));
// Also match short filenames (e.g. "data-settings.tsx") since the body often
// uses basenames while inline comments store full repo-relative paths.
const commentedBasenames = new Set(
validComments.map((c) => c.path.split("/").pop() ?? c.path),
);
const sectionRegex = /^### .+$/gm;
const matches: Array<{ index: number; heading: string }> = [];
let m: RegExpExecArray | null;
while ((m = sectionRegex.exec(body)) !== null) {
matches.push({ index: m.index, heading: m[0] });
}
if (matches.length === 0) return body;
const sections = matches.map((match, i) => ({
start: match.index,
end: matches[i + 1]?.index ?? body.length,
heading: match.heading,
}));
const toRemove: Array<{ start: number; end: number }> = [];
for (const section of sections) {
const content = body.slice(section.start, section.end);
let matched: string | undefined;
for (const path of commentedPaths) {
if (content.includes(path)) { matched = path; break; }
}
if (!matched) {
for (const basename of commentedBasenames) {
if (content.includes(basename)) { matched = basename; break; }
}
}
if (matched) {
toRemove.push({ start: section.start, end: section.end });
log.info(`stripped duplicate body section "${section.heading.slice(0, 80)}" — already covered by inline comment on ${matched}`);
}
}
if (toRemove.length === 0) return body;
let result = body;
for (const { start, end } of [...toRemove].reverse()) {
result = result.slice(0, start) + result.slice(end);
}
return result.replace(/\n{3,}/g, "\n\n").trim();
}
export function CreatePullRequestReviewTool(ctx: ToolContext) {
return tool({
name: "create_pull_request_review",
description:
"Submit a review for an existing pull request. " +
"PREFERRED: use 'preamble' + 'changes' for the reviewed-changes block, and 'body' for ONLY the metadata HTML comment and non-anchored ### sections. " +
"IMPORTANT: 95%+ of feedback must be in 'comments' with file paths and line numbers — not in 'body'. " +
"The first submission may error once with a diff-coverage nudge — retry with the same arguments. " +
"Inline comments: 'path' must be the SOURCE FILE path (e.g. 'apps/foo/bar.ts') from the diff --git header, NOT the diffPath. " +
"'line' must be the actual file line number from the '| newLine |' column in the formatted diff (not the TOC line range). " +
"Inline comments can ONLY target files and lines that appear in the PR diff.",
parameters: CreatePullRequestReview,
execute: execute(async ({ pull_number, preamble, changes, body, approved, commit_id, comments = [] }) => {
if (body) body = fixDoubleEscapedString(body);
// Assemble structured preamble if provided
if (preamble || (changes && changes.length > 0)) {
const preambleBlock = assemblePreamble(preamble ?? "", changes ?? []);
body = body ? `${preambleBlock}\n\n${body}` : preambleBlock;
}
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.request(
"GET /repos/{owner}/{repo}/pulls/{index}",
{ owner: ctx.repo.owner, repo: ctx.repo.name, index: pull_number }
);
latestHeadSha = (pr.data as { head?: { sha?: string } }).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`);
}
}
// Strip body ### sections that duplicate inline comments (#1 post-processing)
if (body && validComments.length > 0) {
body = postProcessBody(body, validComments);
}
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.request("POST /repos/{owner}/{repo}/pulls/{index}/reviews", {
owner: ctx.repo.owner,
repo: ctx.repo.name,
index: pull_number,
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 reviewData = result.data as { id?: number; html_url?: string; state?: string };
const reviewId = reviewData.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: reviewData.html_url,
state: reviewData.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: reviewData.html_url,
state: reviewData.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}`
);
}