Skip to content
Source
Sign inSign up

AzureDevOpsPullRequestProvider.test.ts

349 lines12.5 KB

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" };
48​
49function side(prefix: string, lines: number, width: number): string {
50 const pad = "z".repeat(width);
51 return `${Array.from({ length: lines }, (_, line) => `${prefix} ${line} ${pad}`).join("\n")}\n`;
53​
54const readSlice = (input: {
55 readonly paths: ReadonlyArray<string>;
56 readonly lines: number;
57 readonly width: number;
58 readonly refused?: ReadonlyArray<string>;
59 readonly created?: ReadonlyArray<string>;
60 readonly cursor?: string;
61}) =>
62 Effect.gen(function* () {
63 const reads: string[] = [];
64 const refused = new Set(input.refused ?? []);
65 const created = new Set(input.created ?? []);
66 let inFlight = 0;
67 let peakInFlight = 0;
68​
69 const provider = yield* make.pipe(
70 Effect.provide(
71 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
72 getPullRequest: () => Effect.succeed(PULL_REQUEST),
73 listIterations: () => Effect.succeed([ITERATION]),
74 listIterationChanges: () =>
75 Effect.succeed({
76 changes: input.paths.map((path) =>
77 change(path, created.has(path) ? "new" : "change"),
78 ),
79 truncated: false,
80 }),
81 readItemContent: (item) =>
82 Effect.gen(function* () {
83 reads.push(item.path);
84 inFlight += 1;
85 peakInFlight = Math.max(peakInFlight, inFlight);
86 // Yield like a subprocess to exercise concurrency.
87 const answersAfter = input.paths.length - input.paths.indexOf(item.path);
88 for (let turn = 0; turn < answersAfter; turn += 1) yield* Effect.yieldNow;
89 inFlight -= 1;
90 if (refused.has(item.path)) {
91 return yield* new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({
92 command: "az",
93 cwd: "/w",
94 operation: "readItemContent",
95 cause: "refused",
96 });
97 }
98 const isOldSide = item.commit !== ITERATION.headCommit;
99 if (isOldSide && created.has(item.path)) return { contents: "", isBinary: false };
100 return {
101 contents: side(isOldSide ? "old" : "new", input.lines, input.width),
102 isBinary: false,
103 };
104 }),
105 }),
106 ),
107 );
108​
109 const slice = yield* provider.getDiff({
110 cwd: "/w",
111 repository: "acme/web",
112 host: "dev.azure.com",
113 number: 7,
114 ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
115 });
116 return { slice, reads, peakInFlight };
117 });
118​
119function patchedPaths(patch: string): ReadonlyArray<string> {
120 return [...patch.matchAll(/^diff --git a\/(?<path>\S+) b\//gmu)].map(
121 (match) => match.groups?.path ?? "",
122 );
124​
125describe("getChangeRequest", () => {
126 it.effect("preserves the newest head when file counting fails", () =>
127 Effect.gen(function* () {
128 const provider = yield* make.pipe(
129 Effect.provide(
130 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
131 getPullRequest: () => Effect.succeed(PULL_REQUEST),
132 listIterations: () =>
133 Effect.succeed([ITERATION, { ...ITERATION, id: 4, headCommit: "new-head" }]),
134 listIterationChanges: () =>
135 Effect.fail(
136 new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({
137 command: "az",
138 cwd: "/w",
139 operation: "listIterationChanges",
140 cause: "unavailable",
141 }),
142 ),
143 }),
144 ),
145 );
146​
147 const detail = yield* provider.getChangeRequest({
148 cwd: "/w",
149 repository: "acme/web",
150 host: "dev.azure.com",
151 number: 7,
152 });
153 expect(detail.headSha).toBe("new-head");
154 expect(detail.changedFiles).toBe(0);
155 }),
156 );
157​
158 it.effect.each(["missing", "failed"] as const)(
159 "leaves the head unavailable when the iteration read is %s",
160 (state) =>
161 Effect.gen(function* () {
162 const provider = yield* make.pipe(
163 Effect.provide(
164 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
165 getPullRequest: () => Effect.succeed(PULL_REQUEST),
166 listIterations: () =>
167 state === "missing"
168 ? Effect.succeed([])
169 : Effect.fail(
170 new AzureDevOpsPullRequestCli.AzureDevOpsPullRequestReadError({
171 command: "az",
172 cwd: "/w",
173 operation: "listIterations",
174 cause: "unavailable",
175 }),
176 ),
177 }),
178 ),
179 );
180 const detail = yield* provider.getChangeRequest({
181 cwd: "/w",
182 repository: "acme/web",
183 host: "dev.azure.com",
184 number: 7,
185 });
186 expect(detail.headSha).toBeUndefined();
187 expect(detail.changedFiles).toBe(0);
188 }),
189 );
190});
191​
192describe("getDiff reads", () => {
193 it.effect("holds every reader together to one request's worth of processes", () =>
194 Effect.gen(function* () {
195 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
196 let inFlight = 0;
197 let peakInFlight = 0;
198​
199 const provider = yield* make.pipe(
200 Effect.provide(
201 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
202 getPullRequest: () => Effect.succeed(PULL_REQUEST),
203 listIterations: () => Effect.succeed([ITERATION]),
204 listIterationChanges: () =>
205 Effect.succeed({ changes: paths.map((path) => change(path)), truncated: false }),
206 readItemContent: () =>
207 Effect.gen(function* () {
208 inFlight += 1;
209 peakInFlight = Math.max(peakInFlight, inFlight);
210 // Yield like a subprocess to exercise concurrency.
211 yield* Effect.yieldNow;
212 yield* Effect.yieldNow;
213 inFlight -= 1;
214 return { contents: side("new", 2, 4), isBinary: false };
215 }),
216 }),
217 ),
218 );
219​
220 const readDiff = (number: number) =>
221 provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number });
222​
223 yield* Effect.all([readDiff(7), readDiff(8)], { concurrency: 2 });
224​
225 expect(peakInFlight).toBeLessThanOrEqual(MAX_DIFF_SPAWNS);
226 }),
227 );
228​
229 it.effect("leaves a run of files it could not diff at all for the next slice", () =>
230 Effect.gen(function* () {
231 const paths = Array.from({ length: MAX_DIFF_SLICE_FILES + 20 }, (_, at) => `gen/a${at}.bin`);
232 const read = yield* readSlice({ paths, lines: 2, width: 4, refused: paths });
233​
234 expect(patchedPaths(read.slice.patch)).toHaveLength(MAX_DIFF_SLICE_FILES);
235 expect(byteLength(read.slice.patch)).toBeLessThan(MAX_DIFF_SLICE_BYTES);
236 expect(parseAzureDevOpsDiffCursor(read.slice.nextCursor)?.fileIndex).toBe(
237 MAX_DIFF_SLICE_FILES,
238 );
239 }),
240 );
241​
242 it.effect("keeps the patch in the order the change was listed, whoever answered first", () =>
243 Effect.gen(function* () {
244 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"];
245 const read = yield* readSlice({ paths, lines: 2, width: 4 });
246​
247 expect(patchedPaths(read.slice.patch)).toEqual(paths);
248 expect(read.slice.nextCursor).toBeNull();
249 }),
250 );
251​
252 it.effect("leaves a file the host refused listed without its hunks, in its place", () =>
253 Effect.gen(function* () {
254 const paths = ["a.ts", "b.ts", "c.ts"];
255 const read = yield* readSlice({ paths, lines: 2, width: 4, refused: ["b.ts"] });
256​
257 expect(patchedPaths(read.slice.patch)).toEqual(paths);
258 expect(read.slice.truncated).toBe(true);
259 expect(read.slice.patch).toContain("+++ b/b.ts\ndiff --git a/c.ts");
260 expect(read.slice.patch.match(/^@@ /gmu)).toHaveLength(2);
261 }),
262 );
263});
264​
265describe("what one diff slice spends", () => {
266 it.effect("stops on the byte ceiling without carrying what it read past it", () =>
267 Effect.gen(function* () {
268 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
269 const read = yield* readSlice({ paths, lines: 100, width: 900 });
270​
271 expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES);
272 expect(patchedPaths(read.slice.patch)).toEqual(["a.ts", "b.ts"]);
273 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:2`);
274 expect(new Set(read.reads)).toEqual(new Set(["a.ts", "b.ts", "c.ts", "d.ts"]));
275 }),
276 );
277​
278 it.effect("stops once the diff work one request may do is spent", () =>
279 Effect.gen(function* () {
280 const paths = Array.from({ length: 12 }, (_, file) => `file-${file}.ts`);
281 const read = yield* readSlice({ paths, lines: MAX_FILE_DIFF_EDITS / 4, width: 1 });
282​
283 expect(read.slice.patch.length).toBeLessThan(MAX_DIFF_SLICE_BYTES);
284 expect(patchedPaths(read.slice.patch)).toEqual(paths.slice(0, 5));
285 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:5`);
286 }),
287 );
288​
289 it.effect("carries a whole new file and stops the slice on what it weighed", () =>
290 Effect.gen(function* () {
291 const paths = ["new.ts", "b.ts", "c.ts", "d.ts"];
292 const read = yield* readSlice({ paths, lines: 8_000, width: 30, created: ["new.ts"] });
293​
294 expect(patchedPaths(read.slice.patch)).toEqual(["new.ts"]);
295 expect(read.slice.patch).toContain("--- /dev/null");
296 expect(read.slice.patch).toContain("@@ -0,0 +1,8000 @@");
297 expect(read.slice.patch.length).toBeGreaterThan(MAX_DIFF_SLICE_BYTES);
298 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:1`);
299 }),
300 );
301​
302 it.effect("carries on from where the last slice stopped", () =>
303 Effect.gen(function* () {
304 const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts", "f.ts"];
305 const read = yield* readSlice({
306 paths,
307 lines: 100,
308 width: 900,
309 cursor: `${ITERATION.id}:2`,
310 });
311​
312 expect(patchedPaths(read.slice.patch)).toEqual(["c.ts", "d.ts"]);
313 expect(read.slice.nextCursor).toBe(`${ITERATION.id}:4`);
314 }),
315 );
316 it.effect("keeps the pull request being read, not the one looked up first", () =>
317 Effect.gen(function* () {
318 const HOT = 7;
319 const readsOf = new Map<number, number>();
320​
321 const provider = yield* make.pipe(
322 Effect.provide(
323 Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({
324 getPullRequest: (input) =>
325 Effect.sync(() => {
326 readsOf.set(input.number, (readsOf.get(input.number) ?? 0) + 1);
327 return { ...PULL_REQUEST, number: input.number };
328 }),
329 listIterations: () => Effect.succeed([ITERATION]),
330 listIterationChanges: () =>
331 Effect.succeed({ changes: [change("a.ts")], truncated: false }),
332 readItemContent: () => Effect.succeed({ contents: side("new", 2, 4), isBinary: false }),
333 }),
334 ),
335 );
336​
337 const readDiff = (number: number) =>
338 provider.getDiff({ cwd: "/w", repository: "acme/web", host: "dev.azure.com", number });
339​
340 yield* readDiff(HOT);
341 for (let filled = 0; filled < LOCATION_CACHE_CAPACITY; filled += 1) {
342 yield* readDiff(HOT + 1 + filled);
343 yield* readDiff(HOT);
344 }
345​
346 expect(readsOf.get(HOT)).toBe(1);
347 }),
348 );
349});