Skip to content
Source
Sign inSign up

AzureDevOpsPullRequestCli.test.ts

909 lines29.3 KB

1import { afterEach, assert, expect, it, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { ChildProcessSpawner } from "effect/process";
5​
6import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
7import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
8import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";
9​
10const mockedExecute = vi.fn<AzureDevOpsCli.AzureDevOpsCli["Service"]["execute"]>();
11​
12const layer = it.layer(
13 AzureDevOpsPullRequestCli.layer.pipe(
14 Layer.provide(
15 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({
16 execute: mockedExecute,
17 }),
18 ),
19 ),
20);
21​
22function output(stdout: string) {
23 return {
24 exitCode: ChildProcessSpawner.ExitCode(0),
25 stdout,
26 stderr: "",
27 stdoutTruncated: false,
28 stderrTruncated: false,
29 };
31​
32const VCS_DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
33​
34function outputWithin(maxOutputBytes: number | undefined, response: string) {
35 const ceiling = maxOutputBytes ?? VCS_DEFAULT_MAX_OUTPUT_BYTES;
36 return ceiling >= Buffer.byteLength(response)
37 ? output(response)
38 : { ...output(response.slice(0, ceiling)), stdoutTruncated: true };
40​
41const json = (value: Record<string, unknown>) => JSON.stringify(value);
42​
43const pullRequestRow = {
44 pullRequestId: 42,
45 title: "Add the page",
46 status: "active",
47 sourceRefName: "refs/heads/feat/page",
48 targetRefName: "refs/heads/main",
49 creationDate: "2026-07-01T00:00:00Z",
50 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
51 repository: { name: "web", project: { name: "platform" } },
52};
53​
54const oneIteration = {
55 value: [
56 {
57 id: 1,
58 sourceRefCommit: { commitId: "a".repeat(40) },
59 commonRefCommit: { commitId: "b".repeat(40) },
60 },
61 ],
62};
63​
64function pullRequestRows(
65 count: number,
66 firstNumber: number,
67): ReadonlyArray<Record<string, unknown>> {
68 return Array.from({ length: count }, (_, index) => ({
69 pullRequestId: firstNumber + index,
70 title: `Pull request ${firstNumber + index}`,
71 status: "active",
72 sourceRefName: "refs/heads/feat/page",
73 targetRefName: "refs/heads/main",
74 creationDate: "2026-07-01T00:00:00Z",
75 repository: { name: "web", project: { name: "platform" } },
76 url: `https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/${firstNumber + index}`,
77 }));
79​
80function pullRequests(count: number, firstNumber: number): string {
81 return JSON.stringify(pullRequestRows(count, firstNumber));
83​
84function argsOfCall(index: number): ReadonlyArray<string> {
85 const call = mockedExecute.mock.calls[index];
86 assert.isDefined(call);
87 return call[0].args;
89​
90function changeEntries(count: number): ReadonlyArray<Record<string, unknown>> {
91 const commit = "c".repeat(40);
92 return Array.from({ length: count }, (_, index) => {
93 const path = `/apps/server/src/generated/module-${index}/persisted-projection-${index}.ts`;
94 return {
95 changeType: "edit",
96 item: {
97 path,
98 objectId: "a".repeat(40),
99 originalObjectId: "b".repeat(40),
100 commitId: commit,
101 gitObjectType: "blob",
102 url: `https://dev.azure.com/acme/platform/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/items${path}?versionType=Commit&version=${commit}`,
103 },
104 };
105 });
107​
108afterEach(() => {
109 mockedExecute.mockReset();
110});
111​
112layer("AzureDevOpsPullRequestCli.layer", (it) => {
113 it.effect("reports the latest pushed source commit even while the trial merge is stale", () =>
114 Effect.gen(function* () {
115 const previousHead = "a".repeat(40);
116 const latestHead = "c".repeat(40);
117 mockedExecute
118 .mockReturnValueOnce(
119 Effect.succeed(
120 output(json({ ...pullRequestRow, lastMergeSourceCommit: { commitId: previousHead } })),
121 ),
122 )
123 .mockReturnValueOnce(
124 Effect.succeed(
125 output(
126 json({
127 value: [
128 {
129 id: 2,
130 sourceRefCommit: { commitId: latestHead },
131 commonRefCommit: { commitId: "b".repeat(40) },
132 },
133 ...oneIteration.value,
134 ],
135 }),
136 ),
137 ),
138 )
139 .mockReturnValueOnce(Effect.succeed(output(json({ changeEntries: [] }))));
140 const provider = yield* AzureDevOpsPullRequestProvider.make;
141 const detail = yield* provider.getChangeRequest({
142 cwd: "/w",
143 repository: "platform/web",
144 host: "dev.azure.com",
145 number: 42,
146 });
147​
148 expect(detail.headSha).toBe(latestHead);
149 expect(argsOfCall(2)).toContain("iterationId=2");
150 expect(mockedExecute).toHaveBeenCalledTimes(3);
151 }),
152 );
153​
154 it.effect("reads an Azure pull request page larger than the VCS default output limit", () =>
155 Effect.gen(function* () {
156 const rows = pullRequestRows(100, 1).map((row) => ({
157 ...row,
158 description: "x".repeat(10_000),
159 }));
160 const response = JSON.stringify(rows);
161 expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000);
162​
163 mockedExecute.mockImplementationOnce((input) =>
164 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
165 );
166 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
167​
168 const batch = yield* cli.listPullRequests({
169 cwd: "/w",
170 repository: "web",
171 state: "merged",
172 involvement: "all",
173 viewer: "bilal@acme.dev",
174 limit: 99,
175 });
176​
177 assert.strictEqual(batch.items.length, 99);
178 assert.isTrue(batch.truncated);
179 }),
180 );
181​
182 it.effect("steps over what it has already handed over, which is all Azure can be told", () =>
183 Effect.gen(function* () {
184 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
185 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
186​
187 yield* cli.listPullRequests({
188 cwd: "/w",
189 repository: "web",
190 state: "open",
191 involvement: "all",
192 viewer: "bilal@acme.dev",
193 limit: 10,
194 cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 20 },
195 });
196​
197 const args = argsOfCall(0);
198 expect(args).toContain("--skip");
199 assert.strictEqual(args[args.indexOf("--skip") + 1], "20");
200 expect(args).not.toContain("2026-07-02T00:00:00Z");
201 }),
202 );
203​
204 it.effect("asks Azure for the author's pull requests by creator", () =>
205 Effect.gen(function* () {
206 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 1))));
207 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
208​
209 yield* cli.listPullRequests({
210 cwd: "/w",
211 repository: "web",
212 state: "open",
213 involvement: "reviewing",
214 viewer: "bilal@acme.dev",
215 limit: 10,
216 author: "ana@acme.dev",
217 });
218​
219 const args = argsOfCall(0);
220 assert.strictEqual(args[args.indexOf("--creator") + 1], "ana@acme.dev");
221 assert.strictEqual(args[args.indexOf("--reviewer") + 1], "bilal@acme.dev");
222 }),
223 );
224​
225 it.effect("advances by malformed raw rows and keeps reading until the page is full", () =>
226 Effect.gen(function* () {
227 mockedExecute
228 .mockReturnValueOnce(
229 Effect.succeed(
230 output(
231 JSON.stringify([
232 { pullRequestId: "malformed" },
233 pullRequestRows(1, 1)[0],
234 { pullRequestId: "also malformed" },
235 ]),
236 ),
237 ),
238 )
239 .mockReturnValueOnce(Effect.succeed(output(pullRequests(2, 2))));
240 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
241​
242 const batch = yield* cli.listPullRequests({
243 cwd: "/w",
244 repository: "web",
245 state: "open",
246 involvement: "all",
247 viewer: "bilal@acme.dev",
248 limit: 2,
249 });
250​
251 expect(batch.items.map((item) => item.number)).toEqual([1, 2]);
252 assert.isTrue(batch.truncated);
253 assert.strictEqual(batch.cursorAdvance, 4);
254 const secondArgs = argsOfCall(1);
255 assert.strictEqual(secondArgs[secondArgs.indexOf("--skip") + 1], "3");
256 assert.strictEqual(secondArgs[secondArgs.indexOf("--top") + 1], "2");
257 }),
258 );
259​
260 it.effect("reads the signed-in account, which az reports as a bare value", () =>
261 Effect.gen(function* () {
262 mockedExecute.mockReturnValueOnce(
263 Effect.succeed(output(JSON.stringify({ name: "bilal@acme.dev", type: "user" }))),
264 );
265 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
266​
267 const viewer = yield* cli.getViewer({ cwd: "/w" });
268​
269 assert.strictEqual(viewer, "bilal@acme.dev");
270 expect(argsOfCall(0)).toEqual([
271 "account",
272 "show",
273 "--query",
274 "user",
275 "--only-show-errors",
276 "--output",
277 "json",
278 ]);
279 }),
280 );
281​
282 it.effect("names a token sign-in by the viewer's own pull request", () =>
283 Effect.gen(function* () {
284 const notLoggedIn = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({
285 operation: "execute",
286 command: "az",
287 cwd: "/w",
288 argumentCount: 7,
289 cause: new Error("Please run 'az login' to setup account."),
290 });
291 mockedExecute
292 .mockReturnValueOnce(Effect.fail(notLoggedIn))
293 .mockReturnValueOnce(
294 Effect.succeed(
295 output(
296 JSON.stringify([{ ...pullRequestRow, createdBy: { uniqueName: "bilal@acme.dev" } }]),
297 ),
298 ),
299 )
300 .mockReturnValueOnce(Effect.succeed(output("")))
301 .mockReturnValueOnce(Effect.succeed(output("[]")));
302 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
303​
304 assert.strictEqual(yield* cli.getViewer({ cwd: "/w" }), "bilal@acme.dev");
305 expect(argsOfCall(1)).toEqual(expect.arrayContaining(["--creator", "me", "--top", "1"]));
306 const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" }));
307 assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError");
308 }),
309 );
310​
311 it.effect("fails when no sign-in can read pull requests", () =>
312 Effect.gen(function* () {
313 const signedOut = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({
314 operation: "execute",
315 command: "az",
316 cwd: "/w",
317 argumentCount: 7,
318 cause: new Error("signed out"),
319 });
320 mockedExecute.mockReturnValue(Effect.fail(signedOut));
321 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
322​
323 const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" }));
324​
325 assert.strictEqual(error._tag, "AzureDevOpsCliAuthenticationError");
326 }),
327 );
328​
329 it.effect("sends a description that starts with a dash as one value, not as a flag", () =>
330 Effect.gen(function* () {
331 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
332 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
333​
334 yield* cli.updatePullRequest({
335 cwd: "/w",
336 number: 42,
337 body: "- rewrote the page\n- kept the rest",
338 });
339​
340 expect(argsOfCall(0)).toContain("--description=- rewrote the page\n- kept the rest");
341 }),
342 );
343​
344 it.effect("names the head's blob as what a cleared file was cleared at", () =>
345 Effect.gen(function* () {
346 mockedExecute
347 .mockReturnValueOnce(
348 Effect.succeed(
349 output(
350 JSON.stringify({
351 pullRequestId: 42,
352 title: "Add the page",
353 status: "active",
354 sourceRefName: "refs/heads/feat/page",
355 targetRefName: "refs/heads/main",
356 creationDate: "2026-07-01T00:00:00Z",
357 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
358 repository: { name: "web", project: { name: "platform" } },
359 }),
360 ),
361 ),
362 )
363 .mockReturnValueOnce(
364 Effect.succeed(
365 output(
366 JSON.stringify({
367 value: [
368 {
369 id: 1,
370 sourceRefCommit: { commitId: "a".repeat(40) },
371 commonRefCommit: { commitId: "b".repeat(40) },
372 },
373 {
374 id: 2,
375 sourceRefCommit: { commitId: "c".repeat(40) },
376 commonRefCommit: { commitId: "b".repeat(40) },
377 },
378 ],
379 }),
380 ),
381 ),
382 )
383 .mockReturnValueOnce(
384 Effect.succeed(
385 output(
386 JSON.stringify({
387 changeEntries: [
388 { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } },
389 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
390 ],
391 }),
392 ),
393 ),
394 );
395 const provider = yield* AzureDevOpsPullRequestProvider.make;
396​
397 assert.strictEqual(provider.capabilities.viewedFiles, "environment");
398 assert.isDefined(provider.getFileRevisions);
399 const answer = yield* provider.getFileRevisions({
400 cwd: "/w",
401 repository: "web",
402 host: "dev.azure.com",
403 number: 42,
404 paths: ["README.md"],
405 });
406​
407 expect(argsOfCall(2)).toContain("iterationId=2");
408 expect([...answer.revisions]).toEqual([["README.md", "8f80"]]);
409 }),
410 );
411​
412 it.effect(
413 "answers for a marked file the pull request no longer changes as the empty version",
414 () =>
415 Effect.gen(function* () {
416 mockedExecute
417 .mockReturnValueOnce(
418 Effect.succeed(
419 output(
420 json({
421 pullRequestId: 42,
422 title: "Add the page",
423 status: "active",
424 sourceRefName: "refs/heads/feat/page",
425 targetRefName: "refs/heads/main",
426 creationDate: "2026-07-01T00:00:00Z",
427 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
428 repository: { name: "web", project: { name: "platform" } },
429 }),
430 ),
431 ),
432 )
433 .mockReturnValueOnce(
434 Effect.succeed(
435 output(
436 json({
437 value: [
438 {
439 id: 1,
440 sourceRefCommit: { commitId: "a".repeat(40) },
441 commonRefCommit: { commitId: "b".repeat(40) },
442 },
443 ],
444 }),
445 ),
446 ),
447 )
448 .mockReturnValueOnce(Effect.succeed(output('{"changeEntries":[]}')));
449 const provider = yield* AzureDevOpsPullRequestProvider.make;
450 assert.isDefined(provider.getFileRevisions);
451​
452 const answer = yield* provider.getFileRevisions({
453 cwd: "/w",
454 repository: "web",
455 host: "dev.azure.com",
456 number: 42,
457 paths: ["GONE.md"],
458 });
459​
460 expect([...answer.revisions]).toEqual([["GONE.md", ""]]);
461 }),
462 );
463​
464 it.effect("says nothing about the files past the end of a change it gave up following", () =>
465 Effect.gen(function* () {
466 const entries = (from: number, count: number) =>
467 Array.from({ length: count }, (_, index) => ({
468 changeType: "edit",
469 item: { path: `/src/f${from + index}.ts`, objectId: `blob-${from + index}` },
470 }));
471 mockedExecute
472 .mockReturnValueOnce(
473 Effect.succeed(
474 output(
475 json({
476 pullRequestId: 42,
477 title: "Add the page",
478 status: "active",
479 sourceRefName: "refs/heads/feat/page",
480 targetRefName: "refs/heads/main",
481 creationDate: "2026-07-01T00:00:00Z",
482 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
483 repository: { name: "web", project: { name: "platform" } },
484 }),
485 ),
486 ),
487 )
488 .mockReturnValueOnce(
489 Effect.succeed(
490 output(
491 json({
492 value: [
493 {
494 id: 1,
495 sourceRefCommit: { commitId: "a".repeat(40) },
496 commonRefCommit: { commitId: "b".repeat(40) },
497 },
498 ],
499 }),
500 ),
501 ),
502 )
503 .mockReturnValueOnce(
504 Effect.succeed(output(json({ changeEntries: entries(0, 5_000), nextSkip: 5_000 }))),
505 )
506 .mockReturnValueOnce(
507 Effect.succeed(output(json({ changeEntries: entries(5_000, 5_000), nextSkip: 10_000 }))),
508 );
509 const provider = yield* AzureDevOpsPullRequestProvider.make;
510 assert.isDefined(provider.getFileRevisions);
511​
512 const answer = yield* provider.getFileRevisions({
513 cwd: "/w",
514 repository: "web",
515 host: "dev.azure.com",
516 number: 42,
517 paths: ["src/f1.ts", "src/f5001.ts", "src/past-the-cut.ts"],
518 });
519​
520 expect(argsOfCall(3)).toContain("$skip=5000");
521 expect([...answer.revisions]).toEqual([
522 ["src/f1.ts", "blob-1"],
523 ["src/f5001.ts", "blob-5001"],
524 ]);
525 }),
526 );
527​
528 it.effect("stops following pages by what Azure counts, not by what survives decoding", () =>
529 Effect.gen(function* () {
530 mockedExecute
531 .mockReturnValueOnce(
532 Effect.succeed(
533 output(
534 json({
535 pullRequestId: 42,
536 title: "Add the page",
537 status: "active",
538 sourceRefName: "refs/heads/feat/page",
539 targetRefName: "refs/heads/main",
540 creationDate: "2026-07-01T00:00:00Z",
541 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
542 repository: { name: "web", project: { name: "platform" } },
543 }),
544 ),
545 ),
546 )
547 .mockReturnValueOnce(
548 Effect.succeed(
549 output(
550 json({
551 value: [
552 {
553 id: 1,
554 sourceRefCommit: { commitId: "a".repeat(40) },
555 commonRefCommit: { commitId: "b".repeat(40) },
556 },
557 ],
558 }),
559 ),
560 ),
561 )
562 .mockImplementation((command) => {
563 const skip = command.args.find((arg) => arg.startsWith("$skip="));
564 const from = Number(skip?.slice("$skip=".length) ?? 0);
565 return Effect.succeed(
566 output(
567 json({
568 changeEntries: [{ changeType: "add", item: { path: "/src", isFolder: true } }],
569 nextSkip: from + 2_000,
570 }),
571 ),
572 );
573 });
574 const provider = yield* AzureDevOpsPullRequestProvider.make;
575 assert.isDefined(provider.getFileRevisions);
576​
577 const answer = yield* provider.getFileRevisions({
578 cwd: "/w",
579 repository: "web",
580 host: "dev.azure.com",
581 number: 42,
582 paths: ["src/page.ts"],
583 });
584​
585 assert.strictEqual(mockedExecute.mock.calls.length, 7);
586 assert.strictEqual(answer.revisions.size, 0);
587 }),
588 );
589​
590 it.effect("leaves one file the host would not hand over listed without its hunks", () =>
591 Effect.gen(function* () {
592 mockedExecute
593 .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow))))
594 .mockReturnValueOnce(Effect.succeed(output(json(oneIteration))))
595 .mockReturnValueOnce(
596 Effect.succeed(
597 output(
598 json({
599 changeEntries: [
600 { changeType: "add", item: { path: "/huge.bin", objectId: "8f80" } },
601 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
602 ],
603 }),
604 ),
605 ),
606 )
607 .mockReturnValueOnce(
608 Effect.fail(
609 new AzureDevOpsCli.AzureDevOpsCommandFailedError({
610 operation: "execute",
611 command: "az",
612 cwd: "/w",
613 argumentCount: 1,
614 cause: "the blob is past what the route will carry",
615 }),
616 ),
617 )
618 .mockReturnValueOnce(Effect.succeed(output(json({ content: "hello\n" }))));
619 const provider = yield* AzureDevOpsPullRequestProvider.make;
620​
621 const slice = yield* provider.getDiff({
622 cwd: "/w",
623 repository: "web",
624 host: "dev.azure.com",
625 number: 42,
626 });
627​
628 assert.isTrue(slice.truncated);
629 expect(slice.patch).toContain("diff --git a/huge.bin b/huge.bin");
630 expect(slice.patch).toContain("+hello");
631 }),
632 );
633​
634 it.effect("fails the whole read when it is the connection that would not answer", () =>
635 Effect.gen(function* () {
636 mockedExecute
637 .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow))))
638 .mockReturnValueOnce(Effect.succeed(output(json(oneIteration))))
639 .mockReturnValueOnce(
640 Effect.succeed(
641 output(
642 json({
643 changeEntries: [
644 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
645 ],
646 }),
647 ),
648 ),
649 )
650 .mockReturnValueOnce(
651 Effect.fail(
652 new AzureDevOpsCli.AzureDevOpsCliRateLimitError({
653 operation: "execute",
654 command: "az",
655 cwd: "/w",
656 argumentCount: 1,
657 cause: "429",
658 }),
659 ),
660 );
661 const provider = yield* AzureDevOpsPullRequestProvider.make;
662​
663 const error = yield* Effect.flip(
664 provider.getDiff({ cwd: "/w", repository: "web", host: "dev.azure.com", number: 42 }),
665 );
666​
667 assert.strictEqual(error.reason, "rate-limited");
668 }),
669 );
670​
671 it.effect("takes Azure's own word on a file it will not spell out", () =>
672 Effect.gen(function* () {
673 mockedExecute
674 .mockReturnValueOnce(
675 Effect.succeed(
676 output(
677 json({
678 pullRequestId: 42,
679 title: "Add the page",
680 status: "active",
681 sourceRefName: "refs/heads/feat/page",
682 targetRefName: "refs/heads/main",
683 creationDate: "2026-07-01T00:00:00Z",
684 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
685 repository: { name: "web", project: { name: "platform" } },
686 }),
687 ),
688 ),
689 )
690 .mockReturnValueOnce(
691 Effect.succeed(
692 output(
693 json({
694 value: [
695 {
696 id: 1,
697 sourceRefCommit: { commitId: "a".repeat(40) },
698 commonRefCommit: { commitId: "b".repeat(40) },
699 },
700 ],
701 }),
702 ),
703 ),
704 )
705 .mockReturnValueOnce(
706 Effect.succeed(
707 output(
708 json({
709 changeEntries: [
710 { changeType: "add", item: { path: "/logo.png", objectId: "8f80" } },
711 ],
712 }),
713 ),
714 ),
715 )
716 .mockReturnValueOnce(
717 Effect.succeed(
718 output(json({ content: "iVBORw0KGgo=", contentMetadata: { isBinary: true } })),
719 ),
720 );
721 const provider = yield* AzureDevOpsPullRequestProvider.make;
722​
723 const slice = yield* provider.getDiff({
724 cwd: "/w",
725 repository: "web",
726 host: "dev.azure.com",
727 number: 42,
728 });
729​
730 expect(argsOfCall(3)).toContain("includeContentMetadata=true");
731 expect(slice.patch).toContain("Binary files a/logo.png and b/logo.png differ");
732 assert.isTrue(slice.truncated);
733 }),
734 );
735​
736 it.effect("stops following pages when one of them does not move the cursor on", () =>
737 Effect.gen(function* () {
738 mockedExecute
739 .mockReturnValueOnce(
740 Effect.succeed(
741 output(
742 json({
743 pullRequestId: 42,
744 title: "Add the page",
745 status: "active",
746 sourceRefName: "refs/heads/feat/page",
747 targetRefName: "refs/heads/main",
748 creationDate: "2026-07-01T00:00:00Z",
749 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
750 repository: { name: "web", project: { name: "platform" } },
751 }),
752 ),
753 ),
754 )
755 .mockReturnValueOnce(
756 Effect.succeed(
757 output(
758 json({
759 value: [
760 {
761 id: 1,
762 sourceRefCommit: { commitId: "a".repeat(40) },
763 commonRefCommit: { commitId: "b".repeat(40) },
764 },
765 ],
766 }),
767 ),
768 ),
769 )
770 .mockReturnValueOnce(
771 Effect.succeed(
772 output(
773 json({
774 changeEntries: [{ changeType: "edit", item: { path: "/a.ts", objectId: "8f80" } }],
775 nextSkip: 2_000,
776 }),
777 ),
778 ),
779 )
780 .mockReturnValueOnce(
781 Effect.succeed(
782 output(
783 json({
784 changeEntries: [{ changeType: "edit", item: { path: "/b.ts", objectId: "0ca4" } }],
785 nextSkip: 2_000,
786 }),
787 ),
788 ),
789 );
790 const provider = yield* AzureDevOpsPullRequestProvider.make;
791 assert.isDefined(provider.getFileRevisions);
792​
793 const answer = yield* provider.getFileRevisions({
794 cwd: "/w",
795 repository: "web",
796 host: "dev.azure.com",
797 number: 42,
798 paths: ["a.ts", "b.ts", "unlisted.ts"],
799 });
800​
801 assert.strictEqual(mockedExecute.mock.calls.length, 4);
802 expect([...answer.revisions]).toEqual([
803 ["a.ts", "8f80"],
804 ["b.ts", "0ca4"],
805 ]);
806 }),
807 );
808​
809 it.effect("reads a full page of change entries, which is past the default output limit", () =>
810 Effect.gen(function* () {
811 const response = json({ changeEntries: changeEntries(2_000) });
812 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
813 mockedExecute.mockImplementationOnce((input) =>
814 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
815 );
816 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
817​
818 const page = yield* cli.listIterationChanges({
819 cwd: "/w",
820 location: { project: "platform", repository: "web" },
821 number: 42,
822 iterationId: 1,
823 });
824​
825 assert.strictEqual(page.changes.length, 2_000);
826 assert.isFalse(page.truncated);
827 }),
828 );
829​
830 it.effect("reads a file whose JSON envelope is past the default output limit", () =>
831 Effect.gen(function* () {
832 const file = "const value = 1;\n".repeat(57_000);
833 const response = json({ content: file });
834 expect(Buffer.byteLength(file)).toBeLessThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
835 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
836 mockedExecute.mockImplementationOnce((input) =>
837 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
838 );
839 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
840​
841 const item = yield* cli.readItemContent({
842 cwd: "/w",
843 location: { project: "platform", repository: "web" },
844 path: "src/generated/schema.ts",
845 commit: "a".repeat(40),
846 });
847​
848 assert.strictEqual(item.contents, file);
849 assert.isFalse(item.isBinary);
850 }),
851 );
852​
853 it.effect("escapes characters in a file path that would cut the query string", () =>
854 Effect.gen(function* () {
855 mockedExecute.mockReturnValueOnce(Effect.succeed(output(json({ content: "x" }))));
856 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
857​
858 yield* cli.readItemContent({
859 cwd: "/w",
860 location: { project: "platform", repository: "web" },
861 path: "docs/C#/a&b.md",
862 commit: "a".repeat(40),
863 });
864​
865 expect(mockedExecute.mock.calls.at(-1)?.[0].args).toContain("path=%2Fdocs%2FC%23%2Fa%26b.md");
866 }),
867 );
868​
869 it.effect("reports a pull request it cannot place as its own outcome", () =>
870 Effect.gen(function* () {
871 mockedExecute.mockReturnValueOnce(
872 Effect.succeed(
873 output(
874 JSON.stringify({
875 pullRequestId: 42,
876 title: "Add the page",
877 sourceRefName: "refs/heads/feat/page",
878 targetRefName: "refs/heads/main",
879 creationDate: "2026-07-01T00:00:00Z",
880 }),
881 ),
882 ),
883 );
884 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
885​
886 const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 }));
887​
888 assert.strictEqual(error._tag, "AzureDevOpsPullRequestIncompleteError");
889 }),
890 );
891​
892 it.effect("refuses a reviewer az would read as a flag, before running anything", () =>
893 Effect.gen(function* () {
894 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
895​
896 const error = yield* Effect.flip(
897 cli.setPullRequestReviewers({
898 cwd: "/w",
899 number: 42,
900 reviewers: ["--query"],
901 requested: true,
902 }),
903 );
904​
905 assert.strictEqual(error._tag, "AzureDevOpsReviewerNameError");
906 assert.strictEqual(mockedExecute.mock.calls.length, 0);
907 }),
908 );
909});