import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs";import * as Schema from "effect/Schema"; export const ReviewCommentContextSchema = Schema.Struct({ id: Schema.String, sectionId: Schema.String, sectionTitle: Schema.String, filePath: Schema.String, startIndex: Schema.Number, endIndex: Schema.Number, rangeLabel: Schema.String, text: Schema.String, diff: Schema.String, fenceLanguage: Schema.optional(Schema.String),}); export interface ReviewCommentContext { readonly id: string; readonly sectionId: string; readonly sectionTitle: string; readonly filePath: string; readonly startIndex: number; readonly endIndex: number; readonly rangeLabel: string; readonly text: string; readonly diff: string; readonly fenceLanguage?: string | undefined;} interface DiffReviewLine { readonly change: "context" | "add" | "delete"; readonly oldLineNumber: number | null; readonly newLineNumber: number | null; readonly content: string;} export type ReviewCommentMessageSegment = | { readonly kind: "text"; readonly id: string; readonly text: string; } | { readonly kind: "review-comment"; readonly comment: ReviewCommentContext; }; const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b([^>]*)>\s*([\s\S]*?)<\/review_comment>/g;const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;const REVIEW_COMMENT_FENCE_PATTERN = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g; function escapeReviewCommentAttribute(value: string): string { return value .replace(/&/g, "&") .replace(/"/g, """) .replace(/</g, "<") .replace(/>/g, ">");} function unescapeReviewCommentAttribute(value: string): string { return value .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/&/g, "&");} function readReviewCommentAttributes(rawAttributes: string): Record<string, string> { const attributes: Record<string, string> = {}; for (const match of rawAttributes.matchAll(REVIEW_COMMENT_ATTRIBUTE_PATTERN)) { attributes[match[1]!] = unescapeReviewCommentAttribute(match[2] ?? ""); } return attributes;} function readNonNegativeInteger(value: string | undefined): number | null { if (value === undefined || !/^\d+$/.test(value)) { return null; } return Number(value);} function extractReviewCommentBody(rawBody: string): { text: string; language: string; contents: string;} { const matches = Array.from(rawBody.matchAll(REVIEW_COMMENT_FENCE_PATTERN)); const match = matches.at(-1); const fenceIndex = match?.index; return { text: rawBody.slice(0, fenceIndex ?? rawBody.length).trim(), language: match?.[2]?.trim() || "diff", contents: match?.[3] ?? "", };} function parseReviewCommentContext( rawAttributes: string, rawBody: string, index: number,): ReviewCommentContext | null { const attributes = readReviewCommentAttributes(rawAttributes); const startIndex = readNonNegativeInteger(attributes.startIndex); const endIndex = readNonNegativeInteger(attributes.endIndex); const filePath = attributes.filePath?.trim(); const sectionId = attributes.sectionId?.trim(); if (!filePath || !sectionId || startIndex === null || endIndex === null) { return null; } const body = extractReviewCommentBody(rawBody); return { id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`, sectionId, sectionTitle: attributes.sectionTitle?.trim() || "Review", filePath, startIndex: Math.min(startIndex, endIndex), endIndex: Math.max(startIndex, endIndex), rangeLabel: attributes.rangeLabel?.trim() || "line", text: body.text, diff: body.contents, fenceLanguage: body.language, };} export function parseReviewCommentMessageSegments( value: string,): ReadonlyArray<ReviewCommentMessageSegment> { const segments: ReviewCommentMessageSegment[] = []; let cursor = 0; let parsedCommentIndex = 0; for (const match of value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)) { const matchIndex = match.index ?? 0; const beforeText = value.slice(cursor, matchIndex); if (beforeText.length > 0) { segments.push({ kind: "text", id: `review-comment-text:${cursor}`, text: beforeText, }); } const comment = parseReviewCommentContext(match[1] ?? "", match[2] ?? "", parsedCommentIndex); if (comment) { segments.push({ kind: "review-comment", comment }); parsedCommentIndex += 1; } else { segments.push({ kind: "text", id: `review-comment-invalid:${matchIndex}`, text: match[0], }); } cursor = matchIndex + match[0].length; } const rest = value.slice(cursor); if (rest.length > 0) { segments.push({ kind: "text", id: `review-comment-text:${cursor}`, text: rest, }); } return segments;} export function hasReviewCommentMessageSegments(value: string): boolean { return parseReviewCommentMessageSegments(value).some( (segment) => segment.kind === "review-comment", );} export function formatReviewCommentFence(language: string, contents: string): string { const longestBacktickRun = Math.max( 0, ...Array.from(contents.matchAll(/`+/g), (match) => match[0].length), ); const fence = "`".repeat(Math.max(3, longestBacktickRun + 1)); return [`${fence}${language}`, contents.trimEnd(), fence].join("\n");} export function formatReviewCommentContext(comment: ReviewCommentContext): string { return [ [ "<review_comment", ` sectionId="${escapeReviewCommentAttribute(comment.sectionId)}"`, ` sectionTitle="${escapeReviewCommentAttribute(comment.sectionTitle)}"`, ` filePath="${escapeReviewCommentAttribute(comment.filePath)}"`, ` startIndex="${comment.startIndex}"`, ` endIndex="${comment.endIndex}"`, ` rangeLabel="${escapeReviewCommentAttribute(comment.rangeLabel)}"`, ">", ].join(""), comment.text.trim(), formatReviewCommentFence(comment.fenceLanguage ?? "diff", comment.diff), "</review_comment>", ].join("\n");} export function appendReviewCommentsToPrompt( prompt: string, comments: ReadonlyArray<ReviewCommentContext>,): string { const blocks = comments.map(formatReviewCommentContext); if (blocks.length === 0) return prompt; const trimmedPrompt = prompt.trim(); return trimmedPrompt.length > 0 ? `${trimmedPrompt}\n\n${blocks.join("\n\n")}` : blocks.join("\n\n");} export function buildFileReviewComment(input: { id: string; filePath: string; startLine: number; endLine: number; text: string; contents: string;}): ReviewCommentContext { const startLine = Math.max(1, Math.min(input.startLine, input.endLine)); const endLine = Math.max(startLine, Math.max(input.startLine, input.endLine)); const selectedLines = input.contents.split("\n").slice(startLine - 1, endLine); return { id: input.id, sectionId: `file:${input.filePath}`, sectionTitle: "File comment", filePath: input.filePath, startIndex: startLine - 1, endIndex: endLine - 1, rangeLabel: startLine === endLine ? `L${startLine}` : `L${startLine} to L${endLine}`, text: input.text.trim(), diff: selectedLines.join("\n"), fenceLanguage: inferReviewCommentFenceLanguage(input.filePath), };} export function inferReviewCommentFenceLanguage(filePath: string): string { const normalizedPath = filePath.replaceAll("\\", "/"); const fileName = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1).toLowerCase(); const extensionIndex = fileName.lastIndexOf("."); if (extensionIndex > 0 && extensionIndex < fileName.length - 1) { return fileName.slice(extensionIndex + 1); } if (fileName.startsWith(".") && fileName.length > 1) { return fileName.slice(1); } return "text";} function stripTrailingNewline(value: string): string { return value.endsWith("\n") ? value.slice(0, -1) : value;} function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray<DiffReviewLine> { const rows: DiffReviewLine[] = []; for (const hunk of fileDiff.hunks) { let oldLineNumber = hunk.deletionStart; let newLineNumber = hunk.additionStart; let deletionLineIndex = hunk.deletionLineIndex; let additionLineIndex = hunk.additionLineIndex; for (const segment of hunk.hunkContent) { if (segment.type === "context") { for (let index = 0; index < segment.lines; index += 1) { rows.push({ change: "context", oldLineNumber, newLineNumber, content: stripTrailingNewline( fileDiff.additionLines[additionLineIndex] ?? fileDiff.deletionLines[deletionLineIndex] ?? "", ), }); oldLineNumber += 1; newLineNumber += 1; deletionLineIndex += 1; additionLineIndex += 1; } continue; } for (let index = 0; index < segment.deletions; index += 1) { rows.push({ change: "delete", oldLineNumber, newLineNumber: null, content: stripTrailingNewline(fileDiff.deletionLines[deletionLineIndex] ?? ""), }); oldLineNumber += 1; deletionLineIndex += 1; } for (let index = 0; index < segment.additions; index += 1) { rows.push({ change: "add", oldLineNumber: null, newLineNumber, content: stripTrailingNewline(fileDiff.additionLines[additionLineIndex] ?? ""), }); newLineNumber += 1; additionLineIndex += 1; } } } return rows;} function getDiffReviewSelectionPoint( line: DiffReviewLine,): { lineNumber: number; side: SelectionSide } | null { if (line.change === "delete" && line.oldLineNumber !== null) { return { lineNumber: line.oldLineNumber, side: "deletions" }; } if (line.newLineNumber !== null) { return { lineNumber: line.newLineNumber, side: "additions" }; } if (line.oldLineNumber !== null) { return { lineNumber: line.oldLineNumber, side: "deletions" }; } return null;} export function restoreDiffReviewCommentRange( fileDiff: FileDiffMetadata, comment: ReviewCommentContext,): SelectedLineRange | null { const lines = buildDiffReviewLines(fileDiff); const startLine = lines[comment.startIndex]; const endLine = lines[comment.endIndex]; if (!startLine || !endLine) return null; const start = getDiffReviewSelectionPoint(startLine); const end = getDiffReviewSelectionPoint(endLine); if (!start || !end) return null; return { start: start.lineNumber, side: start.side, end: end.lineNumber, endSide: end.side, };} function findDiffReviewLineIndex( lines: ReadonlyArray<DiffReviewLine>, lineNumber: number, side: SelectionSide | undefined,): number { const preferredKey = side === "deletions" ? "oldLineNumber" : "newLineNumber"; const preferredIndex = lines.findIndex((line) => line[preferredKey] === lineNumber); if (preferredIndex >= 0) return preferredIndex; const fallbackKey = preferredKey === "oldLineNumber" ? "newLineNumber" : "oldLineNumber"; return lines.findIndex((line) => line[fallbackKey] === lineNumber);} function getDiffRange( lines: ReadonlyArray<DiffReviewLine>, key: "oldLineNumber" | "newLineNumber",): { start: number; count: number } { const numberedLines = lines.filter((line) => line[key] !== null); return { start: numberedLines[0]?.[key] ?? 0, count: numberedLines.length, };} function getDiffChangeMarker(change: DiffReviewLine["change"]): string { if (change === "add") return "+"; if (change === "delete") return "-"; return " ";} function formatDiffReviewRangeLabel(lines: ReadonlyArray<DiffReviewLine>): string { const firstLine = lines[0]; const lastLine = lines.at(-1); if (!firstLine || !lastLine) return "line"; const firstNumber = firstLine.newLineNumber ?? firstLine.oldLineNumber; const lastNumber = lastLine.newLineNumber ?? lastLine.oldLineNumber; if (firstNumber === null || lastNumber === null) { return lines.length === 1 ? "line" : `${lines.length} lines`; } const firstMarker = getDiffChangeMarker(firstLine.change).trim(); const marker = firstMarker.length > 0 && lines.every((line) => line.change === firstLine.change) ? firstMarker : ""; return firstNumber === lastNumber ? `${marker}${firstNumber}` : `${marker}${firstNumber} to ${marker}${lastNumber}`;} export function buildDiffReviewComment(input: { id: string; sectionId: string; sectionTitle: string; filePath: string; fileDiff: FileDiffMetadata; range: SelectedLineRange; text: string;}): ReviewCommentContext | null { const lines = buildDiffReviewLines(input.fileDiff); const startIndex = findDiffReviewLineIndex(lines, input.range.start, input.range.side); const endIndex = findDiffReviewLineIndex( lines, input.range.end, input.range.endSide ?? input.range.side, ); if (startIndex < 0 || endIndex < 0) return null; const normalizedStartIndex = Math.min(startIndex, endIndex); const normalizedEndIndex = Math.max(startIndex, endIndex); const selectedLines = lines.slice(normalizedStartIndex, normalizedEndIndex + 1); const oldRange = getDiffRange(selectedLines, "oldLineNumber"); const newRange = getDiffRange(selectedLines, "newLineNumber"); return { id: input.id, sectionId: input.sectionId, sectionTitle: input.sectionTitle, filePath: input.filePath, startIndex: normalizedStartIndex, endIndex: normalizedEndIndex, rangeLabel: formatDiffReviewRangeLabel(selectedLines), text: input.text.trim(), diff: [ `@@ -${oldRange.start},${oldRange.count} +${newRange.start},${newRange.count} @@`, ...selectedLines.map((line) => `${getDiffChangeMarker(line.change)}${line.content}`), ].join("\n"), fenceLanguage: "diff", };} export function buildReviewCommentRenderablePatch(comment: ReviewCommentContext): string { if ((comment.fenceLanguage ?? "diff") !== "diff") { return ""; } const diff = comment.diff.trim(); if (diff.length === 0) { return ""; } if (diff.startsWith("diff --git ")) { return diff; } const normalizedPath = comment.filePath.replaceAll("\\", "/"); return [ `diff --git a/${normalizedPath} b/${normalizedPath}`, `--- a/${normalizedPath}`, `+++ b/${normalizedPath}`, diff, ].join("\n");}
import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs";
import * as Schema from "effect/Schema";
export const ReviewCommentContextSchema = Schema.Struct({
id: Schema.String,
sectionId: Schema.String,
sectionTitle: Schema.String,
filePath: Schema.String,
startIndex: Schema.Number,
endIndex: Schema.Number,
rangeLabel: Schema.String,
text: Schema.String,
diff: Schema.String,
fenceLanguage: Schema.optional(Schema.String),
});
export interface ReviewCommentContext {
readonly id: string;
readonly sectionId: string;
readonly sectionTitle: string;
readonly filePath: string;
readonly startIndex: number;
readonly endIndex: number;
readonly rangeLabel: string;
readonly text: string;
readonly diff: string;
readonly fenceLanguage?: string | undefined;
}
interface DiffReviewLine {
readonly change: "context" | "add" | "delete";
readonly oldLineNumber: number | null;
readonly newLineNumber: number | null;
readonly content: string;
}
export type ReviewCommentMessageSegment =
| {
readonly kind: "text";
readonly id: string;
readonly text: string;
}
| {
readonly kind: "review-comment";
readonly comment: ReviewCommentContext;
};
const REVIEW_COMMENT_BLOCK_PATTERN = /<review_comment\b([^>]*)>\s*([\s\S]*?)<\/review_comment>/g;
const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;
const REVIEW_COMMENT_FENCE_PATTERN = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g;
function escapeReviewCommentAttribute(value: string): string {
return value
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/</g, "<")
.replace(/>/g, ">");
}
function unescapeReviewCommentAttribute(value: string): string {
return value
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/&/g, "&");
}
function readReviewCommentAttributes(rawAttributes: string): Record<string, string> {
const attributes: Record<string, string> = {};
for (const match of rawAttributes.matchAll(REVIEW_COMMENT_ATTRIBUTE_PATTERN)) {
attributes[match[1]!] = unescapeReviewCommentAttribute(match[2] ?? "");
}
return attributes;
}
function readNonNegativeInteger(value: string | undefined): number | null {
if (value === undefined || !/^\d+$/.test(value)) {
return null;
}
return Number(value);
}
function extractReviewCommentBody(rawBody: string): {
text: string;
language: string;
contents: string;
} {
const matches = Array.from(rawBody.matchAll(REVIEW_COMMENT_FENCE_PATTERN));
const match = matches.at(-1);
const fenceIndex = match?.index;
return {
text: rawBody.slice(0, fenceIndex ?? rawBody.length).trim(),
language: match?.[2]?.trim() || "diff",
contents: match?.[3] ?? "",
};
}
function parseReviewCommentContext(
rawAttributes: string,
rawBody: string,
index: number,
): ReviewCommentContext | null {
const attributes = readReviewCommentAttributes(rawAttributes);
const startIndex = readNonNegativeInteger(attributes.startIndex);
const endIndex = readNonNegativeInteger(attributes.endIndex);
const filePath = attributes.filePath?.trim();
const sectionId = attributes.sectionId?.trim();
if (!filePath || !sectionId || startIndex === null || endIndex === null) {
return null;
}
const body = extractReviewCommentBody(rawBody);
return {
id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`,
sectionId,
sectionTitle: attributes.sectionTitle?.trim() || "Review",
filePath,
startIndex: Math.min(startIndex, endIndex),
endIndex: Math.max(startIndex, endIndex),
rangeLabel: attributes.rangeLabel?.trim() || "line",
text: body.text,
diff: body.contents,
fenceLanguage: body.language,
};
}
export function parseReviewCommentMessageSegments(
value: string,
): ReadonlyArray<ReviewCommentMessageSegment> {
const segments: ReviewCommentMessageSegment[] = [];
let cursor = 0;
let parsedCommentIndex = 0;
for (const match of value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)) {
const matchIndex = match.index ?? 0;
const beforeText = value.slice(cursor, matchIndex);
if (beforeText.length > 0) {
segments.push({
kind: "text",
id: `review-comment-text:${cursor}`,
text: beforeText,
});
}
const comment = parseReviewCommentContext(match[1] ?? "", match[2] ?? "", parsedCommentIndex);
if (comment) {
segments.push({ kind: "review-comment", comment });
parsedCommentIndex += 1;
} else {
segments.push({
kind: "text",
id: `review-comment-invalid:${matchIndex}`,
text: match[0],
});
}
cursor = matchIndex + match[0].length;
}
const rest = value.slice(cursor);
if (rest.length > 0) {
segments.push({
kind: "text",
id: `review-comment-text:${cursor}`,
text: rest,
});
}
return segments;
}
export function hasReviewCommentMessageSegments(value: string): boolean {
return parseReviewCommentMessageSegments(value).some(
(segment) => segment.kind === "review-comment",
);
}
export function formatReviewCommentFence(language: string, contents: string): string {
const longestBacktickRun = Math.max(
0,
...Array.from(contents.matchAll(/`+/g), (match) => match[0].length),
);
const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
return [`${fence}${language}`, contents.trimEnd(), fence].join("\n");
}
export function formatReviewCommentContext(comment: ReviewCommentContext): string {
return [
[
"<review_comment",
` sectionId="${escapeReviewCommentAttribute(comment.sectionId)}"`,
` sectionTitle="${escapeReviewCommentAttribute(comment.sectionTitle)}"`,
` filePath="${escapeReviewCommentAttribute(comment.filePath)}"`,
` startIndex="${comment.startIndex}"`,
` endIndex="${comment.endIndex}"`,
` rangeLabel="${escapeReviewCommentAttribute(comment.rangeLabel)}"`,
">",
].join(""),
comment.text.trim(),
formatReviewCommentFence(comment.fenceLanguage ?? "diff", comment.diff),
"</review_comment>",
].join("\n");
}
export function appendReviewCommentsToPrompt(
prompt: string,
comments: ReadonlyArray<ReviewCommentContext>,
): string {
const blocks = comments.map(formatReviewCommentContext);
if (blocks.length === 0) return prompt;
const trimmedPrompt = prompt.trim();
return trimmedPrompt.length > 0
? `${trimmedPrompt}\n\n${blocks.join("\n\n")}`
: blocks.join("\n\n");
}
export function buildFileReviewComment(input: {
id: string;
filePath: string;
startLine: number;
endLine: number;
text: string;
contents: string;
}): ReviewCommentContext {
const startLine = Math.max(1, Math.min(input.startLine, input.endLine));
const endLine = Math.max(startLine, Math.max(input.startLine, input.endLine));
const selectedLines = input.contents.split("\n").slice(startLine - 1, endLine);
return {
id: input.id,
sectionId: `file:${input.filePath}`,
sectionTitle: "File comment",
filePath: input.filePath,
startIndex: startLine - 1,
endIndex: endLine - 1,
rangeLabel: startLine === endLine ? `L${startLine}` : `L${startLine} to L${endLine}`,
text: input.text.trim(),
diff: selectedLines.join("\n"),
fenceLanguage: inferReviewCommentFenceLanguage(input.filePath),
};
}
export function inferReviewCommentFenceLanguage(filePath: string): string {
const normalizedPath = filePath.replaceAll("\\", "/");
const fileName = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1).toLowerCase();
const extensionIndex = fileName.lastIndexOf(".");
if (extensionIndex > 0 && extensionIndex < fileName.length - 1) {
return fileName.slice(extensionIndex + 1);
}
if (fileName.startsWith(".") && fileName.length > 1) {
return fileName.slice(1);
}
return "text";
}
function stripTrailingNewline(value: string): string {
return value.endsWith("\n") ? value.slice(0, -1) : value;
}
function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray<DiffReviewLine> {
const rows: DiffReviewLine[] = [];
for (const hunk of fileDiff.hunks) {
let oldLineNumber = hunk.deletionStart;
let newLineNumber = hunk.additionStart;
let deletionLineIndex = hunk.deletionLineIndex;
let additionLineIndex = hunk.additionLineIndex;
for (const segment of hunk.hunkContent) {
if (segment.type === "context") {
for (let index = 0; index < segment.lines; index += 1) {
rows.push({
change: "context",
oldLineNumber,
newLineNumber,
content: stripTrailingNewline(
fileDiff.additionLines[additionLineIndex] ??
fileDiff.deletionLines[deletionLineIndex] ??
"",
),
});
oldLineNumber += 1;
newLineNumber += 1;
deletionLineIndex += 1;
additionLineIndex += 1;
}
continue;
}
for (let index = 0; index < segment.deletions; index += 1) {
rows.push({
change: "delete",
oldLineNumber,
newLineNumber: null,
content: stripTrailingNewline(fileDiff.deletionLines[deletionLineIndex] ?? ""),
});
oldLineNumber += 1;
deletionLineIndex += 1;
}
for (let index = 0; index < segment.additions; index += 1) {
rows.push({
change: "add",
oldLineNumber: null,
newLineNumber,
content: stripTrailingNewline(fileDiff.additionLines[additionLineIndex] ?? ""),
});
newLineNumber += 1;
additionLineIndex += 1;
}
}
}
return rows;
}
function getDiffReviewSelectionPoint(
line: DiffReviewLine,
): { lineNumber: number; side: SelectionSide } | null {
if (line.change === "delete" && line.oldLineNumber !== null) {
return { lineNumber: line.oldLineNumber, side: "deletions" };
}
if (line.newLineNumber !== null) {
return { lineNumber: line.newLineNumber, side: "additions" };
}
if (line.oldLineNumber !== null) {
return { lineNumber: line.oldLineNumber, side: "deletions" };
}
return null;
}
export function restoreDiffReviewCommentRange(
fileDiff: FileDiffMetadata,
comment: ReviewCommentContext,
): SelectedLineRange | null {
const lines = buildDiffReviewLines(fileDiff);
const startLine = lines[comment.startIndex];
const endLine = lines[comment.endIndex];
if (!startLine || !endLine) return null;
const start = getDiffReviewSelectionPoint(startLine);
const end = getDiffReviewSelectionPoint(endLine);
if (!start || !end) return null;
return {
start: start.lineNumber,
side: start.side,
end: end.lineNumber,
endSide: end.side,
};
}
function findDiffReviewLineIndex(
lines: ReadonlyArray<DiffReviewLine>,
lineNumber: number,
side: SelectionSide | undefined,
): number {
const preferredKey = side === "deletions" ? "oldLineNumber" : "newLineNumber";
const preferredIndex = lines.findIndex((line) => line[preferredKey] === lineNumber);
if (preferredIndex >= 0) return preferredIndex;
const fallbackKey = preferredKey === "oldLineNumber" ? "newLineNumber" : "oldLineNumber";
return lines.findIndex((line) => line[fallbackKey] === lineNumber);
}
function getDiffRange(
lines: ReadonlyArray<DiffReviewLine>,
key: "oldLineNumber" | "newLineNumber",
): { start: number; count: number } {
const numberedLines = lines.filter((line) => line[key] !== null);
return {
start: numberedLines[0]?.[key] ?? 0,
count: numberedLines.length,
};
}
function getDiffChangeMarker(change: DiffReviewLine["change"]): string {
if (change === "add") return "+";
if (change === "delete") return "-";
return " ";
}
function formatDiffReviewRangeLabel(lines: ReadonlyArray<DiffReviewLine>): string {
const firstLine = lines[0];
const lastLine = lines.at(-1);
if (!firstLine || !lastLine) return "line";
const firstNumber = firstLine.newLineNumber ?? firstLine.oldLineNumber;
const lastNumber = lastLine.newLineNumber ?? lastLine.oldLineNumber;
if (firstNumber === null || lastNumber === null) {
return lines.length === 1 ? "line" : `${lines.length} lines`;
}
const firstMarker = getDiffChangeMarker(firstLine.change).trim();
const marker =
firstMarker.length > 0 && lines.every((line) => line.change === firstLine.change)
? firstMarker
: "";
return firstNumber === lastNumber
? `${marker}${firstNumber}`
: `${marker}${firstNumber} to ${marker}${lastNumber}`;
}
export function buildDiffReviewComment(input: {
id: string;
sectionId: string;
sectionTitle: string;
filePath: string;
fileDiff: FileDiffMetadata;
range: SelectedLineRange;
text: string;
}): ReviewCommentContext | null {
const lines = buildDiffReviewLines(input.fileDiff);
const startIndex = findDiffReviewLineIndex(lines, input.range.start, input.range.side);
const endIndex = findDiffReviewLineIndex(
lines,
input.range.end,
input.range.endSide ?? input.range.side,
);
if (startIndex < 0 || endIndex < 0) return null;
const normalizedStartIndex = Math.min(startIndex, endIndex);
const normalizedEndIndex = Math.max(startIndex, endIndex);
const selectedLines = lines.slice(normalizedStartIndex, normalizedEndIndex + 1);
const oldRange = getDiffRange(selectedLines, "oldLineNumber");
const newRange = getDiffRange(selectedLines, "newLineNumber");
return {
id: input.id,
sectionId: input.sectionId,
sectionTitle: input.sectionTitle,
filePath: input.filePath,
startIndex: normalizedStartIndex,
endIndex: normalizedEndIndex,
rangeLabel: formatDiffReviewRangeLabel(selectedLines),
text: input.text.trim(),
diff: [
`@@ -${oldRange.start},${oldRange.count} +${newRange.start},${newRange.count} @@`,
...selectedLines.map((line) => `${getDiffChangeMarker(line.change)}${line.content}`),
].join("\n"),
fenceLanguage: "diff",
};
}
export function buildReviewCommentRenderablePatch(comment: ReviewCommentContext): string {
if ((comment.fenceLanguage ?? "diff") !== "diff") {
return "";
}
const diff = comment.diff.trim();
if (diff.length === 0) {
return "";
}
if (diff.startsWith("diff --git ")) {
return diff;
}
const normalizedPath = comment.filePath.replaceAll("\\", "/");
return [
`diff --git a/${normalizedPath} b/${normalizedPath}`,
`--- a/${normalizedPath}`,
`+++ b/${normalizedPath}`,
diff,
].join("\n");
}