1import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs";
2import { PullRequestContextMetadata, type PullRequestReviewPosition } from "@t3tools/contracts";
3import * as Schema from "effect/Schema";
4
5const ReviewCommentSelectionSchema = Schema.Struct({
6 start: Schema.Number,
7 side: Schema.Literals(["additions", "deletions"]),
8 end: Schema.Number,
9 endSide: Schema.Literals(["additions", "deletions"]),
10});
11type ReviewCommentSelection = typeof ReviewCommentSelectionSchema.Type;
12
13export const ReviewCommentContextSchema = Schema.Struct({
14 id: Schema.String,
15 sectionId: Schema.String,
16 sectionTitle: Schema.String,
17 filePath: Schema.String,
18 startIndex: Schema.Number,
19 endIndex: Schema.Number,
20 rangeLabel: Schema.String,
21 text: Schema.String,
22 diff: Schema.String,
23 fenceLanguage: Schema.optional(Schema.String),
24 selection: Schema.optional(ReviewCommentSelectionSchema),
25 pullRequest: Schema.optional(PullRequestContextMetadata),
26});
27
28export interface ReviewCommentContext {
29 readonly id: string;
30 readonly sectionId: string;
31 readonly sectionTitle: string;
32 readonly filePath: string;
33 readonly startIndex: number;
34 readonly endIndex: number;
35 readonly rangeLabel: string;
36 readonly text: string;
37 readonly diff: string;
38 readonly fenceLanguage?: string | undefined;
39 readonly selection?: ReviewCommentSelection | undefined;
40 readonly pullRequest?: PullRequestContextMetadata | undefined;
41}
42
43interface DiffReviewLine {
44 readonly change: "context" | "add" | "delete";
45 readonly oldLineNumber: number | null;
46 readonly newLineNumber: number | null;
47 readonly content: string;
48}
49
50export function formatReviewCommentFence(language: string, contents: string): string {
51 const longestBacktickRun = Math.max(
52 0,
53 ...Array.from(contents.matchAll(/`+/g), (match) => match[0].length),
54 );
55 const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
56 return [`${fence}${language}`, contents.trimEnd(), fence].join("\n");
57}
58
59export function buildFileReviewComment(input: {
60 id: string;
61 filePath: string;
62 startLine: number;
63 endLine: number;
64 text: string;
65 contents: string;
66}): ReviewCommentContext {
67 const startLine = Math.max(1, Math.min(input.startLine, input.endLine));
68 const endLine = Math.max(startLine, Math.max(input.startLine, input.endLine));
69 const selectedLines = input.contents.split("\n").slice(startLine - 1, endLine);
70 return {
71 id: input.id,
72 sectionId: `file:${input.filePath}`,
73 sectionTitle: "File comment",
74 filePath: input.filePath,
75 startIndex: startLine - 1,
76 endIndex: endLine - 1,
77 rangeLabel: startLine === endLine ? `L${startLine}` : `L${startLine} to L${endLine}`,
78 text: input.text.trim(),
79 diff: selectedLines.join("\n"),
80 fenceLanguage: inferReviewCommentFenceLanguage(input.filePath),
81 };
82}
83
84export function inferReviewCommentFenceLanguage(filePath: string): string {
85 const normalizedPath = filePath.replaceAll("\\", "/");
86 const fileName = normalizedPath.slice(normalizedPath.lastIndexOf("/") + 1).toLowerCase();
87 const extensionIndex = fileName.lastIndexOf(".");
88 if (extensionIndex > 0 && extensionIndex < fileName.length - 1) {
89 return fileName.slice(extensionIndex + 1);
90 }
91 if (fileName.startsWith(".") && fileName.length > 1) {
92 return fileName.slice(1);
93 }
94 return "text";
95}
96
97function stripTrailingNewline(value: string): string {
98 return value.endsWith("\n") ? value.slice(0, -1) : value;
99}
100
101function buildDiffReviewLines(
102 fileDiff: FileDiffMetadata,
103 includeExpandedContext: boolean,
104 slice?: { readonly startIndex: number; readonly endIndex: number },
105): ReadonlyArray<DiffReviewLine> {
106 const rows: DiffReviewLine[] = [];
107 let rowIndex = 0;
108 let oldContextStart = 1;
109 let newContextStart = 1;
110 const pushRow = (row: DiffReviewLine) => {
111 if (!slice || (rowIndex >= slice.startIndex && rowIndex <= slice.endIndex)) {
112 rows.push(row);
113 }
114 rowIndex += 1;
115 };
116 const pushContextGap = (oldStart: number, newStart: number, lineCount: number) => {
117 const count = Math.max(0, lineCount);
118 const firstOffset = slice ? Math.max(0, slice.startIndex - rowIndex) : 0;
119 const lastOffset = slice ? Math.min(count - 1, slice.endIndex - rowIndex) : count - 1;
120 for (let offset = firstOffset; offset <= lastOffset; offset += 1) {
121 rows.push({
122 change: "context",
123 oldLineNumber: oldStart + offset,
124 newLineNumber: newStart + offset,
125 content: stripTrailingNewline(fileDiff.additionLines[newStart + offset - 1] ?? ""),
126 });
127 }
128 rowIndex += count;
129 };
130
131 for (const hunk of fileDiff.hunks) {
132 if (includeExpandedContext) {
133 const oldHunkStart = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0);
134 const newHunkStart = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0);
135 const contextLines = Math.min(oldHunkStart - oldContextStart, newHunkStart - newContextStart);
136 pushContextGap(oldContextStart, newContextStart, contextLines);
137 }
138
139 let oldLineNumber = hunk.deletionStart;
140 let newLineNumber = hunk.additionStart;
141 let deletionLineIndex = hunk.deletionLineIndex;
142 let additionLineIndex = hunk.additionLineIndex;
143
144 for (const segment of hunk.hunkContent) {
145 if (segment.type === "context") {
146 for (let index = 0; index < segment.lines; index += 1) {
147 pushRow({
148 change: "context",
149 oldLineNumber,
150 newLineNumber,
151 content: stripTrailingNewline(
152 fileDiff.additionLines[additionLineIndex] ??
153 fileDiff.deletionLines[deletionLineIndex] ??
154 "",
155 ),
156 });
157 oldLineNumber += 1;
158 newLineNumber += 1;
159 deletionLineIndex += 1;
160 additionLineIndex += 1;
161 }
162 continue;
163 }
164
165 for (let index = 0; index < segment.deletions; index += 1) {
166 pushRow({
167 change: "delete",
168 oldLineNumber,
169 newLineNumber: null,
170 content: stripTrailingNewline(fileDiff.deletionLines[deletionLineIndex] ?? ""),
171 });
172 oldLineNumber += 1;
173 deletionLineIndex += 1;
174 }
175
176 for (let index = 0; index < segment.additions; index += 1) {
177 pushRow({
178 change: "add",
179 oldLineNumber: null,
180 newLineNumber,
181 content: stripTrailingNewline(fileDiff.additionLines[additionLineIndex] ?? ""),
182 });
183 newLineNumber += 1;
184 additionLineIndex += 1;
185 }
186 }
187
188 oldContextStart = hunk.deletionStart + hunk.deletionCount;
189 newContextStart = hunk.additionStart + hunk.additionCount;
190 if (hunk.deletionCount === 0) oldContextStart += 1;
191 if (hunk.additionCount === 0) newContextStart += 1;
192 }
193
194 if (includeExpandedContext) {
195 const trailingLines = Math.min(
196 fileDiff.deletionLines.length - oldContextStart + 1,
197 fileDiff.additionLines.length - newContextStart + 1,
198 );
199 pushContextGap(oldContextStart, newContextStart, trailingLines);
200 }
201
202 return rows;
203}
204
205function getDiffReviewSelectionPoint(
206 line: DiffReviewLine,
207): { lineNumber: number; side: SelectionSide } | null {
208 if (line.change === "delete" && line.oldLineNumber !== null) {
209 return { lineNumber: line.oldLineNumber, side: "deletions" };
210 }
211 if (line.newLineNumber !== null) {
212 return { lineNumber: line.newLineNumber, side: "additions" };
213 }
214 if (line.oldLineNumber !== null) {
215 return { lineNumber: line.oldLineNumber, side: "deletions" };
216 }
217 return null;
218}
219
220export function restoreDiffReviewCommentRange(
221 fileDiff: FileDiffMetadata,
222 comment: ReviewCommentContext,
223): SelectedLineRange | null {
224 if (comment.selection) return comment.selection;
225
226 const includeExpandedContext = !fileDiff.isPartial;
227 const startLine = buildDiffReviewLines(fileDiff, includeExpandedContext, {
228 startIndex: comment.startIndex,
229 endIndex: comment.startIndex,
230 })[0];
231 const endLine =
232 comment.endIndex === comment.startIndex
233 ? startLine
234 : buildDiffReviewLines(fileDiff, includeExpandedContext, {
235 startIndex: comment.endIndex,
236 endIndex: comment.endIndex,
237 })[0];
238 if (!startLine || !endLine) return null;
239 const start = getDiffReviewSelectionPoint(startLine);
240 const end = getDiffReviewSelectionPoint(endLine);
241 if (!start || !end) return null;
242 return {
243 start: start.lineNumber,
244 side: start.side,
245 end: end.lineNumber,
246 endSide: end.side,
247 };
248}
249
250function findDiffReviewLineIndex(
251 fileDiff: FileDiffMetadata,
252 lineNumber: number,
253 side: SelectionSide | undefined,
254 includeExpandedContext = !fileDiff.isPartial,
255): number {
256 const findOnSide = (selectedSide: "left" | "right") => {
257 let rowIndex = 0;
258 let oldContextStart = 1;
259 let newContextStart = 1;
260 const findContextIndex = (oldStart: number, newStart: number, lineCount: number) => {
261 const count = Math.max(0, lineCount);
262 const selectedStart = selectedSide === "left" ? oldStart : newStart;
263 const offset = lineNumber - selectedStart;
264 return offset >= 0 && offset < count ? rowIndex + offset : -1;
265 };
266
267 for (const hunk of fileDiff.hunks) {
268 if (includeExpandedContext) {
269 const oldContextEnd = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0);
270 const newContextEnd = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0);
271 const contextLines = Math.min(
272 oldContextEnd - oldContextStart,
273 newContextEnd - newContextStart,
274 );
275 const contextIndex = findContextIndex(oldContextStart, newContextStart, contextLines);
276 if (contextIndex >= 0) return contextIndex;
277 rowIndex += Math.max(0, contextLines);
278 }
279
280 let oldLineNumber = hunk.deletionStart;
281 let newLineNumber = hunk.additionStart;
282 for (const segment of hunk.hunkContent) {
283 if (segment.type === "context") {
284 const contextIndex = findContextIndex(oldLineNumber, newLineNumber, segment.lines);
285 if (contextIndex >= 0) return contextIndex;
286 rowIndex += segment.lines;
287 oldLineNumber += segment.lines;
288 newLineNumber += segment.lines;
289 continue;
290 }
291
292 if (
293 selectedSide === "left" &&
294 lineNumber >= oldLineNumber &&
295 lineNumber < oldLineNumber + segment.deletions
296 ) {
297 return rowIndex + lineNumber - oldLineNumber;
298 }
299 rowIndex += segment.deletions;
300 oldLineNumber += segment.deletions;
301
302 if (
303 selectedSide === "right" &&
304 lineNumber >= newLineNumber &&
305 lineNumber < newLineNumber + segment.additions
306 ) {
307 return rowIndex + lineNumber - newLineNumber;
308 }
309 rowIndex += segment.additions;
310 newLineNumber += segment.additions;
311 }
312
313 oldContextStart = hunk.deletionStart + hunk.deletionCount;
314 newContextStart = hunk.additionStart + hunk.additionCount;
315 if (hunk.deletionCount === 0) oldContextStart += 1;
316 if (hunk.additionCount === 0) newContextStart += 1;
317 }
318
319 if (!includeExpandedContext) return -1;
320 const trailingLines = Math.min(
321 fileDiff.deletionLines.length - oldContextStart + 1,
322 fileDiff.additionLines.length - newContextStart + 1,
323 );
324 return findContextIndex(oldContextStart, newContextStart, trailingLines);
325 };
326
327 const selectedSide = side === "deletions" ? "left" : "right";
328 const preferredIndex = findOnSide(selectedSide);
329 return preferredIndex >= 0
330 ? preferredIndex
331 : findOnSide(selectedSide === "left" ? "right" : "left");
332}
333
334/** Resolve the host-facing coordinates of a line selected in the diff viewer. */
335export function resolveDiffReviewPosition(
336 fileDiff: FileDiffMetadata,
337 lineNumber: number,
338 side: SelectionSide | undefined,
339): PullRequestReviewPosition | null {
340 const lineIndex = findDiffReviewLineIndex(fileDiff, lineNumber, side);
341 if (lineIndex < 0) return null;
342 const line = buildDiffReviewLines(fileDiff, !fileDiff.isPartial, {
343 startIndex: lineIndex,
344 endIndex: lineIndex,
345 })[0];
346 if (line === undefined) return null;
347
348 switch (line.change) {
349 case "add":
350 return line.newLineNumber === null ? null : { kind: "added", newLine: line.newLineNumber };
351 case "delete":
352 return line.oldLineNumber === null ? null : { kind: "deleted", oldLine: line.oldLineNumber };
353 case "context":
354 return line.oldLineNumber === null || line.newLineNumber === null
355 ? null
356 : {
357 kind: "context",
358 oldLine: line.oldLineNumber,
359 newLine: line.newLineNumber,
360 side: side === "deletions" ? "left" : "right",
361 };
362 }
363}
364
365function getDiffRange(
366 lines: ReadonlyArray<DiffReviewLine>,
367 key: "oldLineNumber" | "newLineNumber",
368): { start: number; count: number } {
369 const numberedLines = lines.filter((line) => line[key] !== null);
370 return {
371 start: numberedLines[0]?.[key] ?? 0,
372 count: numberedLines.length,
373 };
374}
375
376function getDiffChangeMarker(change: DiffReviewLine["change"]): string {
377 if (change === "add") return "+";
378 if (change === "delete") return "-";
379 return " ";
380}
381
382function formatDiffReviewRangeLabel(lines: ReadonlyArray<DiffReviewLine>): string {
383 const firstLine = lines[0];
384 const lastLine = lines.at(-1);
385 if (!firstLine || !lastLine) return "line";
386 const firstNumber = firstLine.newLineNumber ?? firstLine.oldLineNumber;
387 const lastNumber = lastLine.newLineNumber ?? lastLine.oldLineNumber;
388 if (firstNumber === null || lastNumber === null) {
389 return lines.length === 1 ? "line" : `${lines.length} lines`;
390 }
391
392 const firstMarker = getDiffChangeMarker(firstLine.change).trim();
393 const marker =
394 firstMarker.length > 0 && lines.every((line) => line.change === firstLine.change)
395 ? firstMarker
396 : "";
397 return firstNumber === lastNumber
398 ? `${marker}${firstNumber}`
399 : `${marker}${firstNumber} to ${marker}${lastNumber}`;
400}
401
402export function buildDiffReviewComment(input: {
403 id: string;
404 sectionId: string;
405 sectionTitle: string;
406 filePath: string;
407 fileDiff: FileDiffMetadata;
408 range: SelectedLineRange;
409 text: string;
410}): ReviewCommentContext | null {
411 const includeExpandedContext = !input.fileDiff.isPartial;
412 const startIndex = findDiffReviewLineIndex(
413 input.fileDiff,
414 input.range.start,
415 input.range.side,
416 includeExpandedContext,
417 );
418 const endIndex = findDiffReviewLineIndex(
419 input.fileDiff,
420 input.range.end,
421 input.range.endSide ?? input.range.side,
422 includeExpandedContext,
423 );
424 if (startIndex < 0 || endIndex < 0) return null;
425
426 const normalizedStartIndex = Math.min(startIndex, endIndex);
427 const normalizedEndIndex = Math.max(startIndex, endIndex);
428 const selectedLines = buildDiffReviewLines(input.fileDiff, includeExpandedContext, {
429 startIndex: normalizedStartIndex,
430 endIndex: normalizedEndIndex,
431 });
432 const oldRange = getDiffRange(selectedLines, "oldLineNumber");
433 const newRange = getDiffRange(selectedLines, "newLineNumber");
434
435 return {
436 id: input.id,
437 sectionId: input.sectionId,
438 sectionTitle: input.sectionTitle,
439 filePath: input.filePath,
440 startIndex: normalizedStartIndex,
441 endIndex: normalizedEndIndex,
442 rangeLabel: formatDiffReviewRangeLabel(selectedLines),
443 text: input.text.trim(),
444 diff: [
445 `@@ -${oldRange.start},${oldRange.count} +${newRange.start},${newRange.count} @@`,
446 ...selectedLines.map((line) => `${getDiffChangeMarker(line.change)}${line.content}`),
447 ].join("\n"),
448 fenceLanguage: "diff",
449 selection: {
450 start: input.range.start,
451 side: input.range.side ?? "additions",
452 end: input.range.end,
453 endSide: input.range.endSide ?? input.range.side ?? "additions",
454 },
455 };
456}
457
458export function buildReviewCommentRenderablePatch(comment: ReviewCommentContext): string {
459 if ((comment.fenceLanguage ?? "diff") !== "diff") {
460 return "";
461 }
462 const diff = comment.diff.trim();
463 if (diff.length === 0) {
464 return "";
465 }
466 if (diff.startsWith("diff --git ")) {
467 return diff;
468 }
469
470 const normalizedPath = comment.filePath.replaceAll("\\", "/");
471 return [
472 `diff --git a/${normalizedPath} b/${normalizedPath}`,
473 `--- a/${normalizedPath}`,
474 `+++ b/${normalizedPath}`,
475 diff,
476 ].join("\n");
477}