1import { afterEach, assert, expect, it, vi } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import * as BitbucketApi from "./BitbucketApi.ts";
6import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
7
8const mockedRequest = vi.fn<BitbucketApi.BitbucketApi["Service"]["request"]>();
9
10const layer = it.layer(
11 BitbucketPullRequestApi.layer.pipe(
12 Layer.provide(
13 Layer.mock(BitbucketApi.BitbucketApi)({
14 request: mockedRequest,
15 }),
16 ),
17 ),
18);
19
20function response(body: string) {
21 return { body, truncated: false };
22}
23
24function page(count: number, firstNumber: number, next?: string): string {
25 return JSON.stringify({
26 pagelen: 50,
27 size: count,
28 values: Array.from({ length: count }, (_, index) => ({
29 id: firstNumber + index,
30 title: `Pull request ${firstNumber + index}`,
31 state: "OPEN",
32 created_on: "2026-06-16T05:04:32+00:00",
33 updated_on: "2026-06-16T05:04:33+00:00",
34 source: { branch: { name: "feat/page" } },
35 destination: { branch: { name: "master" } },
36 links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${firstNumber}` } },
37 })),
38 ...(next === undefined ? {} : { next }),
39 });
40}
41
42function valuePage(values: ReadonlyArray<unknown>, next?: string): string {
43 return JSON.stringify({ values, ...(next === undefined ? {} : { next }) });
44}
45
46const bilal = { uuid: "{bilal}", nickname: "bilal" };
47const octocat = { uuid: "{octocat}", nickname: "octocat" };
48const hubot = { uuid: "{hubot}", nickname: "hubot" };
49
50function pullRequestJson(overrides: Record<string, unknown>): string {
51 return JSON.stringify({
52 id: 7,
53 title: "Pull request 7",
54 state: "OPEN",
55 author: bilal,
56 created_on: "2026-06-16T05:04:32+00:00",
57 updated_on: "2026-06-16T05:04:33+00:00",
58 source: { branch: { name: "feat/page" } },
59 destination: { branch: { name: "master" } },
60 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/7" } },
61 ...overrides,
62 });
63}
64
65function callAt(index: number) {
66 const call = mockedRequest.mock.calls[index];
67 assert.isDefined(call);
68 return call[0];
69}
70
71function filterOfCall(index: number): string | null {
72 const url = callAt(index).url;
73 return new URLSearchParams(url.slice(url.indexOf("?") + 1)).get("q");
74}
75
76afterEach(() => {
77 mockedRequest.mockReset();
78});
79
80layer("BitbucketPullRequestApi.layer", (it) => {
81 it.effect("follows the cursor Bitbucket sends rather than counting offsets", () =>
82 Effect.gen(function* () {
83 const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2";
84 mockedRequest
85 .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next))))
86 .mockReturnValueOnce(Effect.succeed(response(page(50, 51))));
87 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
88
89 const batch = yield* api.listPullRequests({
90 repository: "acme/web",
91 state: "open",
92 limit: 100,
93 });
94
95 assert.strictEqual(batch.items.length, 100);
96 assert.isFalse(batch.truncated);
97 assert.strictEqual(callAt(1).url, next);
98 }),
99 );
100
101 it.effect("counts the rows it walked past as more to come", () =>
102 Effect.gen(function* () {
103 const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2";
104 mockedRequest
105 .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next))))
106 .mockReturnValueOnce(Effect.succeed(response(page(50, 51))));
107 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
108
109 const batch = yield* api.listPullRequests({
110 repository: "acme/web",
111 state: "open",
112 limit: 99,
113 });
114
115 assert.strictEqual(batch.items.length, 99);
116 assert.isTrue(batch.truncated);
117 }),
118 );
119
120 it.effect("escapes a quote and a backslash, so a search cannot reshape the filter", () =>
121 Effect.gen(function* () {
122 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));
123 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
124
125 yield* api.listPullRequests({
126 repository: "acme/web",
127 state: "open",
128 limit: 50,
129 query: String.raw`a\" OR state = "MERGED"`,
130 });
131
132 const literal = String.raw`a\\\" OR state = \"MERGED\"`;
133 expect(filterOfCall(0)).toBe(`(title ~ "${literal}" OR description ~ "${literal}")`);
134 }),
135 );
136
137 it.effect("narrows by the reader's words and by where it left off at once", () =>
138 Effect.gen(function* () {
139 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));
140 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
141
142 yield* api.listPullRequests({
143 repository: "acme/web",
144 state: "open",
145 limit: 50,
146 query: "page",
147 cursor: { updatedBefore: "2026-07-02T00:00:00+00:00", delivered: 50 },
148 });
149
150 expect(filterOfCall(0)).toBe(
151 '(title ~ "page" OR description ~ "page") AND updated_on < 2026-07-02T00:00:00.001Z',
152 );
153 }),
154 );
155
156 it.effect("narrows by author and draft state in the same query", () =>
157 Effect.gen(function* () {
158 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));
159 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
160
161 yield* api.listPullRequests({
162 repository: "acme/web",
163 state: "open",
164 limit: 50,
165 author: 'ana"',
166 draft: false,
167 });
168
169 expect(filterOfCall(0)).toBe(
170 '(author.nickname = "ana\\"" OR author.display_name = "ana\\"") AND draft = false',
171 );
172 }),
173 );
174
175 it.effect("refuses a repository that is not workspace and slug", () =>
176 Effect.gen(function* () {
177 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
178
179 const error = yield* Effect.flip(
180 api.listPullRequests({ repository: "acme/team/web", state: "open", limit: 50 }),
181 );
182
183 assert.strictEqual(error._tag, "BitbucketRepositoryUnsupportedError");
184 assert.strictEqual(mockedRequest.mock.calls.length, 0);
185 }),
186 );
187
188 it.effect("refuses a commit that is not a sha rather than reading it into a URL", () =>
189 Effect.gen(function* () {
190 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
191
192 const error = yield* Effect.flip(
193 api.getPullRequestDiff({
194 repository: "acme/web",
195 number: 7,
196 commit: "../../acme/other/diff/deadbeef",
197 }),
198 );
199
200 assert.strictEqual(error._tag, "BitbucketDiffCommitError");
201 assert.strictEqual(mockedRequest.mock.calls.length, 0);
202 }),
203 );
204
205 it.effect("reads every file version the patch states, not only the paths asked about", () =>
206 Effect.gen(function* () {
207 mockedRequest.mockReturnValueOnce(
208 Effect.succeed(
209 response(
210 [
211 "diff --git a/a.ts b/a.ts",
212 "index 1111111..2222222 100644",
213 "--- a/a.ts",
214 "+++ b/a.ts",
215 "@@ -1 +1 @@",
216 "-a",
217 "+b",
218 "diff --git a/b.ts b/b.ts",
219 "index 3333333..4444444 100644",
220 "--- a/b.ts",
221 "+++ b/b.ts",
222 "@@ -1 +1 @@",
223 "-c",
224 "+d",
225 "",
226 ].join("\n"),
227 ),
228 ),
229 );
230 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
231
232 const revisions = yield* api.getFileRevisions({
233 repository: "acme/web",
234 number: 71,
235 paths: ["a.ts", "missing.ts"],
236 });
237
238 assert.deepStrictEqual(
239 [...revisions.revisions],
240 [
241 ["a.ts", "2222222"],
242 ["b.ts", "4444444"],
243 ["missing.ts", ""],
244 ],
245 );
246 assert.strictEqual(revisions.complete, true);
247 expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" });
248 }),
249 );
250
251 it.effect("says nothing about the files past the end of a patch it could not read whole", () =>
252 Effect.gen(function* () {
253 mockedRequest.mockReturnValueOnce(
254 Effect.succeed({
255 body: "diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n",
256 truncated: true,
257 }),
258 );
259 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
260
261 const revisions = yield* api.getFileRevisions({
262 repository: "acme/web",
263 number: 72,
264 paths: ["a.ts", "past-the-cut.ts"],
265 });
266
267 assert.deepStrictEqual([...revisions.revisions], [["a.ts", "2222222"]]);
268 assert.strictEqual(revisions.complete, false);
269 }),
270 );
271
272 it.effect("returns the complete commit timeline oldest first across pages", () =>
273 Effect.gen(function* () {
274 const next = "https://api.bitbucket.org/2.0/commits?page=2";
275 mockedRequest
276 .mockReturnValueOnce(
277 Effect.succeed(
278 response(
279 valuePage(
280 [
281 { hash: "ddd", message: "fourth", date: "2026-07-04T00:00:00Z" },
282 { hash: "ccc", message: "third", date: "2026-07-03T00:00:00Z" },
283 ],
284 next,
285 ),
286 ),
287 ),
288 )
289 .mockReturnValueOnce(
290 Effect.succeed(
291 response(
292 valuePage([
293 { hash: "bbb", message: "second", date: "2026-07-02T00:00:00Z" },
294 { hash: "aaa", message: "first", date: "2026-07-01T00:00:00Z" },
295 ]),
296 ),
297 ),
298 );
299 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
300
301 const commits = yield* api.listCommits({ repository: "acme/web", number: 7 });
302
303 expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb", "ccc", "ddd"]);
304 expect(callAt(1).url).toBe(next);
305 }),
306 );
307
308 it.effect("posts a comment as a JSON document, so the body stays text", () =>
309 Effect.gen(function* () {
310 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
311 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
312
313 yield* api.comment({ repository: "acme/web", number: 7, body: "true" });
314
315 expect(callAt(0)).toMatchObject({
316 method: "POST",
317 url: "/repositories/acme/web/pullrequests/7/comments",
318 body: '{"content":{"raw":"true"}}',
319 });
320 }),
321 );
322
323 it.effect("asks for the time a pull request closed, which Bitbucket leaves out by default", () =>
324 Effect.gen(function* () {
325 mockedRequest.mockReturnValueOnce(
326 Effect.succeed(
327 response(
328 pullRequestJson({ state: "MERGED", closed_on: "2025-06-16T23:40:46.222238+00:00" }),
329 ),
330 ),
331 );
332 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
333
334 const pullRequest = yield* api.getPullRequest({ repository: "acme/web", number: 7 });
335
336 expect(callAt(0).url).toBe("/repositories/acme/web/pullrequests/7?fields=%2Bclosed_on");
337 assert.strictEqual(pullRequest.closedAt, "2025-06-16T23:40:46.222Z");
338 }),
339 );
340
341 it.effect("stops the comment walk at its bound and says the conversation was cut short", () =>
342 Effect.gen(function* () {
343 mockedRequest.mockReturnValue(
344 Effect.succeed(
345 response(
346 JSON.stringify({
347 next: "https://api.bitbucket.org/2.0/comments?page=2",
348 values: [
349 {
350 id: 10,
351 content: { raw: "again" },
352 created_on: "2026-06-16T05:04:32+00:00",
353 },
354 ],
355 }),
356 ),
357 ),
358 );
359 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
360
361 const { truncated } = yield* api.listComments({ repository: "acme/web", number: 7 });
362
363 assert.strictEqual(mockedRequest.mock.calls.length, 10);
364 assert.isTrue(truncated);
365 }),
366 );
367
368 it.effect("reassembles a thread from the flat comment list, replies included", () =>
369 Effect.gen(function* () {
370 mockedRequest.mockReturnValueOnce(
371 Effect.succeed(
372 response(
373 JSON.stringify({
374 values: [
375 {
376 id: 10,
377 content: { raw: "rename this" },
378 user: { nickname: "bilal" },
379 created_on: "2026-06-16T05:04:32+00:00",
380 inline: { path: "src/a.ts", to: 12, from: null },
381 resolution: { type: "pullrequest_comment_resolution" },
382 },
383 {
384 id: 11,
385 content: { raw: "done" },
386 user: { nickname: "julius" },
387 created_on: "2026-06-16T06:04:32+00:00",
388 parent: { id: 10 },
389 },
390 {
391 id: 12,
392 content: { raw: "thanks" },
393 user: { nickname: "bilal" },
394 created_on: "2026-06-16T07:04:32+00:00",
395 parent: { id: 11 },
396 },
397 {
398 id: 13,
399 content: { raw: "ship it" },
400 user: { nickname: "bilal" },
401 created_on: "2026-06-16T08:04:32+00:00",
402 },
403 ],
404 }),
405 ),
406 ),
407 );
408 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
409
410 const { threads } = yield* api.listComments({ repository: "acme/web", number: 7 });
411
412 assert.strictEqual(threads.length, 1);
413 expect(threads[0]).toMatchObject({
414 id: "10",
415 path: "src/a.ts",
416 line: 12,
417 side: "right",
418 isResolved: true,
419 });
420 expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11", "12"]);
421 }),
422 );
423
424 it.effect("asks for the credentials' permission on this repository, and nobody else's", () =>
425 Effect.gen(function* () {
426 mockedRequest.mockReturnValue(
427 Effect.succeed(
428 response(
429 JSON.stringify({ values: [{ type: "repository_permission", permission: "read" }] }),
430 ),
431 ),
432 );
433 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
434
435 assert.isFalse(yield* api.getRepositoryPermission({ repository: "acme/web" }));
436
437 expect(new URL(callAt(0).url, "https://api.bitbucket.org").pathname).toBe(
438 "/user/workspaces/acme/permissions/repositories",
439 );
440 assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/web"');
441 }),
442 );
443
444 it.effect("escapes a repository name before it goes inside a filter literal", () =>
445 Effect.gen(function* () {
446 mockedRequest.mockReturnValue(Effect.succeed(response(JSON.stringify({ values: [] }))));
447 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
448
449 yield* api.getRepositoryPermission({ repository: 'acme/we"b' });
450
451 assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/we\\"b"');
452 }),
453 );
454
455 const permissionFailure = (status: number) =>
456 Effect.fail(
457 new BitbucketApi.BitbucketResponseError({
458 operation: "request",
459 status,
460 responseBodyLength: 0,
461 }),
462 );
463
464 it.effect.each([403, 404, 410])(
465 "reads HTTP %s from the permissions endpoint as an unknown permission",
466 (status) =>
467 Effect.gen(function* () {
468 mockedRequest.mockReturnValue(permissionFailure(status));
469 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
470
471 assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" }));
472 }),
473 );
474
475 it.effect.each([401, 500])("still fails the permission read on HTTP %s", (status) =>
476 Effect.gen(function* () {
477 mockedRequest.mockReturnValue(permissionFailure(status));
478 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
479
480 const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" }));
481 assert.strictEqual(error._tag, "BitbucketResponseError");
482 if (error._tag === "BitbucketResponseError") {
483 assert.strictEqual(error.status, status);
484 }
485 }),
486 );
487
488 it.effect("reads the workspace's people and marks whoever is already a reviewer", () =>
489 Effect.gen(function* () {
490 mockedRequest
491 .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] }))))
492 .mockReturnValueOnce(
493 Effect.succeed(
494 response(
495 JSON.stringify({ values: [{ user: bilal }, { user: octocat }, { user: hubot }] }),
496 ),
497 ),
498 );
499 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
500
501 const list = yield* api.listReviewerCandidates({ repository: "acme/web", number: 7 });
502
503 expect(callAt(1).url).toBe("/workspaces/acme/members?pagelen=50");
504 expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([
505 ["{octocat}", true],
506 ["{hubot}", false],
507 ]);
508 assert.isFalse(list.truncated);
509 }),
510 );
511
512 it.effect("writes the reviewer set back with the one being asked added to it", () =>
513 Effect.gen(function* () {
514 mockedRequest
515 .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] }))))
516 .mockReturnValueOnce(Effect.succeed(response("{}")));
517 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
518
519 yield* api.setReviewerRequest({
520 repository: "acme/web",
521 number: 7,
522 reviewers: [{ id: "{hubot}" }],
523 requested: true,
524 });
525
526 const call = callAt(1);
527 expect(call.method).toBe("PUT");
528 expect(call.url).toBe("/repositories/acme/web/pullrequests/7");
529 expect(JSON.parse(call.body ?? "")).toEqual({
530 reviewers: [{ uuid: "{octocat}" }, { uuid: "{hubot}" }],
531 });
532 }),
533 );
534});