Skip to content
Source
Sign inSign up

azureDevOpsPullRequestJson.test.ts

518 lines15.3 KB

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} from "./azureDevOpsPullRequestJson.ts";
12​
13const REST_URL =
14 "https://dev.azure.com/acme/_apis/git/repositories/6f9c9b7f-0000-0000-0000-000000000000/pullRequests/42";
15​
16function pullRequest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
17 return {
18 pullRequestId: 42,
19 title: "Add the change requests page",
20 description: "Ships the page.",
21 status: "active",
22 isDraft: false,
23 mergeStatus: "succeeded",
24 createdBy: { displayName: "Bilal Hassan", uniqueName: "bilal@acme.dev" },
25 sourceRefName: "refs/heads/feat/page",
26 targetRefName: "refs/heads/main",
27 creationDate: "2026-07-01T00:00:00Z",
28 url: REST_URL,
29 repository: { name: "web", project: { name: "platform" } },
30 ...overrides,
31 };
33​
34function expectSuccess<A>(result: Result.Result<A, unknown>): A {
35 expect(Result.isSuccess(result)).toBe(true);
36 if (!Result.isSuccess(result)) throw new Error("expected a successful decode");
37 return result.success;
39​
40const asJson = (value: unknown) => JSON.stringify(value);
41​
42describe("decodePullRequestListJson", () => {
43 it("reads a pull request as a change request", () => {
44 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()])));
45​
46 expect(batch.items).toHaveLength(1);
47 expect(batch.items[0]).toMatchObject({
48 number: 42,
49 title: "Add the change requests page",
50 author: { login: "bilal@acme.dev", name: "Bilal Hassan" },
51 headBranch: "feat/page",
52 baseBranch: "main",
53 state: "open",
54 isDraft: false,
55 mergeability: "mergeable",
56 });
57 });
58​
59 it("assembles a browser url when Azure reports no web link", () => {
60 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest()])));
61​
62 expect(batch.items[0]?.url).toBe("https://dev.azure.com/acme/platform/_git/web/pullrequest/42");
63 });
64​
65 it.each([
66 ["completed", "merged"],
67 ["something new", "open"],
68 ])("reads the %s status as %s", (status, expected) => {
69 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ status })])));
70​
71 expect(batch.items[0]?.state).toBe(expected);
72 });
73​
74 it.each([
75 ["succeeded", "mergeable"],
76 ["conflicts", "conflicting"],
77 ["queued", "unknown"],
78 ])("reads the %s merge status as %s", (mergeStatus, expected) => {
79 const batch = expectSuccess(decodePullRequestListJson(asJson([pullRequest({ mergeStatus })])));
80​
81 expect(batch.items[0]?.mergeability).toBe(expected);
82 });
83​
84 it("stands the closing time in for a last-touched time Azure does not keep", () => {
85 const batch = expectSuccess(
86 decodePullRequestListJson(
87 asJson([pullRequest({ status: "completed", closedDate: "2026-07-05T00:00:00Z" })]),
88 ),
89 );
90​
91 expect(batch.items[0]).toMatchObject({
92 createdAt: "2026-07-01T00:00:00Z",
93 updatedAt: "2026-07-05T00:00:00Z",
94 });
95 });
96​
97 it("skips a malformed row but still counts it, so paging does not stop early", () => {
98 const batch = expectSuccess(
99 decodePullRequestListJson(asJson([{ pullRequestId: "nope" }, pullRequest()])),
100 );
101​
102 expect(batch.items).toHaveLength(1);
103 expect(batch.rawCount).toBe(2);
104 expect(batch.rawIndexes).toEqual([1]);
105 });
106});
107​
108describe("decodePullRequestJson", () => {
109 it("keeps the strategy stored with auto-complete", () => {
110 const armed = expectSuccess(
111 decodePullRequestJson(
112 asJson(
113 pullRequest({
114 autoCompleteSetBy: { displayName: "Bilal Hassan" },
115 completionOptions: { mergeStrategy: "squash" },
116 }),
117 ),
118 ),
119 );
120​
121 expect(armed).toMatchObject({ autoMergeEnabled: true, autoMergeMethod: "squash" });
122​
123 const unspecified = expectSuccess(
124 decodePullRequestJson(
125 asJson(
126 pullRequest({
127 autoCompleteSetBy: { displayName: "Bilal Hassan" },
128 completionOptions: { squashMerge: false },
129 }),
130 ),
131 ),
132 );
133 expect(unspecified?.autoMergeEnabled).toBe(true);
134 expect(unspecified?.autoMergeMethod).toBeUndefined();
135 });
136​
137 it("works out where the repository lives from what Azure returned", () => {
138 const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest())));
139​
140 expect(detail?.location).toEqual({ project: "platform", repository: "web" });
141 });
142​
143 it("reports no repository location when Azure said too little to name one", () => {
144 const detail = expectSuccess(
145 decodePullRequestJson(
146 asJson(
147 pullRequest({
148 url: null,
149 repository: null,
150 _links: {
151 web: { href: "https://dev.azure.com/acme/platform/_git/web/pullrequest/42" },
152 },
153 }),
154 ),
155 ),
156 );
157​
158 expect(detail?.location).toBeNull();
159 });
160​
161 it("returns nothing when Azure gave no way to place the pull request at all", () => {
162 const detail = expectSuccess(
163 decodePullRequestJson(asJson(pullRequest({ url: null, repository: null }))),
164 );
165​
166 expect(detail).toBeNull();
167 });
168});
169​
170describe("decodeThreadsJson", () => {
171 it("takes every real comment of every thread, oldest first", () => {
172 const comments = expectSuccess(
173 decodeThreadsJson(
174 asJson({
175 value: [
176 {
177 id: 2,
178 comments: [
179 {
180 id: 1,
181 content: "Second remark.",
182 author: { displayName: "Julius", uniqueName: "julius@acme.dev" },
183 publishedDate: "2026-07-03T00:00:00Z",
184 },
185 ],
186 },
187 {
188 id: 1,
189 comments: [
190 { id: 1, content: "Bilal voted", commentType: "system", publishedDate: "x" },
191 {
192 id: 2,
193 content: "First remark.",
194 author: { displayName: "Bilal", uniqueName: "bilal@acme.dev" },
195 publishedDate: "2026-07-02T00:00:00Z",
196 },
197 ],
198 },
199 ],
200 }),
201 ),
202 );
203​
204 expect(comments.map((comment) => comment.body)).toEqual(["First remark.", "Second remark."]);
205 expect(comments[0]).toMatchObject({
206 kind: "issue-comment",
207 author: { login: "bilal@acme.dev" },
208 });
209 });
210​
211 it("reads a thread pinned to a file as a review comment on the path its diff uses", () => {
212 const comments = expectSuccess(
213 decodeThreadsJson(
214 asJson({
215 value: [
216 {
217 id: 3,
218 threadContext: { filePath: "/src/app.ts" },
219 comments: [{ id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" }],
220 },
221 ],
222 }),
223 ),
224 );
225​
226 expect(comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" });
227 });
228​
229 it("keeps the replies under a thread, which are as much of the conversation", () => {
230 const comments = expectSuccess(
231 decodeThreadsJson(
232 asJson({
233 value: [
234 {
235 id: 4,
236 threadContext: { filePath: "/src/app.ts" },
237 comments: [
238 { id: 1, content: "Rename this.", publishedDate: "2026-07-02T00:00:00Z" },
239 { id: 2, content: "Renamed.", publishedDate: "2026-07-02T01:00:00Z" },
240 { id: 3, content: "Thanks.", publishedDate: "2026-07-02T02:00:00Z" },
241 ],
242 },
243 ],
244 }),
245 ),
246 );
247​
248 expect(comments.map((comment) => comment.id)).toEqual(["4:1", "4:2", "4:3"]);
249 });
250​
251 it("drops deleted threads and threads with nothing to show", () => {
252 const comments = expectSuccess(
253 decodeThreadsJson(
254 asJson({
255 value: [
256 {
257 id: 1,
258 isDeleted: true,
259 comments: [{ id: 1, content: "gone", publishedDate: "2026-07-02T00:00:00Z" }],
260 },
261 { id: 2, comments: [] },
262 {
263 id: 3,
264 comments: [{ id: 1, content: " ", publishedDate: "2026-07-02T00:00:00Z" }],
265 },
266 ],
267 }),
268 ),
269 );
270​
271 expect(comments).toEqual([]);
272 });
273});
274​
275describe("decodeIterationsJson", () => {
276 const iteration = (id: number, head: string, base: string) => ({
277 id,
278 sourceRefCommit: { commitId: head },
279 commonRefCommit: { commitId: base },
280 targetRefCommit: { commitId: base },
281 });
282​
283 it("reads every push in order, oldest first", () => {
284 const iterations = expectSuccess(
285 decodeIterationsJson(
286 asJson({ value: [iteration(2, "bbb", "base"), iteration(1, "aaa", "base")] }),
287 ),
288 );
289​
290 expect(iterations.map((entry) => entry.id)).toEqual([1, 2]);
291 expect(iterations.at(-1)).toEqual({ id: 2, headCommit: "bbb", mergeBaseCommit: "base" });
292 });
293​
294 it("skips a push Azure could not place both ends of", () => {
295 const iterations = expectSuccess(
296 decodeIterationsJson(
297 asJson({
298 value: [{ id: 1, sourceRefCommit: { commitId: "aaa" } }, iteration(2, "bbb", "base")],
299 }),
300 ),
301 );
302​
303 expect(iterations.map((entry) => entry.id)).toEqual([2]);
304 });
305});
306​
307describe("decodeIterationChangesJson", () => {
308 it("names each changed file without the slash Azure leads its paths with", () => {
309 const page = expectSuccess(
310 decodeIterationChangesJson(
311 asJson({
312 changeEntries: [
313 { changeType: "add", item: { path: "/DEMO.md", objectId: "ec00" } },
314 {
315 changeType: "edit",
316 item: { path: "/README.md", objectId: "8f80", originalObjectId: "0ca4" },
317 },
318 { changeType: "delete", item: { path: "/OLD.md", originalObjectId: "1111" } },
319 ],
320 }),
321 ),
322 );
323​
324 expect(page.changes.map((change) => [change.path, change.changeKind])).toEqual([
325 ["DEMO.md", "new"],
326 ["README.md", "change"],
327 ["OLD.md", "deleted"],
328 ]);
329 });
330​
331 it("keeps a space at the end of a file's name, which belongs to the name", () => {
332 const page = expectSuccess(
333 decodeIterationChangesJson(
334 asJson({
335 changeEntries: [
336 { changeType: "edit", item: { path: "/docs/readme.md ", objectId: "ec00" } },
337 {
338 changeType: "rename",
339 sourceServerItem: "/docs/old.md ",
340 item: { path: "/docs/moved.md", objectId: "aaaa", originalObjectId: "aaaa" },
341 },
342 ],
343 }),
344 ),
345 );
346​
347 expect(page.changes.map((change) => [change.path, change.oldPath])).toEqual([
348 ["docs/readme.md ", "docs/readme.md "],
349 ["docs/moved.md", "docs/old.md "],
350 ]);
351 });
352​
353 it("reads a rename as one file that moved, and says whether it also changed", () => {
354 const page = expectSuccess(
355 decodeIterationChangesJson(
356 asJson({
357 changeEntries: [
358 {
359 changeType: "rename",
360 sourceServerItem: "/docs/old.md",
361 item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" },
362 },
363 {
364 changeType: "edit, rename",
365 sourceServerItem: "/src/old.ts",
366 item: { path: "/src/new.ts", objectId: "bbbb", originalObjectId: "cccc" },
367 },
368 ],
369 }),
370 ),
371 );
372​
373 expect(page.changes).toEqual([
374 {
375 path: "docs/new.md",
376 oldPath: "docs/old.md",
377 changeKind: "rename-pure",
378 objectId: "aaaa",
379 originalObjectId: "aaaa",
380 },
381 {
382 path: "src/new.ts",
383 oldPath: "src/old.ts",
384 changeKind: "rename-changed",
385 objectId: "bbbb",
386 originalObjectId: "cccc",
387 },
388 ]);
389 });
390​
391 it("drops the folders Azure lists alongside the files that changed", () => {
392 const page = expectSuccess(
393 decodeIterationChangesJson(
394 asJson({
395 changeEntries: [
396 { changeType: "add", item: { path: "/docs", isFolder: true, gitObjectType: "tree" } },
397 { changeType: "add", item: { path: "/docs/page.md", objectId: "dddd" } },
398 ],
399 }),
400 ),
401 );
402​
403 expect(page.changes.map((change) => change.path)).toEqual(["docs/page.md"]);
404 });
405​
406 it("reads where a rename came from out of either of the two places Azure names it", () => {
407 const page = expectSuccess(
408 decodeIterationChangesJson(
409 asJson({
410 changeEntries: [
411 {
412 changeType: "rename",
413 originalPath: "/docs/old.md",
414 item: { path: "/docs/new.md", objectId: "aaaa", originalObjectId: "aaaa" },
415 },
416 ],
417 }),
418 ),
419 );
420​
421 expect(page.changes.at(0)?.oldPath).toBe("docs/old.md");
422 });
423});
424​
425describe("decodeItemContentJson", () => {
426 it("reads an empty file as empty rather than as a failure to look", () => {
427 expect(expectSuccess(decodeItemContentJson(asJson({ path: "/a.md" })))).toEqual({
428 contents: "",
429 isBinary: false,
430 });
431 });
432});
433​
434describe("what az devops invoke answers with", () => {
435 it("reads the envelope the extension adds its own continuation token to", () => {
436 expect(
437 expectSuccess(decodeThreadsJson(asJson({ value: [], count: 0, continuation_token: null }))),
438 ).toEqual([]);
439​
440 expect(
441 expectSuccess(
442 decodeIterationsJson(
443 asJson({
444 count: 1,
445 continuation_token: null,
446 value: [
447 {
448 id: 1,
449 sourceRefCommit: { commitId: "f4031105213c68f197cd46ea303bb5e72acc889a" },
450 commonRefCommit: { commitId: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35" },
451 },
452 ],
453 }),
454 ),
455 ),
456 ).toEqual([
457 {
458 id: 1,
459 headCommit: "f4031105213c68f197cd46ea303bb5e72acc889a",
460 mergeBaseCommit: "acbc805382edd6c38b8d6de6ba9f9bba9da4ad35",
461 },
462 ]);
463 });
464​
465 it("keeps the files of a change that names neither their object type nor their page after it", () => {
466 const page = expectSuccess(
467 decodeIterationChangesJson(
468 asJson({
469 continuation_token: null,
470 changeEntries: [
471 { changeType: "add", item: { path: "/DEMO.md", objectId: "EC005DB24" } },
472 { changeType: "edit", item: { path: "/README.md", objectId: "8F8047A49" } },
473 ],
474 }),
475 ),
476 );
477​
478 expect(page.nextSkip).toBeNull();
479 expect(page.changes).toEqual([
480 {
481 path: "DEMO.md",
482 oldPath: "DEMO.md",
483 changeKind: "new",
484 objectId: "EC005DB24",
485 originalObjectId: null,
486 },
487 {
488 path: "README.md",
489 oldPath: "README.md",
490 changeKind: "change",
491 objectId: "8F8047A49",
492 originalObjectId: null,
493 },
494 ]);
495 });
496​
497 it("reads a text file whose content type Azure calls a stream", () => {
498 expect(
499 expectSuccess(
500 decodeItemContentJson(
501 asJson({
502 path: "/README.md",
503 objectId: "8f8047a49",
504 gitObjectType: "blob",
505 content: "# T3Demo\n",
506 contentMetadata: {
507 contentType: "application/octet-stream",
508 encoding: 65001,
509 extension: "md",
510 fileName: "README.md",
511 },
512 continuation_token: null,
513 }),
514 ),
515 ),
516 ).toEqual({ contents: "# T3Demo\n", isBinary: false });
517 });
518});