Skip to content
Source
Sign inSign up

pullRequestReviewGuideNormalize.ts

179 lines6.4 KB

1import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts";
2import type { ReviewGuideGenerationResult } from "@t3tools/provider-core/server/textGeneration";
3import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
4​
5const MAX_EXCERPT_LINES = 25;
6​
7interface LineRange {
8 readonly start: number;
9 readonly end: number;
11​
12interface FileHunks {
13 readonly new: Array<LineRange>;
14 readonly old: Array<LineRange>;
16​
17export interface ReviewGuideChange {
18 readonly files: ReadonlyArray<string>;
19 readonly hunks: ReadonlyMap<string, FileHunks>;
21​
22const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
23​
24const hunkRange = (start: string, count: string | undefined): LineRange | null => {
25 const lines = count === undefined ? 1 : Number(count);
26 return lines === 0 ? null : { start: Number(start), end: Number(start) + lines - 1 };
27};
28​
29/**
30 * Git quotes a name holding a tab, quote or backslash, and ends an unquoted name holding a space
31 * with a tab, so the first literal tab is never part of what the file is called.
32 */
33function headerPath(line: string, prefix: string): string | null {
34 const rest = line.slice(4);
35 const tab = rest.indexOf("\t");
36 const token = tab === -1 ? rest : rest.slice(0, tab);
37 const path = token.startsWith('"') ? unquoteGitPatchPath(token) : token;
38 return path.startsWith(prefix) ? path.slice(prefix.length) : null;
40​
41export function parseReviewGuideChange(nameOnly: string, patch: string): ReviewGuideChange {
42 const hunks = new Map<string, FileHunks>();
43 let oldPath: string | null = null;
44 let current: FileHunks | null = null;
45 // Inside a file's hunks a `--- ` or `+++ ` line is a removed or added source line, not a header.
46 let inHunks = false;
47 for (const line of patch.split("\n")) {
48 if (line.startsWith("diff --git ")) {
49 oldPath = null;
50 current = null;
51 inHunks = false;
52 } else if (!inHunks && line.startsWith("--- ")) {
53 oldPath = headerPath(line, "a/");
54 } else if (!inHunks && line.startsWith("+++ ")) {
55 const path = headerPath(line, "b/") ?? oldPath;
56 if (path === null) continue;
57 current = hunks.get(path) ?? { new: [], old: [] };
58 hunks.set(path, current);
59 } else if (current !== null) {
60 const header = HUNK_HEADER.exec(line);
61 if (header === null) continue;
62 inHunks = true;
63 const removed = hunkRange(header[1]!, header[2]);
64 const added = hunkRange(header[3]!, header[4]);
65 if (removed !== null) current.old.push(removed);
66 if (added !== null) current.new.push(added);
67 }
68 }
69 return { files: nameOnly.split("\0").filter((path) => path !== ""), hunks };
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) };
86​
87function mergeExcerpts(
88 excerpts: ReadonlyArray<PullRequestReviewGuideExcerpt>,
89): Array<PullRequestReviewGuideExcerpt> {
90 let merged: Array<PullRequestReviewGuideExcerpt> = [];
91 for (const excerpt of excerpts) {
92 const touches = (other: PullRequestReviewGuideExcerpt) =>
93 other.path === excerpt.path &&
94 other.side === excerpt.side &&
95 other.start <= excerpt.end + 1 &&
96 excerpt.start <= other.end + 1;
97 // An excerpt can bridge several earlier ones; fold them all into the first.
98 const group = merged.filter(touches);
99 const first = group[0];
100 if (first === undefined) {
101 merged.push(excerpt);
102 continue;
103 }
104 const combined = {
105 ...first,
106 start: Math.min(excerpt.start, ...group.map((other) => other.start)),
107 end: Math.max(excerpt.end, ...group.map((other) => other.end)),
108 caption: group.find((other) => other.caption)?.caption ?? excerpt.caption,
109 };
110 merged = merged.flatMap((other) =>
111 other === first ? [combined] : touches(other) ? [] : [other],
112 );
113 }
114 return merged.map((excerpt) => ({
115 ...excerpt,
116 end: Math.min(excerpt.end, excerpt.start + MAX_EXCERPT_LINES - 1),
117 }));
119​
120const nonEmpty = (value: string) => value.trim() !== "";
121​
122export function normalizeReviewGuide(
123 guide: ReviewGuideGenerationResult,
124 change: ReviewGuideChange,
125): ReviewGuideGenerationResult {
126 const changed = new Set(change.files);
127 const inChange = (path: string) => changed.has(path);
128​
129 const shaped: Array<PullRequestReviewGuideStop> = guide.stops
130 .filter((stop) => nonEmpty(stop.title))
131 .map((stop) => {
132 const excerpts = mergeExcerpts(
133 stop.excerpts.flatMap((excerpt) => {
134 const clamped = inChange(excerpt.path) ? clampExcerpt(excerpt, change) : null;
135 return clamped === null ? [] : [clamped];
136 }),
137 );
138 return {
139 ...stop,
140 attention: stop.attention !== null && nonEmpty(stop.attention.note) ? stop.attention : null,
141 excerpts,
142 files: stop.files
143 .filter((file) => inChange(file.path))
144 .map(({ path, note }) => (note?.trim() ? { path, note } : { path })),
145 notes: stop.notes.filter(
146 (note) =>
147 nonEmpty(note.title) &&
148 excerpts.some(
149 (excerpt) =>
150 excerpt.path === note.path &&
151 excerpt.start <= note.line &&
152 note.line <= excerpt.end,
153 ),
154 ),
155 questions: stop.questions.filter(nonEmpty),
156 };
157 });
158​
159 const placed = new Set(shaped.flatMap((stop) => stop.excerpts.map((excerpt) => excerpt.path)));
160 const stops = shaped.map((stop) => {
161 const files = stop.files.filter((file) => !placed.has(file.path));
162 for (const file of files) placed.add(file.path);
163 return { ...stop, files };
164 });
165 const unplaced = change.files.filter((path) => !placed.has(path));
166 if (unplaced.length > 0) {
167 stops.push({
168 title: "Other files",
169 summary: "Changed files the tour does not otherwise cover.",
170 attention: null,
171 excerpts: [],
172 files: unplaced.map((path) => ({ path })),
173 notes: [],
174 questions: [],
175 });
176 }
177​
178 return { ...guide, stops };