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 };
30}
31
32/** What VcsProcess allows a read that asked for no ceiling of its own. */
33const VCS_DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
34
35/**
36 * The runner as it really behaves: it cuts stdout at the ceiling its caller asked for, and cuts
37 * it at the process default when the caller asked for none. A read whose response is larger than
38 * its ceiling gets JSON that stops mid-string, which is the whole cost of an unset ceiling.
39 */
40function outputWithin(maxOutputBytes: number | undefined, response: string) {
41 const ceiling = maxOutputBytes ?? VCS_DEFAULT_MAX_OUTPUT_BYTES;
42 return ceiling >= Buffer.byteLength(response)
43 ? output(response)
44 : { ...output(response.slice(0, ceiling)), stdoutTruncated: true };
45}
46
47/** A fixture's own shape, spelled the way `az` would answer with it. */
48const json = (value: Record<string, unknown>) => JSON.stringify(value);
49
50const pullRequestRow = {
51 pullRequestId: 42,
52 title: "Add the page",
53 status: "active",
54 sourceRefName: "refs/heads/feat/page",
55 targetRefName: "refs/heads/main",
56 creationDate: "2026-07-01T00:00:00Z",
57 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
58 repository: { name: "web", project: { name: "platform" } },
59};
60
61const oneIteration = {
62 value: [
63 {
64 id: 1,
65 sourceRefCommit: { commitId: "a".repeat(40) },
66 commonRefCommit: { commitId: "b".repeat(40) },
67 },
68 ],
69};
70
71function pullRequestRows(
72 count: number,
73 firstNumber: number,
74): ReadonlyArray<Record<string, unknown>> {
75 return Array.from({ length: count }, (_, index) => ({
76 pullRequestId: firstNumber + index,
77 title: `Pull request ${firstNumber + index}`,
78 status: "active",
79 sourceRefName: "refs/heads/feat/page",
80 targetRefName: "refs/heads/main",
81 creationDate: "2026-07-01T00:00:00Z",
82 repository: { name: "web", project: { name: "platform" } },
83 url: `https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/${firstNumber + index}`,
84 }));
85}
86
87function pullRequests(count: number, firstNumber: number): string {
88 return JSON.stringify(pullRequestRows(count, firstNumber));
89}
90
91/** The arguments of the nth az invocation. */
92function argsOfCall(index: number): ReadonlyArray<string> {
93 const call = mockedExecute.mock.calls[index];
94 assert.isDefined(call);
95 return call[0].args;
96}
97
98/** The output ceiling the nth az invocation asked for, if it asked for one at all. */
99function maxOutputBytesOfCall(index: number) {
100 const call = mockedExecute.mock.calls[index];
101 assert.isDefined(call);
102 return call[0].maxOutputBytes;
103}
104
105/** A page of change entries the size Azure really answers with, url and object ids and all. */
106function changeEntries(count: number): ReadonlyArray<Record<string, unknown>> {
107 const commit = "c".repeat(40);
108 return Array.from({ length: count }, (_, index) => {
109 const path = `/apps/server/src/generated/module-${index}/persisted-projection-${index}.ts`;
110 return {
111 changeType: "edit",
112 item: {
113 path,
114 objectId: "a".repeat(40),
115 originalObjectId: "b".repeat(40),
116 commitId: commit,
117 gitObjectType: "blob",
118 url: `https://dev.azure.com/acme/platform/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/items${path}?versionType=Commit&version=${commit}`,
119 },
120 };
121 });
122}
123
124/** An Azure identity, which rides along with every comment and every push Azure answers with. */
125function identity(name: string) {
126 const id = "6f9c9b7f-0000-0000-0000-000000000000";
127 return {
128 displayName: name,
129 id,
130 uniqueName: `${name.toLowerCase().replace(/ /g, ".")}@acme.com`,
131 descriptor: `aad.${"z".repeat(52)}`,
132 imageUrl: `https://dev.azure.com/acme/_api/_common/identityImage?id=${id}`,
133 url: `https://spsprodweu1.vssps.visualstudio.com/A${id}/_apis/Identities/${id}`,
134 _links: {
135 avatar: {
136 href: `https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.${"z".repeat(52)}`,
137 },
138 },
139 };
140}
141
142/** A review's threads the shape Azure answers with, system threads and identities and all. */
143function threadRows(count: number): ReadonlyArray<Record<string, unknown>> {
144 return Array.from({ length: count }, (_, index) => ({
145 id: index + 1,
146 publishedDate: "2026-07-02T00:00:00Z",
147 lastUpdatedDate: "2026-07-02T00:00:00Z",
148 status: "active",
149 threadContext: { filePath: `/apps/server/src/generated/module-${index}.ts` },
150 identities: { 1: identity("Reviewer One") },
151 isDeleted: false,
152 comments: [
153 {
154 id: 1,
155 parentCommentId: 0,
156 author: identity("Reviewer One"),
157 content: `Comment ${index}: ${"this needs another look. ".repeat(20)}`,
158 publishedDate: "2026-07-02T00:00:00Z",
159 lastUpdatedDate: "2026-07-02T00:00:00Z",
160 commentType: "text",
161 usersLiked: [],
162 },
163 ],
164 }));
165}
166
167/** A review's iterations the shape Azure answers with, one per push. */
168function iterationRows(count: number): ReadonlyArray<Record<string, unknown>> {
169 return Array.from({ length: count }, (_, index) => ({
170 id: index + 1,
171 description: `Pushed ${index} commits`,
172 author: identity("Author One"),
173 createdDate: "2026-07-02T00:00:00Z",
174 updatedDate: "2026-07-02T00:00:00Z",
175 sourceRefCommit: { commitId: `${index}`.padStart(40, "a") },
176 targetRefCommit: { commitId: "b".repeat(40) },
177 commonRefCommit: { commitId: "c".repeat(40) },
178 hasMultipleCommits: true,
179 reason: "push",
180 push: { pushId: index + 1, date: "2026-07-02T00:00:00Z", pushedBy: identity("Author One") },
181 }));
182}
183
184afterEach(() => {
185 mockedExecute.mockReset();
186});
187
188layer("AzureDevOpsPullRequestCli.layer", (it) => {
189 it.effect("asks for one row more than the page, to probe for a next page", () =>
190 Effect.gen(function* () {
191 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
192 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
193
194 const batch = yield* cli.listPullRequests({
195 cwd: "/w",
196 repository: "web",
197 state: "open",
198 involvement: "all",
199 viewer: "bilal@acme.dev",
200 limit: 10,
201 });
202
203 assert.strictEqual(batch.items.length, 3);
204 assert.isFalse(batch.truncated);
205 expect(argsOfCall(0)).toEqual([
206 "repos",
207 "pr",
208 "list",
209 "--detect",
210 "true",
211 "--repository",
212 "web",
213 "--status",
214 "active",
215 "--include-links",
216 "--top",
217 "11",
218 "--only-show-errors",
219 "--output",
220 "json",
221 ]);
222 }),
223 );
224
225 it.effect("reads an Azure pull request page larger than the VCS default output limit", () =>
226 Effect.gen(function* () {
227 const rows = pullRequestRows(100, 1).map((row) => ({
228 ...row,
229 description: "x".repeat(10_000),
230 }));
231 const response = JSON.stringify(rows);
232 expect(Buffer.byteLength(response)).toBeGreaterThan(1_000_000);
233
234 mockedExecute.mockImplementationOnce((input) =>
235 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
236 );
237 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
238
239 const batch = yield* cli.listPullRequests({
240 cwd: "/w",
241 repository: "web",
242 state: "merged",
243 involvement: "all",
244 viewer: "bilal@acme.dev",
245 limit: 99,
246 });
247
248 assert.strictEqual(batch.items.length, 99);
249 assert.isTrue(batch.truncated);
250 }),
251 );
252
253 it.effect("reads the page unnarrowed when asked to search, having nothing to search with", () =>
254 Effect.gen(function* () {
255 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
256 const provider = yield* AzureDevOpsPullRequestProvider.make;
257
258 const page = yield* provider.listChangeRequests({
259 cwd: "/w",
260 repository: "web",
261 host: "dev.azure.com",
262 state: "open",
263 involvement: "all",
264 viewer: "bilal@acme.dev",
265 limit: 10,
266 query: "page",
267 });
268
269 // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at
270 // all. The rows come back as they would have without a search, for the caller to narrow;
271 // nothing of the search reaches the command, where it could only mean the wrong thing.
272 assert.strictEqual(page.items.length, 3);
273 expect(argsOfCall(0)).toEqual([
274 "repos",
275 "pr",
276 "list",
277 "--detect",
278 "true",
279 "--repository",
280 "web",
281 "--status",
282 "active",
283 "--include-links",
284 "--top",
285 "11",
286 "--only-show-errors",
287 "--output",
288 "json",
289 ]);
290 }),
291 );
292
293 it.effect("steps over what it has already handed over, which is all Azure can be told", () =>
294 Effect.gen(function* () {
295 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1))));
296 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
297
298 yield* cli.listPullRequests({
299 cwd: "/w",
300 repository: "web",
301 state: "open",
302 involvement: "all",
303 viewer: "bilal@acme.dev",
304 limit: 10,
305 // The instant is the same cursor every other host reads; Azure has no filter for it and
306 // takes the count instead.
307 cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 20 },
308 });
309
310 const args = argsOfCall(0);
311 expect(args).toContain("--skip");
312 assert.strictEqual(args[args.indexOf("--skip") + 1], "20");
313 expect(args).not.toContain("2026-07-02T00:00:00Z");
314 }),
315 );
316
317 it.effect("reports truncation from the extra row", () =>
318 Effect.gen(function* () {
319 mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1))));
320 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
321
322 const batch = yield* cli.listPullRequests({
323 cwd: "/w",
324 repository: "web",
325 state: "open",
326 involvement: "all",
327 viewer: "bilal@acme.dev",
328 limit: 10,
329 });
330
331 assert.strictEqual(batch.items.length, 10);
332 assert.isTrue(batch.truncated);
333 assert.strictEqual(batch.cursorAdvance, 10);
334 }),
335 );
336
337 it.effect("advances by malformed raw rows and keeps reading until the page is full", () =>
338 Effect.gen(function* () {
339 mockedExecute
340 .mockReturnValueOnce(
341 Effect.succeed(
342 output(
343 JSON.stringify([
344 { pullRequestId: "malformed" },
345 pullRequestRows(1, 1)[0],
346 { pullRequestId: "also malformed" },
347 ]),
348 ),
349 ),
350 )
351 .mockReturnValueOnce(Effect.succeed(output(pullRequests(2, 2))));
352 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
353
354 const batch = yield* cli.listPullRequests({
355 cwd: "/w",
356 repository: "web",
357 state: "open",
358 involvement: "all",
359 viewer: "bilal@acme.dev",
360 limit: 2,
361 });
362
363 expect(batch.items.map((item) => item.number)).toEqual([1, 2]);
364 assert.isTrue(batch.truncated);
365 // Three raw rows from the first request and one from the second produced this page.
366 assert.strictEqual(batch.cursorAdvance, 4);
367 const secondArgs = argsOfCall(1);
368 assert.strictEqual(secondArgs[secondArgs.indexOf("--skip") + 1], "3");
369 assert.strictEqual(secondArgs[secondArgs.indexOf("--top") + 1], "2");
370 }),
371 );
372
373 it.effect("narrows to the author on the authored tab", () =>
374 Effect.gen(function* () {
375 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
376 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
377
378 yield* cli.listPullRequests({
379 cwd: "/w",
380 repository: "web",
381 state: "closed",
382 involvement: "authored",
383 viewer: "bilal@acme.dev",
384 limit: 10,
385 });
386
387 expect(argsOfCall(0)).toContain("--creator");
388 expect(argsOfCall(0)).toContain("bilal@acme.dev");
389 // Azure calls a closed pull request abandoned.
390 expect(argsOfCall(0)).toContain("abandoned");
391 }),
392 );
393
394 it.effect("asks Azure for every status on the All tab", () =>
395 Effect.gen(function* () {
396 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
397 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
398
399 yield* cli.listPullRequests({
400 cwd: "/w",
401 repository: "web",
402 state: "all",
403 involvement: "all",
404 viewer: "bilal@acme.dev",
405 limit: 10,
406 });
407
408 expect(argsOfCall(0)).toContain("--status");
409 expect(argsOfCall(0)).toContain("all");
410 }),
411 );
412
413 it.effect("narrows to the reviewer on the reviewing tab", () =>
414 Effect.gen(function* () {
415 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
416 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
417
418 yield* cli.listPullRequests({
419 cwd: "/w",
420 repository: "web",
421 state: "open",
422 involvement: "reviewing",
423 viewer: "bilal@acme.dev",
424 limit: 10,
425 });
426
427 expect(argsOfCall(0)).toContain("--reviewer");
428 }),
429 );
430
431 it.effect("reads the signed-in account, which az reports as a bare value", () =>
432 Effect.gen(function* () {
433 // `--query user` unwraps the object, so the wrapper has to put it back.
434 mockedExecute.mockReturnValueOnce(
435 Effect.succeed(output(JSON.stringify({ name: "bilal@acme.dev", type: "user" }))),
436 );
437 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
438
439 const viewer = yield* cli.getViewer({ cwd: "/w" });
440
441 assert.strictEqual(viewer, "bilal@acme.dev");
442 expect(argsOfCall(0)).toEqual([
443 "account",
444 "show",
445 "--query",
446 "user",
447 "--only-show-errors",
448 "--output",
449 "json",
450 ]);
451 }),
452 );
453
454 it.effect("names a token sign-in by the viewer's own pull request", () =>
455 Effect.gen(function* () {
456 const notLoggedIn = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({
457 operation: "execute",
458 command: "az",
459 cwd: "/w",
460 argumentCount: 7,
461 cause: new Error("Please run 'az login' to setup account."),
462 });
463 mockedExecute
464 .mockReturnValueOnce(Effect.fail(notLoggedIn))
465 .mockReturnValueOnce(
466 Effect.succeed(
467 output(
468 JSON.stringify([{ ...pullRequestRow, createdBy: { uniqueName: "bilal@acme.dev" } }]),
469 ),
470 ),
471 )
472 .mockReturnValueOnce(Effect.succeed(output("")))
473 .mockReturnValueOnce(Effect.succeed(output("[]")));
474 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
475
476 assert.strictEqual(yield* cli.getViewer({ cwd: "/w" }), "bilal@acme.dev");
477 expect(argsOfCall(1)).toEqual(expect.arrayContaining(["--creator", "me", "--top", "1"]));
478 // Someone who has opened nothing has no name to compare rows against.
479 const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" }));
480 assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError");
481 }),
482 );
483
484 it.effect("fails when no sign-in can read pull requests", () =>
485 Effect.gen(function* () {
486 const signedOut = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({
487 operation: "execute",
488 command: "az",
489 cwd: "/w",
490 argumentCount: 7,
491 cause: new Error("signed out"),
492 });
493 mockedExecute.mockReturnValue(Effect.fail(signedOut));
494 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
495
496 const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" }));
497
498 assert.strictEqual(error._tag, "AzureDevOpsCliAuthenticationError");
499 }),
500 );
501
502 it.effect("completes a pull request to merge it, squashing only when asked", () =>
503 Effect.gen(function* () {
504 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
505 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
506
507 yield* cli.runPullRequestAction({
508 cwd: "/w",
509 number: 42,
510 action: "merge",
511 mergeMethod: "squash",
512 });
513
514 expect(argsOfCall(0)).toEqual([
515 "repos",
516 "pr",
517 "update",
518 "--detect",
519 "true",
520 "--id",
521 "42",
522 "--status",
523 "completed",
524 "--squash",
525 "true",
526 "--only-show-errors",
527 "--output",
528 "json",
529 ]);
530 }),
531 );
532
533 it.effect("stores the squash choice with an auto-completion, as a merge now does", () =>
534 Effect.gen(function* () {
535 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
536 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
537
538 yield* cli.runPullRequestAction({
539 cwd: "/w",
540 number: 42,
541 action: "enable-auto-merge",
542 mergeMethod: "squash",
543 });
544
545 expect(argsOfCall(0)).toEqual([
546 "repos",
547 "pr",
548 "update",
549 "--detect",
550 "true",
551 "--id",
552 "42",
553 "--auto-complete",
554 "true",
555 "--squash",
556 "true",
557 "--only-show-errors",
558 "--output",
559 "json",
560 ]);
561 }),
562 );
563
564 it.effect.each([
565 { action: "enable-auto-merge", expected: ["--auto-complete", "true"] },
566 { action: "disable-auto-merge", expected: ["--auto-complete", "false"] },
567 { action: "draft", expected: ["--draft", "true"] },
568 { action: "ready", expected: ["--draft", "false"] },
569 { action: "close", expected: ["--status", "abandoned"] },
570 { action: "reopen", expected: ["--status", "active"] },
571 ] as const)("moves a pull request with $action", ({ action, expected }) =>
572 Effect.gen(function* () {
573 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
574 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
575
576 yield* cli.runPullRequestAction({ cwd: "/w", number: 42, action });
577
578 expect(argsOfCall(0)).toEqual([
579 "repos",
580 "pr",
581 "update",
582 "--detect",
583 "true",
584 "--id",
585 "42",
586 ...expected,
587 "--only-show-errors",
588 "--output",
589 "json",
590 ]);
591 }),
592 );
593
594 it.effect.each([
595 { name: "a title", rewrite: { title: "Add the page" }, expected: ["--title=Add the page"] },
596 {
597 name: "a description",
598 rewrite: { body: "Why the page changed" },
599 expected: ["--description=Why the page changed"],
600 },
601 {
602 name: "both",
603 rewrite: { title: "Add the page", body: "Why the page changed" },
604 expected: ["--title=Add the page", "--description=Why the page changed"],
605 },
606 ] as const)("rewrites $name, sending nothing it was not given", ({ rewrite, expected }) =>
607 Effect.gen(function* () {
608 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
609 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
610
611 yield* cli.updatePullRequest({ cwd: "/w", number: 42, ...rewrite });
612
613 expect(argsOfCall(0)).toEqual([
614 "repos",
615 "pr",
616 "update",
617 "--detect",
618 "true",
619 "--id",
620 "42",
621 ...expected,
622 "--only-show-errors",
623 "--output",
624 "json",
625 ]);
626 }),
627 );
628
629 it.effect("sends a description that starts with a dash as one value, not as a flag", () =>
630 Effect.gen(function* () {
631 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
632 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
633
634 yield* cli.updatePullRequest({
635 cwd: "/w",
636 number: 42,
637 body: "- rewrote the page\n- kept the rest",
638 });
639
640 // One argument, so the leading dash of an ordinary bullet list never reaches az as a flag,
641 // and the whole text stays together where `--description` would otherwise take several.
642 expect(argsOfCall(0)).toContain("--description=- rewrote the page\n- kept the rest");
643 }),
644 );
645
646 it.effect("rewrites through the provider, which says it takes one", () =>
647 Effect.gen(function* () {
648 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
649 const provider = yield* AzureDevOpsPullRequestProvider.make;
650
651 // False for a remark because nothing here can post one, so there is none to rewrite.
652 expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: false });
653 assert.isDefined(provider.updateChangeRequest);
654 yield* provider.updateChangeRequest({
655 cwd: "/w",
656 repository: "web",
657 host: "dev.azure.com",
658 number: 42,
659 title: "Add the page",
660 });
661
662 expect(argsOfCall(0)).toContain("--title=Add the page");
663 expect(argsOfCall(0)).not.toContain("--description");
664 }),
665 );
666
667 it.effect("names the head's blob as what a cleared file was cleared at", () =>
668 Effect.gen(function* () {
669 mockedExecute
670 .mockReturnValueOnce(
671 Effect.succeed(
672 output(
673 JSON.stringify({
674 pullRequestId: 42,
675 title: "Add the page",
676 status: "active",
677 sourceRefName: "refs/heads/feat/page",
678 targetRefName: "refs/heads/main",
679 creationDate: "2026-07-01T00:00:00Z",
680 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
681 repository: { name: "web", project: { name: "platform" } },
682 }),
683 ),
684 ),
685 )
686 .mockReturnValueOnce(
687 Effect.succeed(
688 output(
689 JSON.stringify({
690 value: [
691 {
692 id: 1,
693 sourceRefCommit: { commitId: "a".repeat(40) },
694 commonRefCommit: { commitId: "b".repeat(40) },
695 },
696 {
697 id: 2,
698 sourceRefCommit: { commitId: "c".repeat(40) },
699 commonRefCommit: { commitId: "b".repeat(40) },
700 },
701 ],
702 }),
703 ),
704 ),
705 )
706 .mockReturnValueOnce(
707 Effect.succeed(
708 output(
709 JSON.stringify({
710 changeEntries: [
711 { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } },
712 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
713 ],
714 }),
715 ),
716 ),
717 );
718 const provider = yield* AzureDevOpsPullRequestProvider.make;
719
720 // Kept here rather than on Azure: its own record of what a reader has read is behind an
721 // undocumented endpoint, so the marks belong to this environment and need a revision of
722 // their own to tell a re-push from a file still as it was read.
723 assert.strictEqual(provider.capabilities.viewedFiles, "environment");
724 assert.isDefined(provider.getFileRevisions);
725 const answer = yield* provider.getFileRevisions({
726 cwd: "/w",
727 repository: "web",
728 host: "dev.azure.com",
729 number: 42,
730 paths: ["README.md"],
731 });
732
733 // The latest push, since an iteration's changes are reported against the merge base rather
734 // than against the push before it.
735 expect(argsOfCall(2)).toContain("iterationId=2");
736 // Only what was asked for. DEMO.md changed too, and nobody has marked it.
737 expect([...answer.revisions]).toEqual([["README.md", "8f80"]]);
738 }),
739 );
740
741 it.effect("reads where a pull request lives once, however often it is asked about", () =>
742 Effect.gen(function* () {
743 // A pull request cannot move repositories, and the marks would otherwise pay for a whole
744 // pull request read every time they checked whether a file had been pushed to.
745 const pullRequest = Effect.succeed(
746 output(
747 JSON.stringify({
748 pullRequestId: 42,
749 title: "Add the page",
750 status: "active",
751 sourceRefName: "refs/heads/feat/page",
752 targetRefName: "refs/heads/main",
753 creationDate: "2026-07-01T00:00:00Z",
754 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
755 repository: { name: "web", project: { name: "platform" } },
756 }),
757 ),
758 );
759 const iterations = () =>
760 Effect.succeed(
761 output(
762 JSON.stringify({
763 value: [
764 {
765 id: 1,
766 sourceRefCommit: { commitId: "a".repeat(40) },
767 commonRefCommit: { commitId: "b".repeat(40) },
768 },
769 ],
770 }),
771 ),
772 );
773 const changes = () =>
774 Effect.succeed(
775 output(
776 JSON.stringify({
777 changeEntries: [
778 { changeType: "edit", item: { path: "/README.md", objectId: "8f80" } },
779 ],
780 }),
781 ),
782 );
783 mockedExecute
784 .mockReturnValueOnce(pullRequest)
785 .mockReturnValueOnce(iterations())
786 .mockReturnValueOnce(changes())
787 .mockReturnValueOnce(iterations())
788 .mockReturnValueOnce(changes());
789 const provider = yield* AzureDevOpsPullRequestProvider.make;
790 const read = provider.getFileRevisions;
791 assert.isDefined(read);
792 const ask = () =>
793 read({
794 cwd: "/w",
795 repository: "web",
796 host: "dev.azure.com",
797 number: 42,
798 paths: ["README.md"],
799 });
800
801 yield* ask();
802 const again = yield* ask();
803
804 assert.strictEqual(mockedExecute.mock.calls.length, 5);
805 // The second read goes straight to the pushes, and still answers with the head's blob.
806 expect(argsOfCall(3)).toContain("pullRequestIterations");
807 expect([...again.revisions]).toEqual([["README.md", "8f80"]]);
808 }),
809 );
810
811 it.effect(
812 "answers for a marked file the pull request no longer changes as the empty version",
813 () =>
814 Effect.gen(function* () {
815 // Which is what was stored for it when it was ticked with nothing on the head, so a file
816 // the pull request deletes is cleared once and stays cleared.
817 mockedExecute
818 .mockReturnValueOnce(
819 Effect.succeed(
820 output(
821 json({
822 pullRequestId: 42,
823 title: "Add the page",
824 status: "active",
825 sourceRefName: "refs/heads/feat/page",
826 targetRefName: "refs/heads/main",
827 creationDate: "2026-07-01T00:00:00Z",
828 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
829 repository: { name: "web", project: { name: "platform" } },
830 }),
831 ),
832 ),
833 )
834 .mockReturnValueOnce(
835 Effect.succeed(
836 output(
837 json({
838 value: [
839 {
840 id: 1,
841 sourceRefCommit: { commitId: "a".repeat(40) },
842 commonRefCommit: { commitId: "b".repeat(40) },
843 },
844 ],
845 }),
846 ),
847 ),
848 )
849 .mockReturnValueOnce(Effect.succeed(output('{"changeEntries":[]}')));
850 const provider = yield* AzureDevOpsPullRequestProvider.make;
851 assert.isDefined(provider.getFileRevisions);
852
853 const answer = yield* provider.getFileRevisions({
854 cwd: "/w",
855 repository: "web",
856 host: "dev.azure.com",
857 number: 42,
858 paths: ["GONE.md"],
859 });
860
861 expect([...answer.revisions]).toEqual([["GONE.md", ""]]);
862 }),
863 );
864
865 it.effect("says nothing about the files past the end of a change it gave up following", () =>
866 Effect.gen(function* () {
867 // Every page is an `az` process of its own, so a change past the ceiling stops being
868 // followed. A path nobody looked at must not be answered for as deleted.
869 const entries = (from: number, count: number) =>
870 Array.from({ length: count }, (_, index) => ({
871 changeType: "edit",
872 item: { path: `/src/f${from + index}.ts`, objectId: `blob-${from + index}` },
873 }));
874 mockedExecute
875 .mockReturnValueOnce(
876 Effect.succeed(
877 output(
878 json({
879 pullRequestId: 42,
880 title: "Add the page",
881 status: "active",
882 sourceRefName: "refs/heads/feat/page",
883 targetRefName: "refs/heads/main",
884 creationDate: "2026-07-01T00:00:00Z",
885 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
886 repository: { name: "web", project: { name: "platform" } },
887 }),
888 ),
889 ),
890 )
891 .mockReturnValueOnce(
892 Effect.succeed(
893 output(
894 json({
895 value: [
896 {
897 id: 1,
898 sourceRefCommit: { commitId: "a".repeat(40) },
899 commonRefCommit: { commitId: "b".repeat(40) },
900 },
901 ],
902 }),
903 ),
904 ),
905 )
906 .mockReturnValueOnce(
907 Effect.succeed(output(json({ changeEntries: entries(0, 5_000), nextSkip: 5_000 }))),
908 )
909 .mockReturnValueOnce(
910 Effect.succeed(output(json({ changeEntries: entries(5_000, 5_000), nextSkip: 10_000 }))),
911 );
912 const provider = yield* AzureDevOpsPullRequestProvider.make;
913 assert.isDefined(provider.getFileRevisions);
914
915 const answer = yield* provider.getFileRevisions({
916 cwd: "/w",
917 repository: "web",
918 host: "dev.azure.com",
919 number: 42,
920 paths: ["src/f1.ts", "src/f5001.ts", "src/past-the-cut.ts"],
921 });
922
923 // The second page picks up where the first said it ended.
924 expect(argsOfCall(3)).toContain("$skip=5000");
925 expect([...answer.revisions]).toEqual([
926 ["src/f1.ts", "blob-1"],
927 ["src/f5001.ts", "blob-5001"],
928 ]);
929 }),
930 );
931
932 it.effect("stops following pages by what Azure counts, not by what survives decoding", () =>
933 Effect.gen(function* () {
934 mockedExecute
935 .mockReturnValueOnce(
936 Effect.succeed(
937 output(
938 json({
939 pullRequestId: 42,
940 title: "Add the page",
941 status: "active",
942 sourceRefName: "refs/heads/feat/page",
943 targetRefName: "refs/heads/main",
944 creationDate: "2026-07-01T00:00:00Z",
945 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
946 repository: { name: "web", project: { name: "platform" } },
947 }),
948 ),
949 ),
950 )
951 .mockReturnValueOnce(
952 Effect.succeed(
953 output(
954 json({
955 value: [
956 {
957 id: 1,
958 sourceRefCommit: { commitId: "a".repeat(40) },
959 commonRefCommit: { commitId: "b".repeat(40) },
960 },
961 ],
962 }),
963 ),
964 ),
965 )
966 // Nothing a review can show, so every page decodes to nothing at all and a ceiling counted
967 // in files would never be reached however long the walk went on.
968 .mockImplementation((command) => {
969 const skip = command.args.find((arg) => arg.startsWith("$skip="));
970 const from = Number(skip?.slice("$skip=".length) ?? 0);
971 return Effect.succeed(
972 output(
973 json({
974 changeEntries: [{ changeType: "add", item: { path: "/src", isFolder: true } }],
975 nextSkip: from + 2_000,
976 }),
977 ),
978 );
979 });
980 const provider = yield* AzureDevOpsPullRequestProvider.make;
981 assert.isDefined(provider.getFileRevisions);
982
983 const answer = yield* provider.getFileRevisions({
984 cwd: "/w",
985 repository: "web",
986 host: "dev.azure.com",
987 number: 42,
988 paths: ["src/page.ts"],
989 });
990
991 // The pull request, its pushes, and five pages: the walk gives up on Azure's own offset
992 // rather than spending an `az` process a page for as long as Azure keeps paging.
993 assert.strictEqual(mockedExecute.mock.calls.length, 7);
994 // And it read part of a change, so it says nothing about the file it never saw.
995 assert.strictEqual(answer.revisions.size, 0);
996 }),
997 );
998
999 it.effect("leaves one file the host would not hand over listed without its hunks", () =>
1000 Effect.gen(function* () {
1001 mockedExecute
1002 .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow))))
1003 .mockReturnValueOnce(Effect.succeed(output(json(oneIteration))))
1004 .mockReturnValueOnce(
1005 Effect.succeed(
1006 output(
1007 json({
1008 changeEntries: [
1009 { changeType: "add", item: { path: "/huge.bin", objectId: "8f80" } },
1010 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
1011 ],
1012 }),
1013 ),
1014 ),
1015 )
1016 .mockReturnValueOnce(
1017 Effect.fail(
1018 new AzureDevOpsCli.AzureDevOpsCommandFailedError({
1019 operation: "execute",
1020 command: "az",
1021 cwd: "/w",
1022 argumentCount: 1,
1023 cause: "the blob is past what the route will carry",
1024 }),
1025 ),
1026 )
1027 .mockReturnValueOnce(Effect.succeed(output(json({ content: "hello\n" }))));
1028 const provider = yield* AzureDevOpsPullRequestProvider.make;
1029
1030 const slice = yield* provider.getDiff({
1031 cwd: "/w",
1032 repository: "web",
1033 host: "dev.azure.com",
1034 number: 42,
1035 });
1036
1037 assert.isTrue(slice.truncated);
1038 expect(slice.patch).toContain("diff --git a/huge.bin b/huge.bin");
1039 // And the file behind it still renders, which is the point of giving up on one file.
1040 expect(slice.patch).toContain("+hello");
1041 }),
1042 );
1043
1044 it.effect("fails the whole read when it is the connection that would not answer", () =>
1045 Effect.gen(function* () {
1046 // A rate limit is not this file's problem, and answering with a change full of files listed
1047 // without their hunks would read as a change nobody can see rather than as a host to wait
1048 // for.
1049 mockedExecute
1050 .mockReturnValueOnce(Effect.succeed(output(json(pullRequestRow))))
1051 .mockReturnValueOnce(Effect.succeed(output(json(oneIteration))))
1052 .mockReturnValueOnce(
1053 Effect.succeed(
1054 output(
1055 json({
1056 changeEntries: [
1057 { changeType: "add", item: { path: "/DEMO.md", objectId: "0ca4" } },
1058 ],
1059 }),
1060 ),
1061 ),
1062 )
1063 .mockReturnValueOnce(
1064 Effect.fail(
1065 new AzureDevOpsCli.AzureDevOpsCliRateLimitError({
1066 operation: "execute",
1067 command: "az",
1068 cwd: "/w",
1069 argumentCount: 1,
1070 cause: "429",
1071 }),
1072 ),
1073 );
1074 const provider = yield* AzureDevOpsPullRequestProvider.make;
1075
1076 const error = yield* Effect.flip(
1077 provider.getDiff({ cwd: "/w", repository: "web", host: "dev.azure.com", number: 42 }),
1078 );
1079
1080 assert.strictEqual(error.reason, "rate-limited");
1081 }),
1082 );
1083
1084 it.effect("takes Azure's own word on a file it will not spell out", () =>
1085 Effect.gen(function* () {
1086 mockedExecute
1087 .mockReturnValueOnce(
1088 Effect.succeed(
1089 output(
1090 json({
1091 pullRequestId: 42,
1092 title: "Add the page",
1093 status: "active",
1094 sourceRefName: "refs/heads/feat/page",
1095 targetRefName: "refs/heads/main",
1096 creationDate: "2026-07-01T00:00:00Z",
1097 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
1098 repository: { name: "web", project: { name: "platform" } },
1099 }),
1100 ),
1101 ),
1102 )
1103 .mockReturnValueOnce(
1104 Effect.succeed(
1105 output(
1106 json({
1107 value: [
1108 {
1109 id: 1,
1110 sourceRefCommit: { commitId: "a".repeat(40) },
1111 commonRefCommit: { commitId: "b".repeat(40) },
1112 },
1113 ],
1114 }),
1115 ),
1116 ),
1117 )
1118 .mockReturnValueOnce(
1119 Effect.succeed(
1120 output(
1121 json({
1122 changeEntries: [
1123 { changeType: "add", item: { path: "/logo.png", objectId: "8f80" } },
1124 ],
1125 }),
1126 ),
1127 ),
1128 )
1129 .mockReturnValueOnce(
1130 Effect.succeed(
1131 output(json({ content: "iVBORw0KGgo=", contentMetadata: { isBinary: true } })),
1132 ),
1133 );
1134 const provider = yield* AzureDevOpsPullRequestProvider.make;
1135
1136 const slice = yield* provider.getDiff({
1137 cwd: "/w",
1138 repository: "web",
1139 host: "dev.azure.com",
1140 number: 42,
1141 });
1142
1143 // Azure leaves the metadata out unless it is asked for, and without it every file reads as
1144 // text however it was stored.
1145 expect(argsOfCall(3)).toContain("includeContentMetadata=true");
1146 expect(slice.patch).toContain("Binary files a/logo.png and b/logo.png differ");
1147 assert.isTrue(slice.truncated);
1148 }),
1149 );
1150
1151 it.effect("stops following pages when one of them does not move the cursor on", () =>
1152 Effect.gen(function* () {
1153 mockedExecute
1154 .mockReturnValueOnce(
1155 Effect.succeed(
1156 output(
1157 json({
1158 pullRequestId: 42,
1159 title: "Add the page",
1160 status: "active",
1161 sourceRefName: "refs/heads/feat/page",
1162 targetRefName: "refs/heads/main",
1163 creationDate: "2026-07-01T00:00:00Z",
1164 url: "https://dev.azure.com/acme/_apis/git/repositories/web/pullRequests/42",
1165 repository: { name: "web", project: { name: "platform" } },
1166 }),
1167 ),
1168 ),
1169 )
1170 .mockReturnValueOnce(
1171 Effect.succeed(
1172 output(
1173 json({
1174 value: [
1175 {
1176 id: 1,
1177 sourceRefCommit: { commitId: "a".repeat(40) },
1178 commonRefCommit: { commitId: "b".repeat(40) },
1179 },
1180 ],
1181 }),
1182 ),
1183 ),
1184 )
1185 .mockReturnValueOnce(
1186 Effect.succeed(
1187 output(
1188 json({
1189 changeEntries: [{ changeType: "edit", item: { path: "/a.ts", objectId: "8f80" } }],
1190 nextSkip: 2_000,
1191 }),
1192 ),
1193 ),
1194 )
1195 .mockReturnValueOnce(
1196 Effect.succeed(
1197 output(
1198 json({
1199 changeEntries: [{ changeType: "edit", item: { path: "/b.ts", objectId: "0ca4" } }],
1200 nextSkip: 2_000,
1201 }),
1202 ),
1203 ),
1204 );
1205 const provider = yield* AzureDevOpsPullRequestProvider.make;
1206 assert.isDefined(provider.getFileRevisions);
1207
1208 const answer = yield* provider.getFileRevisions({
1209 cwd: "/w",
1210 repository: "web",
1211 host: "dev.azure.com",
1212 number: 42,
1213 paths: ["a.ts", "b.ts", "unlisted.ts"],
1214 });
1215
1216 // Four reads and no more: a page pointing at where it already is would be read forever.
1217 assert.strictEqual(mockedExecute.mock.calls.length, 4);
1218 // And what was read is not the whole change, so the file nobody listed is left unanswered
1219 // rather than reported as gone from the change request.
1220 expect([...answer.revisions]).toEqual([
1221 ["a.ts", "8f80"],
1222 ["b.ts", "0ca4"],
1223 ]);
1224 }),
1225 );
1226
1227 it.effect("asks Azure nothing when no file has been ticked off", () =>
1228 Effect.gen(function* () {
1229 mockedExecute.mockReturnValue(Effect.succeed(output('{"changeEntries":[]}')));
1230 const provider = yield* AzureDevOpsPullRequestProvider.make;
1231 assert.isDefined(provider.getFileRevisions);
1232
1233 const answer = yield* provider.getFileRevisions({
1234 cwd: "/w",
1235 repository: "web",
1236 host: "dev.azure.com",
1237 number: 42,
1238 paths: [],
1239 });
1240
1241 expect(answer.revisions.size).toBe(0);
1242 // Nothing was marked, so Azure was not asked at all.
1243 assert.strictEqual(mockedExecute.mock.calls.length, 0);
1244 }),
1245 );
1246
1247 it.effect("reads the conversation through the REST API, pinned to a version", () =>
1248 Effect.gen(function* () {
1249 mockedExecute.mockReturnValueOnce(
1250 Effect.succeed(
1251 output(
1252 JSON.stringify({
1253 value: [
1254 {
1255 id: 1,
1256 comments: [
1257 { id: 1, content: "Looks good.", publishedDate: "2026-07-02T00:00:00Z" },
1258 ],
1259 },
1260 ],
1261 }),
1262 ),
1263 ),
1264 );
1265 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1266
1267 const comments = yield* cli.listThreads({
1268 cwd: "/w",
1269 location: { project: "platform", repository: "web" },
1270 number: 42,
1271 });
1272
1273 assert.strictEqual(comments.length, 1);
1274 // `az devops invoke` rather than `az rest`: it signs in the way the azure-devops extension
1275 // does, and `az rest` mints its own token against whichever tenant `az` defaults to.
1276 expect(argsOfCall(0)).toContain("invoke");
1277 expect(argsOfCall(0)).toContain("pullRequestThreads");
1278 expect(argsOfCall(0)).toContain("project=platform");
1279 expect(argsOfCall(0)).toContain("repositoryId=web");
1280 expect(argsOfCall(0)).toContain("pullRequestId=42");
1281 // A review's threads grow with how long it ran, and this route does not page, so the read
1282 // asks for more than the process default rather than taking whatever it is given.
1283 expect(maxOutputBytesOfCall(0)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1284 }),
1285 );
1286
1287 it.effect("reads a long review's threads, which are past the default output limit", () =>
1288 Effect.gen(function* () {
1289 const response = json({ value: threadRows(800) });
1290 // Azure opens a thread per vote and per ref update beside the ones people wrote, and every
1291 // comment carries a full identity, so a review argued over for weeks outgrows the default.
1292 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1293 mockedExecute.mockImplementationOnce((input) =>
1294 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1295 );
1296 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1297
1298 const comments = yield* cli.listThreads({
1299 cwd: "/w",
1300 location: { project: "platform", repository: "web" },
1301 number: 42,
1302 });
1303
1304 assert.strictEqual(comments.length, 800);
1305 }),
1306 );
1307
1308 it.effect("reads a long review's iterations, which are past the default output limit", () =>
1309 Effect.gen(function* () {
1310 const response = json({ value: iterationRows(1_200) });
1311 // This route does not page, so the whole history arrives at once. Cut at the default it is
1312 // JSON stopping mid-string, and every diff and file revision read on this host fails with
1313 // it, since each of them starts by asking which iteration is the latest.
1314 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1315 mockedExecute.mockImplementationOnce((input) =>
1316 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1317 );
1318 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1319
1320 const iterations = yield* cli.listIterations({
1321 cwd: "/w",
1322 location: { project: "platform", repository: "web" },
1323 number: 42,
1324 });
1325
1326 assert.strictEqual(iterations.length, 1_200);
1327 assert.strictEqual(iterations.at(-1)?.id, 1_200);
1328 }),
1329 );
1330
1331 it.effect("reads a full page of change entries, which is past the default output limit", () =>
1332 Effect.gen(function* () {
1333 const response = json({ changeEntries: changeEntries(2_000) });
1334 // Azure's own maximum for this route, and every entry carries a path, a url and three
1335 // object ids, so an ordinary page of a large change already outgrows the default.
1336 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1337 mockedExecute.mockImplementationOnce((input) =>
1338 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1339 );
1340 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1341
1342 const page = yield* cli.listIterationChanges({
1343 cwd: "/w",
1344 location: { project: "platform", repository: "web" },
1345 number: 42,
1346 iterationId: 1,
1347 });
1348
1349 // Cut at the default this would arrive as JSON stopping mid-string, and a perfectly
1350 // ordinary page would be reported as a host answering with nonsense.
1351 assert.strictEqual(page.changes.length, 2_000);
1352 assert.isFalse(page.truncated);
1353 }),
1354 );
1355
1356 it.effect("reads a file whose JSON envelope is past the default output limit", () =>
1357 Effect.gen(function* () {
1358 // Under a megabyte as bytes on the host, so this is a file the other hosts hand over.
1359 const file = "const value = 1;\n".repeat(57_000);
1360 const response = json({ content: file });
1361 expect(Buffer.byteLength(file)).toBeLessThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1362 // And past it once Azure wraps it, because there is no route here that serves the bytes.
1363 expect(Buffer.byteLength(response)).toBeGreaterThan(VCS_DEFAULT_MAX_OUTPUT_BYTES);
1364 mockedExecute.mockImplementationOnce((input) =>
1365 Effect.succeed(outputWithin(input.maxOutputBytes, response)),
1366 );
1367 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1368
1369 const item = yield* cli.readItemContent({
1370 cwd: "/w",
1371 location: { project: "platform", repository: "web" },
1372 path: "src/generated/schema.ts",
1373 commit: "a".repeat(40),
1374 });
1375
1376 assert.strictEqual(item.contents, file);
1377 assert.isFalse(item.isBinary);
1378 }),
1379 );
1380
1381 it.effect("asks for a file by Azure's own spelling of its path", () =>
1382 Effect.gen(function* () {
1383 mockedExecute.mockReturnValueOnce(Effect.succeed(output(json({ content: "const a = 1;" }))));
1384 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1385
1386 // The path carried around here has had Azure's leading slash taken off so it matches the
1387 // patch and the viewed mark. The items route is documented in Azure's spelling, so it goes
1388 // back on the way out rather than being sent as the shorter name.
1389 yield* cli.readItemContent({
1390 cwd: "/w",
1391 location: { project: "platform", repository: "web" },
1392 path: "src/app.ts",
1393 commit: "a".repeat(40),
1394 });
1395
1396 expect(argsOfCall(0)).toContain("path=/src/app.ts");
1397 }),
1398 );
1399
1400 it.effect("reports a pull request it cannot place as its own outcome", () =>
1401 Effect.gen(function* () {
1402 mockedExecute.mockReturnValueOnce(
1403 Effect.succeed(
1404 output(
1405 // Well-formed, but with nothing to build a link from: not a decode failure.
1406 JSON.stringify({
1407 pullRequestId: 42,
1408 title: "Add the page",
1409 sourceRefName: "refs/heads/feat/page",
1410 targetRefName: "refs/heads/main",
1411 creationDate: "2026-07-01T00:00:00Z",
1412 }),
1413 ),
1414 ),
1415 );
1416 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1417
1418 const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 }));
1419
1420 assert.strictEqual(error._tag, "AzureDevOpsPullRequestIncompleteError");
1421 }),
1422 );
1423
1424 it.effect("fails the read when az returns something unreadable", () =>
1425 Effect.gen(function* () {
1426 mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}')));
1427 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1428
1429 const error = yield* Effect.flip(cli.getPullRequest({ cwd: "/w", number: 42 }));
1430
1431 assert.strictEqual(error._tag, "AzureDevOpsPullRequestReadError");
1432 }),
1433 );
1434
1435 it.effect("adds reviewers with the one command Azure has for it", () =>
1436 Effect.gen(function* () {
1437 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1438 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1439
1440 yield* cli.setPullRequestReviewers({
1441 cwd: "/w",
1442 number: 42,
1443 reviewers: ["octocat@acme.test", "hubot@acme.test"],
1444 requested: true,
1445 });
1446
1447 expect(argsOfCall(0)).toEqual([
1448 "repos",
1449 "pr",
1450 "reviewer",
1451 "add",
1452 "--detect",
1453 "true",
1454 "--id",
1455 "42",
1456 "--reviewers",
1457 "octocat@acme.test",
1458 "hubot@acme.test",
1459 "--only-show-errors",
1460 "--output",
1461 "json",
1462 ]);
1463 }),
1464 );
1465
1466 it.effect("takes a reviewer off the pull request with the same command's counterpart", () =>
1467 Effect.gen(function* () {
1468 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
1469 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1470
1471 yield* cli.setPullRequestReviewers({
1472 cwd: "/w",
1473 number: 42,
1474 reviewers: ["octocat@acme.test"],
1475 requested: false,
1476 });
1477
1478 expect(argsOfCall(0)).toContain("remove");
1479 }),
1480 );
1481
1482 it.effect("refuses a reviewer az would read as a flag, before running anything", () =>
1483 Effect.gen(function* () {
1484 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
1485
1486 const error = yield* Effect.flip(
1487 cli.setPullRequestReviewers({
1488 cwd: "/w",
1489 number: 42,
1490 reviewers: ["--query"],
1491 requested: true,
1492 }),
1493 );
1494
1495 assert.strictEqual(error._tag, "AzureDevOpsReviewerNameError");
1496 assert.strictEqual(mockedExecute.mock.calls.length, 0);
1497 }),
1498 );
1499});