1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
6import {
7 LOCATION_CACHE_CAPACITY,
8 make,
9 MAX_DIFF_SPAWNS,
10} from "./AzureDevOpsPullRequestProvider.ts";
11import {
12 byteLength,
13 MAX_DIFF_SLICE_BYTES,
14 MAX_DIFF_SLICE_FILES,
15 MAX_FILE_DIFF_EDITS,
16 parseAzureDevOpsDiffCursor,
17} from "./azureDevOpsDiff.ts";
18import type { AzureDevOpsChangeEntry } from "./azureDevOpsPullRequestJson.ts";
19
20const ITERATION = { id: 3, headCommit: "head", mergeBaseCommit: "base" };
21
22const PULL_REQUEST = {
23 number: 7,
24 title: "Pull request 7",
25 url: "https://dev.azure.com/acme/web/_git/web/pullrequest/7",
26 author: null,
27 headBranch: "feat/page",
28 baseBranch: "main",
29 state: "open" as const,
30 isDraft: false,
31 mergeability: "mergeable" as const,
32 createdAt: "2026-07-01T00:00:00Z",
33 updatedAt: "2026-07-02T00:00:00Z",
34 closedAt: null,
35 body: "",
36 reviewRequestLogins: [],
37 reviewers: [],
38 location: { project: "acme", repository: "web" },
39 autoMergeEnabled: false,
40};
41
42function change(
43 path: string,
44 changeKind: AzureDevOpsChangeEntry["changeKind"] = "change",
45): AzureDevOpsChangeEntry {
46 return { path, oldPath: path, changeKind, objectId: "8f80", originalObjectId: "0ca4" };
47}
48
49/**
50 * A file whose two sides share no line, so its patch is `lines` removals and `lines` additions of
51 * `width` characters each: the diff work and the patch bytes one file costs are both dialled from
52 * here, and they are what a slice is bounded by. Each line carries its own number and a prefix
53 * as well, so `width` is a floor on how long a line is rather than its byte count.
54 */
55function side(prefix: string, lines: number, width: number): string {
56 const pad = "z".repeat(width);
57 return `${Array.from({ length: lines }, (_, line) => `${prefix} ${line} ${pad}`).join("\n")}\n`;
58}
59
60const readSlice = (input: {
61 readonly paths: ReadonlyArray<string>;
62 readonly lines: number;
63 readonly width: number;
64 /** Paths the host refuses, which is one file's problem rather than the read's. */
65 readonly refused?: ReadonlyArray<string>;
66 /** Paths the change creates, so the host has nothing to hand back for their old side. */
67 readonly created?: ReadonlyArray<string>;
68 readonly cursor?: string;
69}) =>
70 Effect.gen(function* () {
71 const reads: string[] = [];
72 const refused = new Set(input.refused ?? []);
73 const created = new Set(input.created ?? []);
74 let inFlight = 0;
75 let peakInFlight = 0;
76
77 const provider = yield* make.pipe(
78 Effect.provide(
79 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
80 getPullRequest: () => Effect.succeed(PULL_REQUEST),
81 listIterations: () => Effect.succeed([ITERATION]),
82 listIterationChanges: () =>
83 Effect.succeed({
84 changes: input.paths.map((path) =>
85 change(path, created.has(path) ? "new" : "change"),
86 ),
87 truncated: false,
88 }),
89 readItemContent: (item) =>
90 Effect.gen(function* () {
91 reads.push(item.path);
92 inFlight += 1;
93 peakInFlight = Math.max(peakInFlight, inFlight);
94 // Every read suspends before it answers, as a subprocess would, so what runs at
95 // once is the scheduler's answer rather than an artefact of resolving inline. The
96 // later a file is listed the sooner it answers, to leave the assembled patch
97 // nothing but the change list to take its order from.
98 const answersAfter = input.paths.length - input.paths.indexOf(item.path);
99 for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow;
100 inFlight -= 1;
101 if (refused.has(item.path)) {
102 return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({
103 command: "az",
104 cwd: "/w",
105 operation: "readItemContent",
106 cause: "refused",
107 });
108 }
109 const isOldSide = item.commit !== ITERATION.headCommit;
110 if (isOldSide && created.has(item.path)) return { contents: "", isBinary: false };
111 return {
112 contents: side(isOldSide ? "old" : "new", input.lines, input.width),
113 isBinary: false,
114 };
115 }),
116 }),
117 ),
118 );
119
120 const slice = yield* provider.getDiff({
121 cwd: "/w",
122 repository: "acme/web",
123 host: "dev.azure.com",
124 number: 7,
125 ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
126 });
127 return { slice, reads, peakInFlight };
128 });
129
130/** Which files the patch carries a section for, in the order it carries them. */
131function patchedPaths(patch: string): ReadonlyArray<string> {
132 return [...patch.matchAll(/^diff --git a\/(?<path>\S+) b\//gmu)].map(
133 (match) => match.groups?.path ?? "",
134 );
135}
136
137describe("getChangeRequestSummary", () => {
138 it.effect("costs the one pull request read, not the iterations changedFiles needs", () =>
139 Effect.gen(function* () {
140 let pullRequestReads = 0;
141
142 const provider = yield* make.pipe(
143 Effect.provide(
144 // listIterations and listIterationChanges are left unimplemented here, so a summary
145 // that reached for either would die with UnimplementedError instead of this passing.
146 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
147 getPullRequest: () => {
148 pullRequestReads += 1;
149 return Effect.succeed(PULL_REQUEST);
150 },
151 }),
152 ),
153 );
154
155 const readSummary = provider.getChangeRequestSummary;
156 if (readSummary === undefined) return yield* Effect.die("summary read was not implemented");
157 const summary = yield* readSummary({
158 cwd: "/w",
159 repository: "acme/web",
160 host: "dev.azure.com",
161 number: 7,
162 });
163
164 expect(pullRequestReads).toBe(1);
165 expect(summary.title).toBe(PULL_REQUEST.title);
166 expect(summary.changedFiles).toBeUndefined();
167 }),
168 );
169});
170
171describe("getChangeRequest", () => {
172 it.effect("still reports the file count the detail panel needs", () =>
173 Effect.gen(function* () {
174 const provider = yield* make.pipe(
175 Effect.provide(
176 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
177 getPullRequest: () => Effect.succeed(PULL_REQUEST),
178 listIterations: () => Effect.succeed([ITERATION]),
179 listIterationChanges: () =>
180 Effect.succeed({ changes: [change("a.ts"), change("b.ts")], truncated: false }),
181 }),
182 ),
183 );
184
185 const detail = yield* provider.getChangeRequest({
186 cwd: "/w",
187 repository: "acme/web",
188 host: "dev.azure.com",
189 number: 7,
190 });
191
192 expect(detail.changedFiles).toBe(2);
193 }),
194 );
195});
196
197describe("getDiff reads", () => {
198 it.effect("holds every reader together to one request's worth of processes", () =>
199 Effect.gen(function* () {
200 // The fan-out inside a read bounds one Code tab. Two people opening two Azure reviews at
201 // once are two reads, so without a ceiling above them both they are twice a request's
202 // processes, each paying a Python interpreter's start-up on the same machine.
203 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
204 let inFlight = 0;
205 let peakInFlight = 0;
206
207 const provider = yield* make.pipe(
208 Effect.provide(
209 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
210 getPullRequest: () => Effect.succeed(PULL_REQUEST),
211 listIterations: () => Effect.succeed([ITERATION]),
212 listIterationChanges: () =>
213 Effect.succeed({ changes: paths.map((path) => change(path)), truncated: false }),
214 readItemContent: () =>
215 Effect.gen(function* () {
216 inFlight += 1;
217 peakInFlight = Math.max(peakInFlight, inFlight);
218 // Suspends before answering, as a subprocess would, so what is out at once is the
219 // scheduler's answer rather than an artefact of resolving inline.
220 yield* Effect.yieldNow;
221 yield* Effect.yieldNow;
222 inFlight -= 1;
223 return { contents: side("new", 2, 4), isBinary: false };
224 }),
225 }),
226 ),
227 );
228
229 const readDiff = (number: number) =>
230 provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number });
231
232 yield* Effect.all([readDiff(7), readDiff(8)], { concurrency: 2 });
233
234 expect(peakInFlight).toBeLessThanOrEqual(MAX_DIFF_SPAWNS);
235 }),
236 );
237
238 it.effect("leaves a run of files it could not diff at all for the next slice", () =>
239 Effect.gen(function* () {
240 // A binary, oversize, purely renamed or unreadable entry is a header apiece, a couple of
241 // hundred bytes with no edits in it, so a change made of them spends neither budget: the
242 // byte one would take well over a thousand of them, and the edit one never fills at all.
243 // A listing holds up to ten thousand entries and each still costs its two reads, which is
244 // what a file count is here to bound.
245 const paths = Array.from({ length: MAX_DIFF_SLICE_FILES + 20 }, (_, at) => `gen/a${at}.bin`);
246 const read = yield* readSlice({ paths, lines: 2, width: 4, refused: paths });
247
248 expect(patchedPaths(read.slice.patch)).toHaveLength(MAX_DIFF_SLICE_FILES);
249 // Well inside the byte budget, so the file count is what stopped it rather than either of
250 // the budgets that were already there.
251 expect(byteLength(read.slice.patch)).toBeLessThan(MAX_DIFF_SLICE_BYTES);
252 expect(parseAzureDevOpsDiffCursor(read.slice.nextCursor)?.fileIndex).toBe(
253 MAX_DIFF_SLICE_FILES,
254 );
255 }),
256 );
257
258 it.effect("asks for both sides of several files at once rather than one side at a time", () =>
259 Effect.gen(function* () {
260 // Each file is two `az` invocations, each paying a Python interpreter's start-up, so a
261 // slice read one side after another is most of what the Code tab waits for.
262 const read = yield* readSlice({
263 paths: ["a.ts", "b.ts", "c.ts", "d.ts"],
264 lines: 2,
265 width: 4,
266 });
267
268 expect(read.peakInFlight).toBeGreaterThan(2);
269 }),
270 );
271
272 it.effect("holds the number of files it reads at once down", () =>
273 Effect.gen(function* () {
274 // The host throttles, and `az` is a process on the same machine the reader runs agents on,
275 // so a long change is read in batches rather than all at once.
276 const paths = Array.from({ length: 24 }, (_, file) => `file-${file}.ts`);
277 const read = yield* readSlice({ paths, lines: 2, width: 4 });
278
279 expect(read.reads).toHaveLength(paths.length * 2);
280 expect(read.peakInFlight).toBeLessThanOrEqual(8);
281 }),
282 );
283
284 it.effect("keeps the patch in the order the change was listed, whoever answered first", () =>
285 Effect.gen(function* () {
286 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"];
287 const read = yield* readSlice({ paths, lines: 2, width: 4 });
288
289 expect(patchedPaths(read.slice.patch)).toEqual(paths);
290 expect(read.slice.nextCursor).toBeNull();
291 }),
292 );
293
294 it.effect("leaves a file the host refused listed without its hunks, in its place", () =>
295 Effect.gen(function* () {
296 const paths = ["a.ts", "b.ts", "c.ts"];
297 const read = yield* readSlice({ paths, lines: 2, width: 4, refused: ["b.ts"] });
298
299 expect(patchedPaths(read.slice.patch)).toEqual(paths);
300 expect(read.slice.truncated).toBe(true);
301 // Its section ends at its header, and the files around it still carry their hunks.
302 expect(read.slice.patch).toContain("+++ b/b.ts\ndiff --git a/c.ts");
303 expect(read.slice.patch.match(/^@@ /gmu)).toHaveLength(2);
304 }),
305 );
306});
307
308describe("what one diff slice spends", () => {
309 it.effect("stops on the byte ceiling without carrying what it read past it", () =>
310 Effect.gen(function* () {
311 // Two of these fill the slice, and the batch they were read in reached two files further.
312 // Those two belong to the next slice: carrying them would put the request past a ceiling
313 // that is there to bound what one answer weighs.
314 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
315 const read = yield* readSlice({ paths, lines: 100, width: 900 });
316
317 expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES);
318 expect(patchedPaths(read.slice.patch)).toEqual(["a.ts", "b.ts"]);
319 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:2`);
320 expect(new Set(read.reads)).toEqual(new Set(["a.ts", "b.ts", "c.ts", "d.ts"]));
321 }),
322 );
323
324 it.effect("narrows what it reads at once as the slice fills", () =>
325 Effect.gen(function* () {
326 // Four files fit inside the budget and the fifth is past half of what is left of it, so
327 // reading four more would throw most of them away and read them again next slice. Every
328 // file is two `az` invocations, so the batch is judged against what the files before it
329 // weighed rather than left at its full width to the last file.
330 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts", "g.ts", "h.ts"];
331 const read = yield* readSlice({ paths, lines: 50, width: 450 });
332
333 expect(read.slice.nextCursor).not.toBeNull();
334 expect(new Set(read.reads)).toEqual(new Set(patchedPaths(read.slice.patch)));
335 }),
336 );
337
338 it.effect("stops once the diff work one request may do is spent", () =>
339 Effect.gen(function* () {
340 // Short lines are cheap on the wire and dear to diff, so the byte ceiling alone would let
341 // one request hold the thread through a dozen of them. Each of these is half of what one
342 // file is allowed, and the slice ends while there is still room for another.
343 const paths = Array.from({ length: 12 }, (_, file) => `file-${file}.ts`);
344 const read = yield* readSlice({ paths, lines: MAX_FILE_DIFF_EDITS / 4, width: 1 });
345
346 expect(read.slice.patch.length).toBeLessThan(MAX_DIFF_SLICE_BYTES);
347 expect(patchedPaths(read.slice.patch)).toEqual(paths.slice(0, 5));
348 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:5`);
349 }),
350 );
351
352 it.effect("carries a whole new file and stops the slice on what it weighed", () =>
353 Effect.gen(function* () {
354 // A creation has no edit distance to search out, so no edit bound applies to it and its
355 // section is the whole file. What keeps a run of them from filling one answer is the bytes
356 // they weighed, which the slice has to be charged for.
357 const paths = ["new.ts", "b.ts", "c.ts", "d.ts"];
358 const read = yield* readSlice({ paths, lines: 8_000, width: 30, created: ["new.ts"] });
359
360 expect(patchedPaths(read.slice.patch)).toEqual(["new.ts"]);
361 expect(read.slice.patch).toContain("--- /dev/null");
362 expect(read.slice.patch).toContain("@@ -0,0 +1,8000 @@");
363 expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES);
364 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:1`);
365 }),
366 );
367
368 it.effect("carries on from where the last slice stopped", () =>
369 Effect.gen(function* () {
370 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
371 const read = yield* readSlice({
372 paths,
373 lines: 100,
374 width: 900,
375 cursor: `${ITERATION.id}:2`,
376 });
377
378 expect(patchedPaths(read.slice.patch)).toEqual(["c.ts", "d.ts"]);
379 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:4`);
380 }),
381 );
382 it.effect("keeps the pull request being read, not the one looked up first", () =>
383 Effect.gen(function* () {
384 // Where a pull request lives is read from the pull request itself, so an evicted entry
385 // costs a whole pull request read before any file can be asked for. Ordered by insertion
386 // alone a hit does not renew its entry, so the review being worked through is the first
387 // thing dropped once a listing has walked a cache's worth of cold pull requests.
388 const HOT = 7;
389 const readsOf = new Map<number, number>();
390
391 const provider = yield* make.pipe(
392 Effect.provide(
393 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
394 getPullRequest: (input) =>
395 Effect.sync(() => {
396 readsOf.set(input.number, (readsOf.get(input.number) ?? 0) + 1);
397 return { ...PULL_REQUEST, number: input.number };
398 }),
399 listIterations: () => Effect.succeed([ITERATION]),
400 listIterationChanges: () =>
401 Effect.succeed({ changes: [change("a.ts")], truncated: false }),
402 readItemContent: () => Effect.succeed({ contents: side("new", 2, 4), isBinary: false }),
403 }),
404 ),
405 );
406
407 const readDiff = (number: number) =>
408 provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number });
409
410 yield* readDiff(HOT);
411 // A cache's worth of cold pull requests, with the open one read in between each of them.
412 for (let filled = 0; filled < LOCATION_CACHE_CAPACITY; filled += 1) {
413 yield* readDiff(HOT + 1 + filled);
414 yield* readDiff(HOT);
415 }
416
417 expect(readsOf.get(HOT)).toBe(1);
418 }),
419 );
420});