1import * as Result from "effect/Result";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 decodeItemContentJson,
6 decodeIterationChangesJson,
7 decodeIterationsJson,
8 decodePullRequestJson,
9 decodePullRequestListJson,
10 decodeThreadsJson,
11 decodeViewerJson,
12} from "./azureDevOpsPullRequestJson.ts";
13
14const REST_URL =
15 "https://dev.azure.com/acme/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/pullRequests/42";
16
17/** Shaped after Azure's `GitPullRequest`, trimmed to the fields that are read. */
18function pullRequest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
19 return {
20 pullRequestId: 42,
21 title: "Add the change requests page",
22 description: "Ships the page.",
23 status: "active",
24 isDraft: false,
25 mergeStatus: "succeeded",
26 createdBy: { displayName: "Bilal Hassan", uniqueName: "bilal@acme.dev" },
27 sourceRefName: "refs/heads/feat/page",
28 targetRefName: "refs/heads/main",
29 creationDate: "2026-07-01T00:00:00Z",
30 url: REST_URL,
31 repository: { name: "web", project: { name: "platform" } },
32 ...overrides,
33 };
34}
35
36function expectSuccess<A>(result: Result.Result<A, unknown>): A {
37 expect(Result.isSuccess(result)).toBe(true);
38 if (!Result.isSuccess(result)) throw new Error("expected a successful decode");
39 return result.success;
40}
41
42const asJson = (value: unknown) => JSON.stringify(value);
43
44describe("decodePullRequestListJson", () => {
45 it("reads a pull request as a change request", () => {
46 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()])));
47
48 expect(batch.items).toHaveLength(1);
49 expect(batch.items[0]).toMatchObject({
50 number: 42,
51 title: "Add the change requests page",
52 // The login is an email, because that is what `az account show` reports to compare with.
53 author: { login: "bilal@acme.dev", name: "Bilal Hassan" },
54 // Azure prefixes its refs, which no other host does.
55 headBranch: "feat/page",
56 baseBranch: "main",
57 state: "open",
58 isDraft: false,
59 mergeability: "mergeable",
60 });
61 });
62
63 it("assembles a browser url when Azure reports no web link", () => {
64 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()])));
65
66 expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42");
67 });
68
69 it("prefers the web link Azure sends when asked for one", () => {
70 const batch = expectSuccess(
71 decodePullRequestListJson(
72 asJson([
73 pullRequest({
74 _links: {
75 web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" },
76 },
77 }),
78 ]),
79 ),
80 );
81
82 expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42");
83 });
84
85 it.each([
86 ["active", "open"],
87 ["completed", "merged"],
88 ["abandoned", "closed"],
89 ["something new", "open"],
90 ])("reads the %s status as %s", (status, expected) => {
91 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ status })])));
92
93 expect(batch.items[0]?.state).toBe(expected);
94 });
95
96 it.each([
97 ["succeeded", "mergeable"],
98 ["conflicts", "conflicting"],
99 ["rejectedByPolicy", "conflicting"],
100 ["queued", "unknown"],
101 ["notSet", "unknown"],
102 ])("reads the %s merge status as %s", (mergeStatus, expected) => {
103 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ mergeStatus })])));
104
105 expect(batch.items[0]?.mergeability).toBe(expected);
106 });
107
108 it("stands the closing time in for a last-touched time Azure does not keep", () => {
109 const batch = expectSuccess(
110 decodePullRequestListJson(
111 asJson([pullRequest({ status: "completed", closedDate: "2026-07-05T00:00:00Z" })]),
112 ),
113 );
114
115 expect(batch.items[0]).toMatchObject({
116 createdAt: "2026-07-01T00:00:00Z",
117 updatedAt: "2026-07-05T00:00:00Z",
118 });
119 });
120
121 it("skips a malformed row but still counts it, so paging does not stop early", () => {
122 const batch = expectSuccess(
123 decodePullRequestListJson(asJson([{ pullRequestId: "nope" }, pullRequest()])),
124 );
125
126 expect(batch.items).toHaveLength(1);
127 expect(batch.rawCount).toBe(2);
128 expect(batch.rawIndexes).toEqual([1]);
129 });
130});
131
132describe("decodePullRequestJson", () => {
133 it("reads reviewers as review requests", () => {
134 const detail = expectSuccess(
135 decodePullRequestJson(
136 asJson(
137 pullRequest({
138 reviewers: [{ displayName: "Julius", uniqueName: "julius@acme.dev", vote: 10 }],
139 }),
140 ),
141 ),
142 );
143
144 expect(detail?.reviewRequestLogins).toEqual(["julius@acme.dev"]);
145 expect(detail?.reviewers).toEqual([
146 { login: "julius@acme.dev", name: "Julius", avatarUrl: null },
147 ]);
148 });
149
150 it("reads auto-complete from whoever armed it, and its absence as nobody", () => {
151 const armed = expectSuccess(
152 decodePullRequestJson(
153 asJson(pullRequest({ autoCompleteSetBy: { displayName: "Bilal Hassan" } })),
154 ),
155 );
156 expect(armed?.autoMergeEnabled).toBe(true);
157
158 // Azure leaves the field out entirely rather than sending it empty, so its absence is the
159 // whole of what it says about auto-complete being off.
160 expect(expectSuccess(decodePullRequestJson(asJson(pullRequest())))?.autoMergeEnabled).toBe(
161 false,
162 );
163 });
164
165 it("keeps the strategy stored with auto-complete", () => {
166 const armed = expectSuccess(
167 decodePullRequestJson(
168 asJson(
169 pullRequest({
170 autoCompleteSetBy: { displayName: "Bilal Hassan" },
171 completionOptions: { mergeStrategy: "squash" },
172 }),
173 ),
174 ),
175 );
176
177 expect(armed).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" });
178
179 const unspecified = expectSuccess(
180 decodePullRequestJson(
181 asJson(
182 pullRequest({
183 autoCompleteSetBy: { displayName: "Bilal Hassan" },
184 completionOptions: { squashMerge: false },
185 }),
186 ),
187 ),
188 );
189 expect(unspecified?.autoMergeEnabled).toBe(true);
190 expect(unspecified?.autoMergeMethod).toBeUndefined();
191 });
192
193 it("works out where the repository lives from what Azure returned", () => {
194 const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest())));
195
196 expect(detail?.location).toEqual({ project: "platform", repository: "web" });
197 });
198
199 it("reports no repository location when Azure said too little to name one", () => {
200 // A web link places the pull request, but with no repository named there is nothing to
201 // address the routes that read its files and its conversation.
202 const detail = expectSuccess(
203 decodePullRequestJson(
204 asJson(
205 pullRequest({
206 url: null,
207 repository: null,
208 _links: {
209 web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" },
210 },
211 }),
212 ),
213 ),
214 );
215
216 expect(detail?.location).toBeNull();
217 });
218
219 it("returns nothing when Azure gave no way to place the pull request at all", () => {
220 const detail = expectSuccess(
221 decodePullRequestJson(asJson(pullRequest({ url: null, repository: null }))),
222 );
223
224 expect(detail).toBeNull();
225 });
226});
227
228describe("decodeViewerJson", () => {
229 it("reads the signed-in account name", () => {
230 expect(expectSuccess(decodeViewerJson(asJson({ user: { name: "bilal@acme.dev" } })))).toBe(
231 "bilal@acme.dev",
232 );
233 });
234
235 it("returns nothing when nobody is signed in", () => {
236 expect(expectSuccess(decodeViewerJson(asJson({ user: null })))).toBeNull();
237 });
238});
239
240describe("decodeThreadsJson", () => {
241 it("takes every real comment of every thread, oldest first", () => {
242 const comments = expectSuccess(
243 decodeThreadsJson(
244 asJson({
245 value: [
246 {
247 id: 2,
248 comments: [
249 {
250 id: 1,
251 content: "Second remark.",
252 author: { displayName: "Julius", uniqueName: "julius@acme.dev" },
253 publishedDate: "2026-07-03T00:00:00Z",
254 },
255 ],
256 },
257 {
258 id: 1,
259 comments: [
260 // Azure's own activity notes are events rather than remarks.
261 { id: 1, content: "Bilal voted", commentType: "system", publishedDate: "x" },
262 {
263 id: 2,
264 content: "First remark.",
265 author: { displayName: "Bilal", uniqueName: "bilal@acme.dev" },
266 publishedDate: "2026-07-02T00:00:00Z",
267 },
268 ],
269 },
270 ],
271 }),
272 ),
273 );
274
275 expect(comments.map((comment) => comment.body)).toEqual(["First remark.", "Second remark."]);
276 expect(comments[0]).toMatchObject({
277 kind: "issue-comment",
278 author: { login: "bilal@acme.dev" },
279 });
280 });
281
282 it("reads a thread pinned to a file as a review comment", () => {
283 const comments = expectSuccess(
284 decodeThreadsJson(
285 asJson({
286 value: [
287 {
288 id: 3,
289 threadContext: { filePath: "/src/app.ts" },
290 comments: [{ id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }],
291 },
292 ],
293 }),
294 ),
295 );
296
297 expect(comments[0]).toMatchObject({ kind: "review-comment", path: "/src/app.ts" });
298 });
299
300 it("keeps the replies under a thread, which are as much of the conversation", () => {
301 const comments = expectSuccess(
302 decodeThreadsJson(
303 asJson({
304 value: [
305 {
306 id: 4,
307 threadContext: { filePath: "/src/app.ts" },
308 comments: [
309 { id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" },
310 { id: 2, content: "Renamed.", publishedDate: "2026-07-02T01:00:00Z" },
311 { id: 3, content: "Thanks.", publishedDate: "2026-07-02T02:00:00Z" },
312 ],
313 },
314 ],
315 }),
316 ),
317 );
318
319 expect(comments.map((comment) => comment.id)).toEqual(["4:1", "4:2", "4:3"]);
320 });
321
322 it("drops deleted threads and threads with nothing to show", () => {
323 const comments = expectSuccess(
324 decodeThreadsJson(
325 asJson({
326 value: [
327 {
328 id: 1,
329 isDeleted: true,
330 comments: [{ id: 1, content: "gone", publishedDate: "2026-07-02T00:00:00Z" }],
331 },
332 { id: 2, comments: [] },
333 {
334 id: 3,
335 comments: [{ id: 1, content: " ", publishedDate: "2026-07-02T00:00:00Z" }],
336 },
337 ],
338 }),
339 ),
340 );
341
342 expect(comments).toEqual([]);
343 });
344});
345
346describe("decodeIterationsJson", () => {
347 const iteration = (id: number, head: string, base: string) => ({
348 id,
349 sourceRefCommit: { commitId: head },
350 commonRefCommit: { commitId: base },
351 targetRefCommit: { commitId: base },
352 });
353
354 it("reads every push in order, oldest first", () => {
355 const iterations = expectSuccess(
356 decodeIterationsJson(
357 asJson({ value: [iteration(2, "bbb", "base"), iteration(1, "aaa", "base")] }),
358 ),
359 );
360
361 expect(iterations.map((entry) => entry.id)).toEqual([1, 2]);
362 expect(iterations.at(-1)).toEqual({ id: 2, headCommit: "bbb", mergeBaseCommit: "base" });
363 });
364
365 it("skips a push Azure could not place both ends of", () => {
366 // A patch is taken over a range, and an iteration missing either end names no range at all.
367 const iterations = expectSuccess(
368 decodeIterationsJson(
369 asJson({
370 value: [{ id: 1, sourceRefCommit: { commitId: "aaa" } }, iteration(2, "bbb", "base")],
371 }),
372 ),
373 );
374
375 expect(iterations.map((entry) => entry.id)).toEqual([2]);
376 });
377});
378
379describe("decodeIterationChangesJson", () => {
380 it("names each changed file without the slash Azure leads its paths with", () => {
381 const page = expectSuccess(
382 decodeIterationChangesJson(
383 asJson({
384 changeEntries: [
385 { changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } },
386 {
387 changeType: "edit",
388 item: { path: "/README.md", objectId: "8f80", originalObjectId: "0ca4" },
389 },
390 { changeType: "delete", item: { path: "/OLD.md", originalObjectId: "1111" } },
391 ],
392 }),
393 ),
394 );
395
396 expect(page.changes.map((change) => [change.path, change.changeKind])).toEqual([
397 ["DEMO.md", "new"],
398 ["README.md", "change"],
399 ["OLD.md", "deleted"],
400 ]);
401 });
402
403 it("keeps a space at the end of a file's name, which belongs to the name", () => {
404 // Git will carry a name that ends in a space, and the patch and the viewed mark are both
405 // keyed by it. Tidying it here files the change under a name nothing else uses.
406 const page = expectSuccess(
407 decodeIterationChangesJson(
408 asJson({
409 changeEntries: [
410 { changeType: "edit", item: { path: "/docs/readme.md ", objectId: "ec00" } },
411 {
412 changeType: "rename",
413 sourceServerItem: "/docs/old.md ",
414 item: { path: "/docs/moved.md", objectId: "aaaa", originalObjectId: "aaaa" },
415 },
416 ],
417 }),
418 ),
419 );
420
421 expect(page.changes.map((change) => [change.path, change.oldPath])).toEqual([
422 ["docs/readme.md ", "docs/readme.md "],
423 ["docs/moved.md", "docs/old.md "],
424 ]);
425 });
426
427 it("reads a rename as one file that moved, and says whether it also changed", () => {
428 const page = expectSuccess(
429 decodeIterationChangesJson(
430 asJson({
431 changeEntries: [
432 {
433 changeType: "rename",
434 sourceServerItem: "/docs/old.md",
435 item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" },
436 },
437 {
438 changeType: "edit, rename",
439 sourceServerItem: "/src/old.ts",
440 item: { path: "/src/new.ts", objectId: "bbbb", originalObjectId: "cccc" },
441 },
442 ],
443 }),
444 ),
445 );
446
447 expect(page.changes).toEqual([
448 {
449 path: "docs/new.md",
450 oldPath: "docs/old.md",
451 changeKind: "rename-pure",
452 objectId: "aaaa",
453 originalObjectId: "aaaa",
454 },
455 {
456 path: "src/new.ts",
457 oldPath: "src/old.ts",
458 changeKind: "rename-changed",
459 objectId: "bbbb",
460 originalObjectId: "cccc",
461 },
462 ]);
463 });
464
465 it("drops the folders Azure lists alongside the files that changed", () => {
466 // A review shows files, and a folder has no content on either side to show for one.
467 const page = expectSuccess(
468 decodeIterationChangesJson(
469 asJson({
470 changeEntries: [
471 { changeType: "add", item: { path: "/docs", isFolder: true, gitObjectType: "tree" } },
472 { changeType: "add", item: { path: "/docs/page.md", objectId: "dddd" } },
473 ],
474 }),
475 ),
476 );
477
478 expect(page.changes.map((change) => change.path)).toEqual(["docs/page.md"]);
479 });
480
481 it("carries where the next page of a long change starts", () => {
482 const page = expectSuccess(
483 decodeIterationChangesJson(
484 asJson({
485 changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }],
486 nextSkip: 2000,
487 }),
488 ),
489 );
490
491 expect(page.nextSkip).toBe(2000);
492 });
493
494 it("reads the last page, which names no page after it, as the end of the change", () => {
495 const page = expectSuccess(
496 decodeIterationChangesJson(
497 asJson({
498 changeEntries: [{ changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } }],
499 }),
500 ),
501 );
502
503 expect(page.nextSkip).toBeNull();
504 });
505
506 it("reads where a rename came from out of either of the two places Azure names it", () => {
507 // The iteration-changes route answers with `originalPath`; the commit routes answer with
508 // `sourceServerItem`, and both are the same fact under two names.
509 const page = expectSuccess(
510 decodeIterationChangesJson(
511 asJson({
512 changeEntries: [
513 {
514 changeType: "rename",
515 originalPath: "/docs/old.md",
516 item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" },
517 },
518 ],
519 }),
520 ),
521 );
522
523 expect(page.changes.at(0)?.oldPath).toBe("docs/old.md");
524 });
525});
526
527describe("decodeItemContentJson", () => {
528 it("reads the file's text out of the envelope Azure wraps it in", () => {
529 expect(
530 expectSuccess(decodeItemContentJson(asJson({ path: "/a.md", content: "one\ntwo" }))),
531 ).toEqual({ contents: "one\ntwo", isBinary: false });
532 });
533
534 it("reads an empty file as empty rather than as a failure to look", () => {
535 expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toEqual({
536 contents: "",
537 isBinary: false,
538 });
539 });
540
541 it("keeps Azure's own word that a file is binary", () => {
542 // Which it answers base64-encoded, so nothing in the text it sent would give it away.
543 expect(
544 expectSuccess(
545 decodeItemContentJson(
546 asJson({ path: "/logo.png", content: "b2xk", contentMetadata: { isBinary: true } }),
547 ),
548 ),
549 ).toEqual({ contents: "b2xk", isBinary: true });
550 });
551});
552
553/**
554 * Every other fixture in this file is written from Azure's published contract. These are the
555 * shapes a real organisation answered with, read back through `az devops invoke` rather than
556 * `az rest`: the extension hands over the route's own body with one key of its own added, and a
557 * live repository leaves out fields the contract documents.
558 */
559describe("what az devops invoke answers with", () => {
560 it("reads the envelope the extension adds its own continuation token to", () => {
561 // Set on every JSON body it returns, from a response header these routes do not send, so it
562 // arrives as null rather than not at all. Nothing reads it, and it must not fail the decode.
563 expect(
564 expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))),
565 ).toEqual([]);
566
567 expect(
568 expectSuccess(
569 decodeIterationsJson(
570 asJson({
571 count: 1,
572 continuation_token: null,
573 value: [
574 {
575 id: 1,
576 sourceRefCommit: { commitId: "f4031105213c68f197cd46ea303bb5e72acc889a" },
577 commonRefCommit: { commitId: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35" },
578 },
579 ],
580 }),
581 ),
582 ),
583 ).toEqual([
584 {
585 id: 1,
586 headCommit: "f4031105213c68f197cd46ea303bb5e72acc889a",
587 mergeBaseCommit: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35",
588 },
589 ]);
590 });
591
592 it("keeps the files of a change that names neither their object type nor their page after it", () => {
593 // A live iteration states `changeType`, `item.path` and `item.objectId` and nothing else: no
594 // `gitObjectType`, no `isFolder`, and no `nextSkip` on the only page. Each of those absences
595 // is what the defaults in the decoder are for, and reading any of them as stated would drop
596 // every file of every Azure change request.
597 const page = expectSuccess(
598 decodeIterationChangesJson(
599 asJson({
600 continuation_token: null,
601 changeEntries: [
602 { changeType: "add", item: { path: "/DEMO.md", objectId: "EC005DB24" } },
603 { changeType: "edit", item: { path: "/README.md", objectId: "8F8047A49" } },
604 ],
605 }),
606 ),
607 );
608
609 expect(page.nextSkip).toBeNull();
610 expect(page.changes).toEqual([
611 {
612 path: "DEMO.md",
613 oldPath: "DEMO.md",
614 changeKind: "new",
615 objectId: "EC005DB24",
616 originalObjectId: null,
617 },
618 {
619 path: "README.md",
620 oldPath: "README.md",
621 changeKind: "change",
622 objectId: "8F8047A49",
623 originalObjectId: null,
624 },
625 ]);
626 });
627
628 it("reads a text file whose content type Azure calls a stream", () => {
629 // `contentMetadata` comes back for a markdown file with `application/octet-stream` on it and
630 // no `isBinary` at all, so the content type is not the field to ask, and its absence is the
631 // answer that the text is text.
632 expect(
633 expectSuccess(
634 decodeItemContentJson(
635 asJson({
636 path: "/README.md",
637 objectId: "8f8047a49",
638 gitObjectType: "blob",
639 content: "# T3Demo\n",
640 contentMetadata: {
641 contentType: "application/octet-stream",
642 encoding: 65001,
643 extension: "md",
644 fileName: "README.md",
645 },
646 continuation_token: null,
647 }),
648 ),
649 ),
650 ).toEqual({ contents: "# T3Demo\n", isBinary: false });
651 });
652});