1import * as Result from "effect/Result";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 decodeCommentsJson,
6 decodeCommitsJson,
7 decodePullRequestJson,
8 decodePullRequestPageJson,
9 decodeRepositoryPermissionJson,
10 decodeStatusesJson,
11 decodeViewerJson,
12} from "./bitbucketPullRequestJson.ts";
13
14function pullRequest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
15 return {
16 id: 897,
17 title: "Add trustabl-pipe",
18 description: "# Add trustabl-pipe",
19 state: "OPEN",
20 draft: false,
21 created_on: "2026-06-16T05:04:32.258456+00:00",
22 updated_on: "2026-06-16T05:04:33.750542+00:00",
23 author: { display_name: "Bilal Hassan", nickname: "bilal", type: "user" },
24 source: {
25 branch: { name: "feat/page" },
26 commit: { hash: "9f3a7c2d8b1e6f045a9c3d7e2b8f1a6c0d4e5f93", type: "commit" },
27 repository: { full_name: "fork/web" },
28 },
29 destination: { branch: { name: "master" }, commit: { hash: "base-commit" } },
30 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/897" } },
31 ...overrides,
32 };
33}
34
35function page(values: ReadonlyArray<unknown>, extra: Record<string, unknown> = {}): string {
36 return JSON.stringify({ pagelen: 50, page: 1, size: values.length, values, ...extra });
37}
38
39function expectSuccess<A>(result: Result.Result<A, unknown>): A {
40 expect(Result.isSuccess(result)).toBe(true);
41 if (!Result.isSuccess(result)) throw new Error("expected a successful decode");
42 return result.success;
43}
44
45describe("decodePullRequestPageJson", () => {
46 it("reads a pull request as a change request", () => {
47 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()])));
48
49 expect(decoded.items).toHaveLength(1);
50 expect(decoded.items[0]).toMatchObject({
51 number: 897,
52 title: "Add trustabl-pipe",
53 url: "https://bitbucket.org/acme/web/pull-requests/897",
54 author: { login: "bilal", name: "Bilal Hassan" },
55 headBranch: "feat/page",
56 headSha: "9f3a7c2d8b1e6f045a9c3d7e2b8f1a6c0d4e5f93",
57 headRepositoryNameWithOwner: "fork/web",
58 baseBranch: "master",
59 state: "open",
60 isDraft: false,
61 mergeability: "unknown",
62 });
63 expect(decoded.next).toBeNull();
64 });
65
66 it("normalizes Bitbucket's offset timestamps, which the page sorts against other hosts", () => {
67 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest()])));
68
69 expect(decoded.items[0]).toMatchObject({
70 createdAt: "2026-06-16T05:04:32.258Z",
71 updatedAt: "2026-06-16T05:04:33.750Z",
72 });
73 });
74
75 it.each([
76 ["MERGED", "merged"],
77 ["DECLINED", "closed"],
78 ["something new", "open"],
79 ])("reads the %s state as %s", (state, expected) => {
80 const decoded = expectSuccess(decodePullRequestPageJson(page([pullRequest({ state })])));
81
82 expect(decoded.items[0]?.state).toBe(expected);
83 });
84
85 it("skips a malformed row rather than failing the page", () => {
86 const decoded = expectSuccess(
87 decodePullRequestPageJson(page([{ id: "not a number" }, pullRequest()])),
88 );
89
90 expect(decoded.items).toHaveLength(1);
91 });
92});
93
94describe("decodePullRequestJson", () => {
95 // regression: #18402 a pull request with no head commit hash still decodes, headSha null
96 it.each([
97 { metadata: "absent commit", commit: undefined },
98 { metadata: "null commit", commit: null },
99 { metadata: "absent hash", commit: {} },
100 { metadata: "null hash", commit: { hash: null } },
101 ])("reads detail and list payloads with $metadata", ({ commit }) => {
102 const raw = pullRequest({ source: { branch: { name: "feat/page" }, commit } });
103 const detail = expectSuccess(decodePullRequestJson(JSON.stringify(raw)));
104 const list = expectSuccess(decodePullRequestPageJson(page([raw])));
105
106 expect(detail).toMatchObject({ number: 897, headBranch: "feat/page", headSha: null });
107 expect(list.items).toHaveLength(1);
108 expect(list.items[0]).toMatchObject({ number: 897, headBranch: "feat/page", headSha: null });
109 });
110
111 it("reads a participant's vote as a review", () => {
112 const decoded = expectSuccess(
113 decodePullRequestJson(
114 JSON.stringify(
115 pullRequest({
116 participants: [
117 {
118 user: { nickname: "julius", display_name: "Julius" },
119 role: "REVIEWER",
120 approved: true,
121 state: "approved",
122 participated_on: "2026-06-17T09:00:00+00:00",
123 },
124 {
125 user: { nickname: "sam", display_name: "Sam" },
126 role: "REVIEWER",
127 approved: false,
128 state: null,
129 participated_on: null,
130 },
131 ],
132 }),
133 ),
134 ),
135 );
136
137 expect(decoded.reviews).toHaveLength(1);
138 expect(decoded.reviews[0]).toMatchObject({
139 kind: "review",
140 author: { login: "julius" },
141 reviewState: "approved",
142 createdAt: "2026-06-17T09:00:00.000Z",
143 });
144 });
145});
146
147describe("decodeViewerJson", () => {
148 it("falls back to the display name, which app accounts have instead", () => {
149 const decoded = decodeViewerJson(JSON.stringify({ display_name: "Release Bot" }));
150
151 expect(expectSuccess(decoded)).toBe("Release Bot");
152 });
153});
154
155describe("decodeCommentsJson", () => {
156 it("keeps a posted comment and drops deleted and unposted ones", () => {
157 const decoded = expectSuccess(
158 decodeCommentsJson(
159 page([
160 {
161 id: 797230941,
162 content: { raw: "The issue is ready for review." },
163 user: { display_name: "Release Bot", type: "app_user" },
164 created_on: "2026-05-15T01:58:38.220690+00:00",
165 deleted: false,
166 pending: false,
167 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/892#c1" } },
168 },
169 {
170 id: 2,
171 content: { raw: "gone" },
172 created_on: "2026-05-15T02:00:00+00:00",
173 deleted: true,
174 },
175 {
176 id: 3,
177 content: { raw: "wip" },
178 created_on: "2026-05-15T02:00:00+00:00",
179 pending: true,
180 },
181 { id: 4, content: { raw: " " }, created_on: "2026-05-15T02:00:00+00:00" },
182 ]),
183 ),
184 );
185
186 expect(decoded.comments).toHaveLength(1);
187 expect(decoded.comments[0]).toMatchObject({
188 id: "797230941",
189 kind: "issue-comment",
190 author: { login: "Release Bot" },
191 createdAt: "2026-05-15T01:58:38.220Z",
192 });
193 });
194
195 it("reads a comment pinned to a file as a review comment", () => {
196 const decoded = expectSuccess(
197 decodeCommentsJson(
198 page([
199 {
200 id: 5,
201 content: { raw: "Rename this." },
202 created_on: "2026-05-15T02:00:00+00:00",
203 inline: { path: "src/app.ts" },
204 },
205 ]),
206 ),
207 );
208
209 expect(decoded.comments[0]).toMatchObject({ kind: "review-comment", path: "src/app.ts" });
210 });
211});
212
213describe("decodeCommitsJson", () => {
214 it("returns commits oldest first with only the subject line", () => {
215 const decoded = expectSuccess(
216 decodeCommitsJson(
217 page([
218 { hash: "bbb", message: "second\n\nbody text\n", date: "2026-06-16T04:51:00+00:00" },
219 {
220 hash: "aaa",
221 message: "first\n",
222 date: "2026-06-16T04:50:49+00:00",
223 author: {
224 raw: "Ada Lovelace <ada@example.com>",
225 user: { nickname: "ada", display_name: "Ada Lovelace" },
226 },
227 },
228 ]),
229 ),
230 );
231
232 expect(decoded.items.map((commit) => commit.oid)).toEqual(["aaa", "bbb"]);
233 expect(decoded.items[0]?.authors).toEqual([
234 { login: "ada", name: "Ada Lovelace", avatarUrl: null },
235 ]);
236 expect(decoded.items[1]?.messageHeadline).toBe("second");
237 expect(decoded.next).toBeNull();
238 });
239});
240
241describe("decodeStatusesJson", () => {
242 it("reads a build status as a check", () => {
243 const decoded = expectSuccess(
244 decodeStatusesJson(
245 page([
246 {
247 key: "custom:check-version-and-pr",
248 name: "Pipeline - custom: check-version-and-pr",
249 state: "SUCCESSFUL",
250 description: "",
251 url: "https://bitbucket.org/acme/web/pipelines/results/8126",
252 },
253 ]),
254 ),
255 );
256
257 expect(decoded).toEqual({
258 items: [
259 {
260 name: "Pipeline - custom: check-version-and-pr",
261 status: "success",
262 description: null,
263 url: "https://bitbucket.org/acme/web/pipelines/results/8126",
264 },
265 ],
266 next: null,
267 });
268 });
269
270 it.each([
271 ["SUCCESSFUL", "success"],
272 ["FAILED", "failure"],
273 ["something new", "neutral"],
274 ])("reads the %s build state as %s", (state, expected) => {
275 const decoded = expectSuccess(decodeStatusesJson(page([{ name: "Pipeline", state }])));
276
277 expect(decoded.items[0]?.status).toBe(expected);
278 });
279
280 it("keeps two statuses that share a display name but have different keys", () => {
281 const decoded = expectSuccess(
282 decodeStatusesJson(
283 page([
284 { key: "build", name: "Pipeline", state: "SUCCESSFUL" },
285 { key: "deploy", name: "Pipeline", state: "FAILED" },
286 ]),
287 ),
288 );
289
290 expect(decoded.items.map((check) => [check.name, check.status])).toEqual([
291 ["deploy / Pipeline", "failure"],
292 ["build / Pipeline", "success"],
293 ]);
294 });
295});
296
297describe("repository permission decoding", () => {
298 const permissionPage = (permission: string) =>
299 page([{ type: "repository_permission", permission }]);
300
301 it("counts admin and write as write, and read as not", () => {
302 expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("admin")))).toBe(true);
303 expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("write")))).toBe(true);
304 expect(expectSuccess(decodeRepositoryPermissionJson(permissionPage("read")))).toBe(false);
305 });
306
307 it("grants write where Bitbucket named no permission at all", () => {
308 expect(expectSuccess(decodeRepositoryPermissionJson(page([])))).toBe(true);
309 });
310});