Skip to content
Source
Sign inSign up

reviewCommentContext.ts

530 lines18.2 KB

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