| 1 | import type { PullRequestReviewGuideExcerpt, PullRequestReviewGuideStop } from "@t3tools/contracts"; |
| 2 | import 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. */ |
| 5 | const MAX_EXCERPT_LINES = 25; |
| 6 | |
| 7 | interface 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. */ |
| 13 | interface 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. */ |
| 19 | export 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 | |
| 25 | const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; |
| 26 | |
| 27 | const 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 | */ |
| 37 | export 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 | */ |
| 72 | function 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. */ |
| 88 | function 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 | |
| 118 | const 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 | */ |
| 125 | export 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 | } |