408 lines
16 KiB
TypeScript
408 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 { 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"),
|
|
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(
|
|
"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(),
|
|
});
|
|
|
|
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. " +
|
|
"For every actionable issue, the comment 'body' should explain the problem AND include a markdown " +
|
|
"code block showing the corrected code. For example: " +
|
|
"'SQL injection: id is interpolated directly into the query string.\\n\\n```ts\\nconst result = await db.query(\\'SELECT * FROM users WHERE id = $1\\', [id]);\\n```'. " +
|
|
"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 returned by checkout_pr. " +
|
|
"'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, 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.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`);
|
|
}
|
|
}
|
|
|
|
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}`
|
|
);
|
|
}
|