1import * as Result from "effect/Result";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 decodeCommentsJson,
6 decodeCommitsJson,
7 decodeConflictsJson,
8 decodeDiffstatJson,
9 decodePullRequestJson,
10 decodePullRequestPageJson,
11 decodeRepositoryPermissionJson,
12 decodeStatusesJson,
13 decodeViewerJson,
14} from "./bitbucketPullRequestJson.ts";
15
16/** Shaped after a real api.bitbucket.org pull request, trimmed to the fields that are read. */
17function pullRequest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
18 return {
19 id: 897,
20 title: "Add trustabl-pipe",
21 description: "# Add trustabl-pipe",
22 state: "OPEN",
23 draft: false,
24 created_on: "2026-06-16T05:04:32.258456+00:00",
25 updated_on: "2026-06-16T05:04:33.750542+00:00",
26 author: { display_name: "Bilal Hassan", nickname: "bilal", type: "user" },
27 source: {
28 branch: { name: "feat/page" },
29 commit: { hash: "9f3a7c2d8b1e6f045a9c3d7e2b8f1a6c0d4e5f93", type: "commit" },
30 repository: { full_name: "fork/web" },
31 },
32 destination: { branch: { name: "master" }, commit: { hash: "base-commit" } },
33 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } },
34 ...overrides,
35 };
36}
37
38function page(values: ReadonlyArray<unknown>, extra: Record<string, unknown> = {}): string {
39 return JSON.stringify({ pagelen: 50, page: 1, size: values.length, values, ...extra });
40}
41
42function expectSuccess<A>(result: Result.Result<A, unknown>): A {
43 expect(Result.isSuccess(result)).toBe(true);
44 if (!Result.isSuccess(result)) throw new Error("expected a successful decode");
45 return result.success;
46}
47
48describe("decodePullRequestPageJson", () => {
49 it("reads a pull request as a change request", () => {
50 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()])));
51
52 expect(decoded.items).toHaveLength(1);
53 expect(decoded.items[0]).toMatchObject({
54 number: 897,
55 title: "Add trustabl-pipe",
56 url: "https://bitbucket.org/acme/web/pull-requests/897",
57 author: { login: "bilal", name: "Bilal Hassan" },
58 headBranch: "feat/page",
59 headSha: "9f3a7c2d8b1e6f045a9c3d7e2b8f1a6c0d4e5f93",
60 headRepositoryNameWithOwner: "fork/web",
61 baseBranch: "master",
62 state: "open",
63 isDraft: false,
64 // Bitbucket says nothing about conflicts on the pull request itself.
65 mergeability: "unknown",
66 });
67 expect(decoded.next).toBeNull();
68 });
69
70 it("normalizes Bitbucket's offset timestamps, which the page sorts against other hosts", () => {
71 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()])));
72
73 expect(decoded.items[0]).toMatchObject({
74 createdAt: "2026-06-16T05:04:32.258Z",
75 updatedAt: "2026-06-16T05:04:33.750Z",
76 });
77 });
78
79 it("reports the next page as the whole URL Bitbucket sends", () => {
80 const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2";
81 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()], { next })));
82
83 expect(decoded.next).toBe(next);
84 });
85
86 it.each([
87 ["MERGED", "merged"],
88 ["DECLINED", "closed"],
89 ["SUPERSEDED", "closed"],
90 ["OPEN", "open"],
91 ["something new", "open"],
92 ])("reads the %s state as %s", (state, expected) => {
93 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest({ state })])));
94
95 expect(decoded.items[0]?.state).toBe(expected);
96 });
97
98 it("skips a malformed row rather than failing the page", () => {
99 const decoded = expectSuccess(
100 decodePullRequestPageJson(page([{ id: "not a number" }, pullRequest()])),
101 );
102
103 expect(decoded.items).toHaveLength(1);
104 });
105
106 it("fails when Bitbucket did not answer with a page", () => {
107 expect(Result.isFailure(decodePullRequestPageJson(JSON.stringify({ error: "nope" })))).toBe(
108 true,
109 );
110 });
111});
112
113describe("decodePullRequestJson", () => {
114 it("preserves the source commit hash as the head SHA", () => {
115 const decoded = expectSuccess(decodePullRequestJson(JSON.stringify(pullRequest())));
116
117 expect(decoded).toMatchObject({ headSha: "9f3a7c2d8b1e6f045a9c3d7e2b8f1a6c0d4e5f93" });
118 });
119
120 it.each([
121 { metadata: "absent commit", commit: undefined },
122 { metadata: "null commit", commit: null },
123 { metadata: "absent hash", commit: {} },
124 { metadata: "null hash", commit: { hash: null } },
125 ])("reads detail and list payloads with $metadata", ({ commit }) => {
126 const raw = pullRequest({ source: { branch: { name: "feat/page" }, commit } });
127 const detail = expectSuccess(decodePullRequestJson(JSON.stringify(raw)));
128 const list = expectSuccess(decodePullRequestPageJson(page([raw])));
129
130 expect(detail).toMatchObject({ number: 897, headBranch: "feat/page", headSha: null });
131 expect(list.items).toHaveLength(1);
132 expect(list.items[0]).toMatchObject({ number: 897, headBranch: "feat/page", headSha: null });
133 });
134
135 it("reads reviewers as review requests", () => {
136 const decoded = expectSuccess(
137 decodePullRequestJson(
138 JSON.stringify(
139 pullRequest({
140 reviewers: [{ nickname: "julius", display_name: "Julius" }],
141 }),
142 ),
143 ),
144 );
145
146 expect(decoded.reviewRequestLogins).toEqual(["julius"]);
147 expect(decoded.reviewers).toEqual([{ login: "julius", name: "Julius", avatarUrl: null }]);
148 });
149
150 it("reads a participant's vote as a review", () => {
151 const decoded = expectSuccess(
152 decodePullRequestJson(
153 JSON.stringify(
154 pullRequest({
155 participants: [
156 {
157 user: { nickname: "julius", display_name: "Julius" },
158 role: "REVIEWER",
159 approved: true,
160 state: "approved",
161 participated_on: "2026-06-17T09:00:00+00:00",
162 },
163 // Added as a reviewer but has not voted, so there is no verdict to show.
164 {
165 user: { nickname: "sam", display_name: "Sam" },
166 role: "REVIEWER",
167 approved: false,
168 state: null,
169 participated_on: null,
170 },
171 ],
172 }),
173 ),
174 ),
175 );
176
177 expect(decoded.reviews).toHaveLength(1);
178 expect(decoded.reviews[0]).toMatchObject({
179 kind: "review",
180 author: { login: "julius" },
181 reviewState: "approved",
182 createdAt: "2026-06-17T09:00:00.000Z",
183 });
184 });
185});
186
187describe("decodeViewerJson", () => {
188 it("reads the signed-in nickname", () => {
189 const decoded = decodeViewerJson(JSON.stringify({ nickname: "bilal", display_name: "Bilal" }));
190
191 expect(expectSuccess(decoded)).toBe("bilal");
192 });
193
194 it("falls back to the display name, which app accounts have instead", () => {
195 const decoded = decodeViewerJson(JSON.stringify({ display_name: "Release Bot" }));
196
197 expect(expectSuccess(decoded)).toBe("Release Bot");
198 });
199
200 it("returns nothing when the account has neither", () => {
201 expect(expectSuccess(decodeViewerJson(JSON.stringify({})))).toBeNull();
202 });
203});
204
205describe("decodeCommentsJson", () => {
206 it("keeps a posted comment and drops deleted and unposted ones", () => {
207 const decoded = expectSuccess(
208 decodeCommentsJson(
209 page([
210 {
211 id: 797230941,
212 content: { raw: "The issue is ready for review." },
213 user: { display_name: "Release Bot", type: "app_user" },
214 created_on: "2026-05-15T01:58:38.220690+00:00",
215 deleted: false,
216 pending: false,
217 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/892#c1" } },
218 },
219 {
220 id: 2,
221 content: { raw: "gone" },
222 created_on: "2026-05-15T02:00:00+00:00",
223 deleted: true,
224 },
225 {
226 id: 3,
227 content: { raw: "wip" },
228 created_on: "2026-05-15T02:00:00+00:00",
229 pending: true,
230 },
231 { id: 4, content: { raw: " " }, created_on: "2026-05-15T02:00:00+00:00" },
232 ]),
233 ),
234 );
235
236 expect(decoded.comments).toHaveLength(1);
237 expect(decoded.comments[0]).toMatchObject({
238 id: "797230941",
239 kind: "issue-comment",
240 // An app account has no nickname, so its display name is the only handle it has.
241 author: { login: "Release Bot" },
242 createdAt: "2026-05-15T01:58:38.220Z",
243 });
244 });
245
246 it("reads a comment pinned to a file as a review comment", () => {
247 const decoded = expectSuccess(
248 decodeCommentsJson(
249 page([
250 {
251 id: 5,
252 content: { raw: "Rename this." },
253 created_on: "2026-05-15T02:00:00+00:00",
254 inline: { path: "src/app.ts" },
255 },
256 ]),
257 ),
258 );
259
260 expect(decoded.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" });
261 });
262});
263
264describe("decodeCommitsJson", () => {
265 it("returns commits oldest first with only the subject line", () => {
266 const decoded = expectSuccess(
267 decodeCommitsJson(
268 page([
269 { hash: "bbb", message: "second\n\nbody text\n", date: "2026-06-16T04:51:00+00:00" },
270 {
271 hash: "aaa",
272 message: "first\n",
273 date: "2026-06-16T04:50:49+00:00",
274 author: {
275 raw: "Ada Lovelace <ada@example.com>",
276 user: { nickname: "ada", display_name: "Ada Lovelace" },
277 },
278 },
279 ]),
280 ),
281 );
282
283 expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]);
284 expect(decoded.items[0]?.authors).toEqual([
285 { login: "ada", name: "Ada Lovelace", avatarUrl: null },
286 ]);
287 expect(decoded.items[1]?.messageHeadline).toBe("second");
288 expect(decoded.next).toBeNull();
289 });
290
291 it("skips commits whose hash is empty", () => {
292 const decoded = expectSuccess(
293 decodeCommitsJson(
294 page([
295 { hash: " ", message: "invalid", date: "2026-06-16T04:51:00+00:00" },
296 { hash: "aaa", date: "2026-06-16T04:50:49+00:00" },
297 ]),
298 ),
299 );
300
301 expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa"]);
302 });
303});
304
305describe("decodeStatusesJson", () => {
306 it("reads a build status as a check", () => {
307 const decoded = expectSuccess(
308 decodeStatusesJson(
309 page([
310 {
311 key: "custom:check-version-and-pr",
312 name: "Pipeline - custom: check-version-and-pr",
313 state: "SUCCESSFUL",
314 description: "",
315 url: "https://bitbucket.org/acme/web/pipelines/results/8126",
316 },
317 ]),
318 ),
319 );
320
321 expect(decoded).toEqual({
322 items: [
323 {
324 name: "Pipeline - custom: check-version-and-pr",
325 status: "success",
326 description: null,
327 url: "https://bitbucket.org/acme/web/pipelines/results/8126",
328 },
329 ],
330 next: null,
331 });
332 });
333
334 it.each([
335 ["SUCCESSFUL", "success"],
336 ["FAILED", "failure"],
337 ["INPROGRESS", "pending"],
338 ["STOPPED", "cancelled"],
339 ["something new", "neutral"],
340 ])("reads the %s build state as %s", (state, expected) => {
341 const decoded = expectSuccess(decodeStatusesJson(page([{ name: "Pipeline", state }])));
342
343 expect(decoded.items[0]?.status).toBe(expected);
344 });
345
346 it("keeps two statuses that share a display name but have different keys", () => {
347 const decoded = expectSuccess(
348 decodeStatusesJson(
349 page([
350 { key: "build", name: "Pipeline", state: "SUCCESSFUL" },
351 { key: "deploy", name: "Pipeline", state: "FAILED" },
352 ]),
353 ),
354 );
355
356 expect(decoded.items.map((check) => [check.name, check.status])).toEqual([
357 ["build / Pipeline", "success"],
358 ["deploy / Pipeline", "failure"],
359 ]);
360 });
361});
362
363describe("decodeDiffstatJson", () => {
364 it("adds up the per-file counts", () => {
365 const decoded = expectSuccess(
366 decodeDiffstatJson(
367 page([
368 { lines_added: 9, lines_removed: 2 },
369 { lines_added: 32, lines_removed: 14 },
370 ]),
371 ),
372 );
373
374 expect(decoded).toEqual({ additions: 41, deletions: 16, changedFiles: 2, next: null });
375 });
376});
377
378describe("decodeConflictsJson", () => {
379 it("calls an empty conflict list mergeable", () => {
380 expect(expectSuccess(decodeConflictsJson(page([])))).toBe("mergeable");
381 });
382
383 it("calls any reported conflict conflicting", () => {
384 expect(expectSuccess(decodeConflictsJson(page([{ path: "src/app.ts" }])))).toBe("conflicting");
385 });
386});
387
388describe("repository permission decoding", () => {
389 const permissionPage = (permission: string) =>
390 page([{ type: "repository_permission", permission }]);
391
392 it("counts admin and write as write, and read as not", () => {
393 expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("admin")))).toBe(true);
394 expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("write")))).toBe(true);
395 expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("read")))).toBe(false);
396 });
397
398 it("grants write where Bitbucket named no permission at all", () => {
399 // An empty page is Bitbucket declining to say, which is an unknown standing rather than a
400 // refusal — and an unknown one is granted.
401 expect(expectSuccess(decodeRepositoryPermissionJson(page([])))).toBe(true);
402 });
403});