1import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts";
2import type { ReviewGuideGenerationResult } from "@t3tools/provider-core/server/textGeneration";
3
4/** Longest excerpt kept. The reader wants the lines that carry the idea, not the whole hunk. */
5const MAX_EXCERPT_LINES = 25;
6
7interface LineRange {
8 readonly start: number;
9 readonly end: number;
10}
11
12/** The lines a change touched in one file, on each side of the diff. */
13interface FileHunks {
14 readonly new: Array<LineRange>;
15 readonly old: Array<LineRange>;
16}
17
18/** What a guide is held to: the changed paths, and the lines each touched. */
19export interface ReviewGuideChange {
20 /** Every changed path, in diff order, named as `git diff --name-only` names it. */
21 readonly files: ReadonlyArray<string>;
22 readonly hunks: ReadonlyMap<string, FileHunks>;
23}
24
25const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
26
27const hunkRange = (start: string, count: string | undefined): LineRange | null => {
28 const lines = count === undefined ? 1 : Number(count);
29 return lines === 0 ? null : { start: Number(start), end: Number(start) + lines - 1 };
30};
31
32/**
33 * Reads `git diff --name-only -z` and `git diff --unified=0` of the same range. Hunks are keyed
34 * by the new path, or the old one for a deleted file, so both sides of a renamed file sit under
35 * the name the model sees in `--stat`.
36 */
37export function parseReviewGuideChange(nameOnly: string, patch: string): ReviewGuideChange {
38 const hunks = new Map<string, FileHunks>();
39 let oldPath: string | null = null;
40 let current: FileHunks | null = null;
41 // Inside a file's hunks a `--- ` or `+++ ` line is a removed or added source line, not a header.
42 let inHunks = false;
43 for (const line of patch.split("\n")) {
44 if (line.startsWith("diff --git ")) {
45 oldPath = null;
46 current = null;
47 inHunks = false;
48 } else if (!inHunks && line.startsWith("--- ")) {
49 oldPath = line.startsWith("--- a/") ? line.slice(6) : null;
50 } else if (!inHunks && line.startsWith("+++ ")) {
51 const path = line.startsWith("+++ b/") ? line.slice(6) : oldPath;
52 if (path === null) continue;
53 current = hunks.get(path) ?? { new: [], old: [] };
54 hunks.set(path, current);
55 } else if (current !== null) {
56 const header = HUNK_HEADER.exec(line);
57 if (header === null) continue;
58 inHunks = true;
59 const removed = hunkRange(header[1]!, header[2]);
60 const added = hunkRange(header[3]!, header[4]);
61 if (removed !== null) current.old.push(removed);
62 if (added !== null) current.new.push(added);
63 }
64 }
65 return { files: nameOnly.split("\0").filter((path) => path !== ""), hunks };
66}
67
68/**
69 * Narrows an excerpt to the changed lines it covers: from the first changed line it overlaps to
70 * the last. Null when it covers none, which means the model pointed somewhere the change is not.
71 */
72function clampExcerpt(
73 excerpt: PullRequestReviewGuideExcerpt,
74 change: ReviewGuideChange,
75): PullRequestReviewGuideExcerpt | null {
76 const start = Math.min(excerpt.start, excerpt.end);
77 const end = Math.max(excerpt.start, excerpt.end);
78 const overlapping = (change.hunks.get(excerpt.path)?.[excerpt.side] ?? []).filter(
79 (hunk) => hunk.start <= end && start <= hunk.end,
80 );
81 const first = overlapping[0];
82 const last = overlapping.at(-1);
83 if (first === undefined || last === undefined) return null;
84 return { ...excerpt, start: Math.max(start, first.start), end: Math.min(end, last.end) };
85}
86
87/** Joins a stop's excerpts that overlap or touch in one file, then caps each one's length. */
88function mergeExcerpts(
89 excerpts: ReadonlyArray<PullRequestReviewGuideExcerpt>,
90): Array<PullRequestReviewGuideExcerpt> {
91 const merged: Array<PullRequestReviewGuideExcerpt> = [];
92 for (const excerpt of excerpts) {
93 const index = merged.findIndex(
94 (other) =>
95 other.path === excerpt.path &&
96 other.side === excerpt.side &&
97 other.start <= excerpt.end + 1 &&
98 excerpt.start <= other.end + 1,
99 );
100 const other = merged[index];
101 if (other === undefined) {
102 merged.push(excerpt);
103 continue;
104 }
105 merged[index] = {
106 ...other,
107 start: Math.min(other.start, excerpt.start),
108 end: Math.max(other.end, excerpt.end),
109 caption: other.caption || excerpt.caption,
110 };
111 }
112 return merged.map((excerpt) => ({
113 ...excerpt,
114 end: Math.min(excerpt.end, excerpt.start + MAX_EXCERPT_LINES - 1),
115 }));
116}
117
118const nonEmpty = (value: string) => value.trim() !== "";
119
120/**
121 * Holds a generated guide to the change it describes. A model's line numbers and ids are
122 * suggestions: excerpts are clamped to changed lines, notes must sit inside what is left, and any
123 * changed file the tour skipped lands in a closing skim stop.
124 */
125export function normalizeReviewGuide(
126 guide: ReviewGuideGenerationResult,
127 change: ReviewGuideChange,
128): ReviewGuideGenerationResult {
129 const changed = new Set(change.files);
130 const inChange = (path: string) => changed.has(path);
131
132 const shaped: Array<PullRequestReviewGuideStop> = guide.stops
133 .filter((stop) => nonEmpty(stop.title))
134 .map((stop) => {
135 const excerpts = mergeExcerpts(
136 stop.excerpts.flatMap((excerpt) => {
137 const clamped = inChange(excerpt.path) ? clampExcerpt(excerpt, change) : null;
138 return clamped === null ? [] : [clamped];
139 }),
140 );
141 return {
142 ...stop,
143 attention: stop.attention !== null && nonEmpty(stop.attention.note) ? stop.attention : null,
144 excerpts,
145 files: stop.files
146 .filter((file) => inChange(file.path))
147 .map(({ path, note }) => (note?.trim() ? { path, note } : { path })),
148 notes: stop.notes.filter(
149 (note) =>
150 nonEmpty(note.title) &&
151 excerpts.some(
152 (excerpt) =>
153 excerpt.path === note.path &&
154 excerpt.start <= note.line &&
155 note.line <= excerpt.end,
156 ),
157 ),
158 questions: stop.questions.filter(nonEmpty),
159 };
160 });
161
162 // A file can be excerpted in several stops, its shape in one and its implementation in another.
163 // A files entry is for a file the tour never shows, so it is listed once and only then.
164 const placed = new Set(shaped.flatMap((stop) => stop.excerpts.map((excerpt) => excerpt.path)));
165 const stops = shaped.map((stop) => {
166 const files = stop.files.filter((file) => !placed.has(file.path));
167 for (const file of files) placed.add(file.path);
168 return { ...stop, files };
169 });
170 const unplaced = change.files.filter((path) => !placed.has(path));
171 if (unplaced.length > 0) {
172 stops.push({
173 title: "Other files",
174 summary: "Changed files the tour does not otherwise cover.",
175 attention: null,
176 excerpts: [],
177 files: unplaced.map((path) => ({ path })),
178 notes: [],
179 questions: [],
180 });
181 }
182
183 return { ...guide, stops };
184}