import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs";import type { PullRequestReviewPosition } from "@t3tools/contracts";import * as Schema from "effect/Schema"; const ReviewCommentSelectionSchema = Schema.Struct({ start: Schema.Number, side: Schema.Literals(["additions", "deletions"]), end: Schema.Number, endSide: Schema.Literals(["additions", "deletions"]),});type ReviewCommentSelection = typeof ReviewCommentSelectionSchema.Type; 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), selection: Schema.optional(ReviewCommentSelectionSchema),}); 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; readonly selection?: ReviewCommentSelection | 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");} /** * Keeps a comment's own words from closing the block they travel in. The parser ends an * attachment at the first `</review_comment>`, so text carrying one would spill the rest of * itself into the prompt — and could open a forged attachment naming any file it liked. Only * ever the local reader's words before, but a pull request's review bodies come from whoever * wrote them. */function neutralizeReviewCommentTags(text: string): string { return text.replace(/<(?=\/?review_comment\b)/giu, "<");} 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(""), neutralizeReviewCommentTags(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, includeExpandedContext: boolean, slice?: { readonly startIndex: number; readonly endIndex: number },): ReadonlyArray<DiffReviewLine> { const rows: DiffReviewLine[] = []; let rowIndex = 0; let oldContextStart = 1; let newContextStart = 1; const pushRow = (row: DiffReviewLine) => { if (!slice || (rowIndex >= slice.startIndex && rowIndex <= slice.endIndex)) { rows.push(row); } rowIndex += 1; }; const pushContextGap = (oldStart: number, newStart: number, lineCount: number) => { const count = Math.max(0, lineCount); const firstOffset = slice ? Math.max(0, slice.startIndex - rowIndex) : 0; const lastOffset = slice ? Math.min(count - 1, slice.endIndex - rowIndex) : count - 1; for (let offset = firstOffset; offset <= lastOffset; offset += 1) { rows.push({ change: "context", oldLineNumber: oldStart + offset, newLineNumber: newStart + offset, content: stripTrailingNewline(fileDiff.additionLines[newStart + offset - 1] ?? ""), }); } rowIndex += count; }; for (const hunk of fileDiff.hunks) { if (includeExpandedContext) { const oldHunkStart = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); const newHunkStart = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); const contextLines = Math.min(oldHunkStart - oldContextStart, newHunkStart - newContextStart); pushContextGap(oldContextStart, newContextStart, contextLines); } 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) { pushRow({ 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) { pushRow({ change: "delete", oldLineNumber, newLineNumber: null, content: stripTrailingNewline(fileDiff.deletionLines[deletionLineIndex] ?? ""), }); oldLineNumber += 1; deletionLineIndex += 1; } for (let index = 0; index < segment.additions; index += 1) { pushRow({ change: "add", oldLineNumber: null, newLineNumber, content: stripTrailingNewline(fileDiff.additionLines[additionLineIndex] ?? ""), }); newLineNumber += 1; additionLineIndex += 1; } } oldContextStart = hunk.deletionStart + hunk.deletionCount; newContextStart = hunk.additionStart + hunk.additionCount; if (hunk.deletionCount === 0) oldContextStart += 1; if (hunk.additionCount === 0) newContextStart += 1; } if (includeExpandedContext) { const trailingLines = Math.min( fileDiff.deletionLines.length - oldContextStart + 1, fileDiff.additionLines.length - newContextStart + 1, ); pushContextGap(oldContextStart, newContextStart, trailingLines); } 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 { if (comment.selection) return comment.selection; const includeExpandedContext = !fileDiff.isPartial; const startLine = buildDiffReviewLines(fileDiff, includeExpandedContext, { startIndex: comment.startIndex, endIndex: comment.startIndex, })[0]; const endLine = comment.endIndex === comment.startIndex ? startLine : buildDiffReviewLines(fileDiff, includeExpandedContext, { startIndex: comment.endIndex, endIndex: comment.endIndex, })[0]; 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( fileDiff: FileDiffMetadata, lineNumber: number, side: SelectionSide | undefined, includeExpandedContext = !fileDiff.isPartial,): number { const findOnSide = (selectedSide: "left" | "right") => { let rowIndex = 0; let oldContextStart = 1; let newContextStart = 1; const findContextIndex = (oldStart: number, newStart: number, lineCount: number) => { const count = Math.max(0, lineCount); const selectedStart = selectedSide === "left" ? oldStart : newStart; const offset = lineNumber - selectedStart; return offset >= 0 && offset < count ? rowIndex + offset : -1; }; for (const hunk of fileDiff.hunks) { if (includeExpandedContext) { const oldContextEnd = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); const newContextEnd = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); const contextLines = Math.min( oldContextEnd - oldContextStart, newContextEnd - newContextStart, ); const contextIndex = findContextIndex(oldContextStart, newContextStart, contextLines); if (contextIndex >= 0) return contextIndex; rowIndex += Math.max(0, contextLines); } let oldLineNumber = hunk.deletionStart; let newLineNumber = hunk.additionStart; for (const segment of hunk.hunkContent) { if (segment.type === "context") { const contextIndex = findContextIndex(oldLineNumber, newLineNumber, segment.lines); if (contextIndex >= 0) return contextIndex; rowIndex += segment.lines; oldLineNumber += segment.lines; newLineNumber += segment.lines; continue; } if ( selectedSide === "left" && lineNumber >= oldLineNumber && lineNumber < oldLineNumber + segment.deletions ) { return rowIndex + lineNumber - oldLineNumber; } rowIndex += segment.deletions; oldLineNumber += segment.deletions; if ( selectedSide === "right" && lineNumber >= newLineNumber && lineNumber < newLineNumber + segment.additions ) { return rowIndex + lineNumber - newLineNumber; } rowIndex += segment.additions; newLineNumber += segment.additions; } oldContextStart = hunk.deletionStart + hunk.deletionCount; newContextStart = hunk.additionStart + hunk.additionCount; if (hunk.deletionCount === 0) oldContextStart += 1; if (hunk.additionCount === 0) newContextStart += 1; } if (!includeExpandedContext) return -1; const trailingLines = Math.min(Lines 1–500 of 657. Load more
import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs"; import type { PullRequestReviewPosition } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; const ReviewCommentSelectionSchema = Schema.Struct({ start: Schema.Number, side: Schema.Literals(["additions", "deletions"]), end: Schema.Number, endSide: Schema.Literals(["additions", "deletions"]), }); type ReviewCommentSelection = typeof ReviewCommentSelectionSchema.Type; 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), selection: Schema.optional(ReviewCommentSelectionSchema), }); 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; readonly selection?: ReviewCommentSelection | 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"); } /** * Keeps a comment's own words from closing the block they travel in. The parser ends an * attachment at the first `</review_comment>`, so text carrying one would spill the rest of * itself into the prompt — and could open a forged attachment naming any file it liked. Only * ever the local reader's words before, but a pull request's review bodies come from whoever * wrote them. */ function neutralizeReviewCommentTags(text: string): string { return text.replace(/<(?=\/?review_comment\b)/giu, "<"); } 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(""), neutralizeReviewCommentTags(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, includeExpandedContext: boolean, slice?: { readonly startIndex: number; readonly endIndex: number }, ): ReadonlyArray<DiffReviewLine> { const rows: DiffReviewLine[] = []; let rowIndex = 0; let oldContextStart = 1; let newContextStart = 1; const pushRow = (row: DiffReviewLine) => { if (!slice || (rowIndex >= slice.startIndex && rowIndex <= slice.endIndex)) { rows.push(row); } rowIndex += 1; }; const pushContextGap = (oldStart: number, newStart: number, lineCount: number) => { const count = Math.max(0, lineCount); const firstOffset = slice ? Math.max(0, slice.startIndex - rowIndex) : 0; const lastOffset = slice ? Math.min(count - 1, slice.endIndex - rowIndex) : count - 1; for (let offset = firstOffset; offset <= lastOffset; offset += 1) { rows.push({ change: "context", oldLineNumber: oldStart + offset, newLineNumber: newStart + offset, content: stripTrailingNewline(fileDiff.additionLines[newStart + offset - 1] ?? ""), }); } rowIndex += count; }; for (const hunk of fileDiff.hunks) { if (includeExpandedContext) { const oldHunkStart = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); const newHunkStart = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); const contextLines = Math.min(oldHunkStart - oldContextStart, newHunkStart - newContextStart); pushContextGap(oldContextStart, newContextStart, contextLines); } 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) { pushRow({ 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) { pushRow({ change: "delete", oldLineNumber, newLineNumber: null, content: stripTrailingNewline(fileDiff.deletionLines[deletionLineIndex] ?? ""), }); oldLineNumber += 1; deletionLineIndex += 1; } for (let index = 0; index < segment.additions; index += 1) { pushRow({ change: "add", oldLineNumber: null, newLineNumber, content: stripTrailingNewline(fileDiff.additionLines[additionLineIndex] ?? ""), }); newLineNumber += 1; additionLineIndex += 1; } } oldContextStart = hunk.deletionStart + hunk.deletionCount; newContextStart = hunk.additionStart + hunk.additionCount; if (hunk.deletionCount === 0) oldContextStart += 1; if (hunk.additionCount === 0) newContextStart += 1; } if (includeExpandedContext) { const trailingLines = Math.min( fileDiff.deletionLines.length - oldContextStart + 1, fileDiff.additionLines.length - newContextStart + 1, ); pushContextGap(oldContextStart, newContextStart, trailingLines); } 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 { if (comment.selection) return comment.selection; const includeExpandedContext = !fileDiff.isPartial; const startLine = buildDiffReviewLines(fileDiff, includeExpandedContext, { startIndex: comment.startIndex, endIndex: comment.startIndex, })[0]; const endLine = comment.endIndex === comment.startIndex ? startLine : buildDiffReviewLines(fileDiff, includeExpandedContext, { startIndex: comment.endIndex, endIndex: comment.endIndex, })[0]; 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( fileDiff: FileDiffMetadata, lineNumber: number, side: SelectionSide | undefined, includeExpandedContext = !fileDiff.isPartial, ): number { const findOnSide = (selectedSide: "left" | "right") => { let rowIndex = 0; let oldContextStart = 1; let newContextStart = 1; const findContextIndex = (oldStart: number, newStart: number, lineCount: number) => { const count = Math.max(0, lineCount); const selectedStart = selectedSide === "left" ? oldStart : newStart; const offset = lineNumber - selectedStart; return offset >= 0 && offset < count ? rowIndex + offset : -1; }; for (const hunk of fileDiff.hunks) { if (includeExpandedContext) { const oldContextEnd = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); const newContextEnd = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); const contextLines = Math.min( oldContextEnd - oldContextStart, newContextEnd - newContextStart, ); const contextIndex = findContextIndex(oldContextStart, newContextStart, contextLines); if (contextIndex >= 0) return contextIndex; rowIndex += Math.max(0, contextLines); } let oldLineNumber = hunk.deletionStart; let newLineNumber = hunk.additionStart; for (const segment of hunk.hunkContent) { if (segment.type === "context") { const contextIndex = findContextIndex(oldLineNumber, newLineNumber, segment.lines); if (contextIndex >= 0) return contextIndex; rowIndex += segment.lines; oldLineNumber += segment.lines; newLineNumber += segment.lines; continue; } if ( selectedSide === "left" && lineNumber >= oldLineNumber && lineNumber < oldLineNumber + segment.deletions ) { return rowIndex + lineNumber - oldLineNumber; } rowIndex += segment.deletions; oldLineNumber += segment.deletions; if ( selectedSide === "right" && lineNumber >= newLineNumber && lineNumber < newLineNumber + segment.additions ) { return rowIndex + lineNumber - newLineNumber; } rowIndex += segment.additions; newLineNumber += segment.additions; } oldContextStart = hunk.deletionStart + hunk.deletionCount; newContextStart = hunk.additionStart + hunk.additionCount; if (hunk.deletionCount === 0) oldContextStart += 1; if (hunk.additionCount === 0) newContextStart += 1; } if (!includeExpandedContext) return -1; const trailingLines = Math.min(