1import { afterEach, assert, expect, it, vi } from "@effect/vitest";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as TestClock from "effect/testing/TestClock";
6
7import * as BitbucketApi from "./BitbucketApi.ts";
8import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
9
10const mockedRequest = vi.fn<BitbucketApi.BitbucketApi["Service"]["request"]>();
11
12const layer = it.layer(
13 BitbucketPullRequestApi.layer.pipe(
14 Layer.provide(
15 Layer.mock(BitbucketApi.BitbucketApi)({
16 request: mockedRequest,
17 }),
18 ),
19 ),
20);
21
22/** The shape `request` answers with: a body plus whether it had to be cut short. */
23function response(body: string) {
24 return { body, truncated: false };
25}
26
27function page(count: number, firstNumber: number, next?: string): string {
28 return JSON.stringify({
29 pagelen: 50,
30 size: count,
31 values: Array.from({ length: count }, (_, index) => ({
32 id: firstNumber + index,
33 title: `Pull request ${firstNumber + index}`,
34 state: "OPEN",
35 created_on: "2026-06-16T05:04:32+00:00",
36 updated_on: "2026-06-16T05:04:33+00:00",
37 source: { branch: { name: "feat/page" } },
38 destination: { branch: { name: "master" } },
39 links: { html: { href: `https://bitbucket.org/acme/web/pull-requests/${firstNumber}` } },
40 })),
41 ...(next === undefined ? {} : { next }),
42 });
43}
44
45function valuePage(values: ReadonlyArray<unknown>, next?: string): string {
46 return JSON.stringify({ values, ...(next === undefined ? {} : { next }) });
47}
48
49/** Who opened the pull request, and two accounts that could review it. */
50const bilal = { uuid: "{bilal}", nickname: "bilal" };
51const octocat = { uuid: "{octocat}", nickname: "octocat" };
52const hubot = { uuid: "{hubot}", nickname: "hubot" };
53
54/** One pull request as `/pullrequests/{id}` answers with it. */
55function pullRequestJson(overrides: Record<string, unknown>): string {
56 return JSON.stringify({
57 id: 7,
58 title: "Pull request 7",
59 state: "OPEN",
60 author: bilal,
61 created_on: "2026-06-16T05:04:32+00:00",
62 updated_on: "2026-06-16T05:04:33+00:00",
63 source: { branch: { name: "feat/page" } },
64 destination: { branch: { name: "master" } },
65 links: { html: { href: "https://bitbucket.org/acme/web/pull-requests/7" } },
66 ...overrides,
67 });
68}
69
70/** The request the nth call made. */
71function callAt(index: number) {
72 const call = mockedRequest.mock.calls[index];
73 assert.isDefined(call);
74 return call[0];
75}
76
77/** The filter expression of the nth request, read back out of its query string. */
78function filterOfCall(index: number): string | null {
79 const url = callAt(index).url;
80 return new URLSearchParams(url.slice(url.indexOf("?") + 1)).get("q");
81}
82
83afterEach(() => {
84 mockedRequest.mockReset();
85});
86
87layer("BitbucketPullRequestApi.layer", (it) => {
88 it.effect("asks for reviewers, newest first, at Bitbucket's page ceiling", () =>
89 Effect.gen(function* () {
90 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(3, 1))));
91 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
92
93 const batch = yield* api.listPullRequests({
94 repository: "acme/web",
95 state: "open",
96 limit: 50,
97 });
98
99 assert.strictEqual(batch.items.length, 3);
100 assert.isFalse(batch.truncated);
101 const url = callAt(0).url;
102 expect(url).toContain("/repositories/acme/web/pullrequests");
103 expect(url).toContain("state=OPEN");
104 // Over 50 Bitbucket answers with an empty page and no error, so it is never exceeded.
105 expect(url).toContain("pagelen=50");
106 expect(url).toContain("sort=-updated_on");
107 expect(url).toContain("fields=%2Bvalues.reviewers");
108 }),
109 );
110
111 it.effect("follows the cursor Bitbucket sends rather than counting offsets", () =>
112 Effect.gen(function* () {
113 const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2";
114 mockedRequest
115 .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next))))
116 .mockReturnValueOnce(Effect.succeed(response(page(50, 51))));
117 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
118
119 const batch = yield* api.listPullRequests({
120 repository: "acme/web",
121 state: "open",
122 limit: 100,
123 });
124
125 assert.strictEqual(batch.items.length, 100);
126 assert.isFalse(batch.truncated);
127 assert.strictEqual(callAt(1).url, next);
128 }),
129 );
130
131 it.effect("stops at the caller's page and says more remain", () =>
132 Effect.gen(function* () {
133 const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2";
134 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(50, 1, next))));
135 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
136
137 const batch = yield* api.listPullRequests({
138 repository: "acme/web",
139 state: "open",
140 limit: 50,
141 });
142
143 assert.strictEqual(batch.items.length, 50);
144 assert.isTrue(batch.truncated);
145 assert.strictEqual(mockedRequest.mock.calls.length, 1);
146 }),
147 );
148
149 it.effect("counts the rows it walked past as more to come", () =>
150 Effect.gen(function* () {
151 // Bitbucket pages in fifties whatever was asked for, so a request for ninety-nine reads a
152 // hundred and drops one. That row is more results, and saying otherwise takes the "load
153 // more" away from a listing that has not finished.
154 const next = "https://api.bitbucket.org/2.0/repositories/acme/web/pullrequests?page=2";
155 mockedRequest
156 .mockReturnValueOnce(Effect.succeed(response(page(50, 1, next))))
157 .mockReturnValueOnce(Effect.succeed(response(page(50, 51))));
158 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
159
160 const batch = yield* api.listPullRequests({
161 repository: "acme/web",
162 state: "open",
163 limit: 99,
164 });
165
166 assert.strictEqual(batch.items.length, 99);
167 assert.isTrue(batch.truncated);
168 }),
169 );
170
171 it.effect("searches with a filter expression, which is all Bitbucket offers", () =>
172 Effect.gen(function* () {
173 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));
174 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
175
176 yield* api.listPullRequests({
177 repository: "acme/web",
178 state: "open",
179 limit: 50,
180 query: "page",
181 });
182
183 expect(filterOfCall(0)).toBe('(title ~ "page" OR description ~ "page")');
184 // The state filter beside it still stands, which the brackets are there to keep.
185 expect(callAt(0).url).toContain("state=OPEN");
186 }),
187 );
188
189 it.effect("escapes a quote and a backslash, so a search cannot reshape the filter", () =>
190 Effect.gen(function* () {
191 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));
192 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
193
194 yield* api.listPullRequests({
195 repository: "acme/web",
196 state: "open",
197 limit: 50,
198 query: String.raw`a\" OR state = "MERGED"`,
199 });
200
201 const literal = String.raw`a\\\" OR state = \"MERGED\"`;
202 expect(filterOfCall(0)).toBe(`(title ~ "${literal}" OR description ~ "${literal}")`);
203 }),
204 );
205
206 it.effect("asks for no filter at all when the reader typed only spaces", () =>
207 Effect.gen(function* () {
208 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));
209 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
210
211 yield* api.listPullRequests({
212 repository: "acme/web",
213 state: "open",
214 limit: 50,
215 query: " ",
216 });
217
218 assert.isNull(filterOfCall(0));
219 }),
220 );
221
222 it.effect("carries on from the instant the last slice ended on", () =>
223 Effect.gen(function* () {
224 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));
225 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
226
227 yield* api.listPullRequests({
228 repository: "acme/web",
229 state: "open",
230 limit: 50,
231 cursor: { updatedBefore: "2026-07-02T00:00:00.123456+00:00", delivered: 50 },
232 });
233
234 // Inclusive, so the rows already sent at that instant come back for the caller to drop.
235 expect(filterOfCall(0)).toBe("updated_on <= 2026-07-02T00:00:00.123456+00:00");
236 expect(callAt(0).url).toContain("sort=-updated_on");
237 }),
238 );
239
240 it.effect("narrows by the reader's words and by where it left off at once", () =>
241 Effect.gen(function* () {
242 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));
243 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
244
245 yield* api.listPullRequests({
246 repository: "acme/web",
247 state: "open",
248 limit: 50,
249 query: "page",
250 cursor: { updatedBefore: "2026-07-02T00:00:00+00:00", delivered: 50 },
251 });
252
253 // Bitbucket takes one `q`, so the two narrowings are joined rather than one replacing the
254 // other — and the search keeps its brackets, which is what keeps the AND out of its OR.
255 expect(filterOfCall(0)).toBe(
256 '(title ~ "page" OR description ~ "page") AND updated_on <= 2026-07-02T00:00:00+00:00',
257 );
258 }),
259 );
260
261 it.effect("asks for declined pull requests on the closed tab", () =>
262 Effect.gen(function* () {
263 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));
264 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
265
266 yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 });
267
268 expect(callAt(0).url).toContain("state=DECLINED");
269 }),
270 );
271
272 it.effect("asks for every state at once on the All tab", () =>
273 Effect.gen(function* () {
274 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));
275 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
276
277 yield* api.listPullRequests({ repository: "acme/web", state: "all", limit: 50 });
278
279 // Bitbucket unions repeated state parameters, which is the only way to span them.
280 const url = callAt(0).url;
281 for (const state of ["OPEN", "MERGED", "DECLINED", "SUPERSEDED"]) {
282 expect(url).toContain(`state=${state}`);
283 }
284 }),
285 );
286
287 it.effect("counts a superseded pull request as closed", () =>
288 Effect.gen(function* () {
289 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));
290 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
291
292 yield* api.listPullRequests({ repository: "acme/web", state: "closed", limit: 50 });
293
294 expect(callAt(0).url).toContain("state=DECLINED");
295 expect(callAt(0).url).toContain("state=SUPERSEDED");
296 }),
297 );
298
299 it.effect("refuses a repository that is not workspace and slug", () =>
300 Effect.gen(function* () {
301 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
302
303 const error = yield* Effect.flip(
304 api.listPullRequests({ repository: "acme/team/web", state: "open", limit: 50 }),
305 );
306
307 assert.strictEqual(error._tag, "BitbucketRepositoryUnsupportedError");
308 assert.strictEqual(mockedRequest.mock.calls.length, 0);
309 }),
310 );
311
312 it.effect("returns the diff verbatim, because Bitbucket already sends a patch", () =>
313 Effect.gen(function* () {
314 const patch = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n";
315 mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch)));
316 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
317
318 const diff = yield* api.getPullRequestDiff({ repository: "acme/web", number: 7 });
319
320 assert.strictEqual(diff.patch, patch);
321 assert.isFalse(diff.truncated);
322 expect(callAt(0)).toMatchObject({
323 url: "/repositories/acme/web/pullrequests/7/diff",
324 // A diff of any size would otherwise be read into memory whole.
325 maxBytes: 8 * 1024 * 1024,
326 });
327 }),
328 );
329
330 it.effect("reads a named commit's own patch, which pages no further than the whole of it", () =>
331 Effect.gen(function* () {
332 const patch = "diff --git a/a.ts b/a.ts\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n";
333 mockedRequest.mockReturnValueOnce(Effect.succeed(response(patch)));
334 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
335
336 const diff = yield* api.getPullRequestDiff({
337 repository: "acme/web",
338 number: 7,
339 commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
340 });
341
342 assert.strictEqual(diff.patch, patch);
343 expect(callAt(0)).toMatchObject({
344 url: "/repositories/acme/web/diff/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
345 maxBytes: 8 * 1024 * 1024,
346 });
347 }),
348 );
349
350 it.effect("refuses a commit that is not a sha rather than reading it into a URL", () =>
351 Effect.gen(function* () {
352 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
353
354 const error = yield* Effect.flip(
355 api.getPullRequestDiff({
356 repository: "acme/web",
357 number: 7,
358 commit: "../../acme/other/diff/deadbeef",
359 }),
360 );
361
362 assert.strictEqual(error._tag, "BitbucketDiffCommitError");
363 assert.strictEqual(mockedRequest.mock.calls.length, 0);
364 }),
365 );
366
367 it.effect("reads every file version the patch states, not only the paths asked about", () =>
368 Effect.gen(function* () {
369 mockedRequest.mockReturnValueOnce(
370 Effect.succeed(
371 response(
372 [
373 "diff --git a/a.ts b/a.ts",
374 "index 1111111..2222222 100644",
375 "--- a/a.ts",
376 "+++ b/a.ts",
377 "@@ -1 +1 @@",
378 "-a",
379 "+b",
380 "diff --git a/b.ts b/b.ts",
381 "index 3333333..4444444 100644",
382 "--- a/b.ts",
383 "+++ b/b.ts",
384 "@@ -1 +1 @@",
385 "-c",
386 "+d",
387 "",
388 ].join("\n"),
389 ),
390 ),
391 );
392 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
393
394 const revisions = yield* api.getFileRevisions({
395 repository: "acme/web",
396 number: 71,
397 paths: ["a.ts", "missing.ts"],
398 });
399
400 // `b.ts` was not asked about and is reported anyway: parsing the patch for `a.ts` read it
401 // too, and the caller holding it is what stops the next tick paying for the patch again.
402 // `missing.ts` was asked about and the whole patch was read without finding it, which is
403 // what a file this pull request deletes looks like, so it is answered as the empty version.
404 assert.deepStrictEqual(
405 [...revisions.revisions],
406 [
407 ["a.ts", "2222222"],
408 ["b.ts", "4444444"],
409 ["missing.ts", ""],
410 ],
411 );
412 assert.strictEqual(revisions.complete, true);
413 expect(callAt(0)).toMatchObject({ url: "/repositories/acme/web/pullrequests/71/diff" });
414 }),
415 );
416
417 it.effect("says nothing about the files past the end of a patch it could not read whole", () =>
418 Effect.gen(function* () {
419 // Bitbucket's patch is read up to a byte ceiling, and a file past the cut was not looked at.
420 // Answering for it as deleted would clear a mark on it once and for good.
421 mockedRequest.mockReturnValueOnce(
422 Effect.succeed({
423 body: "diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n",
424 truncated: true,
425 }),
426 );
427 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
428
429 const revisions = yield* api.getFileRevisions({
430 repository: "acme/web",
431 number: 72,
432 paths: ["a.ts", "past-the-cut.ts"],
433 });
434
435 assert.deepStrictEqual([...revisions.revisions], [["a.ts", "2222222"]]);
436 // `past-the-cut.ts` gets no empty version, and nothing here may be held as the whole story.
437 assert.strictEqual(revisions.complete, false);
438 }),
439 );
440
441 it.effect("reads the patch once for a run of ticks, not once a tick", () =>
442 Effect.gen(function* () {
443 mockedRequest.mockReturnValue(
444 Effect.succeed(
445 response(
446 [
447 "diff --git a/a.ts b/a.ts",
448 "index 1111111..2222222 100644",
449 "@@ -1 +1 @@",
450 "diff --git a/b.ts b/b.ts",
451 "index 3333333..4444444 100644",
452 "@@ -1 +1 @@",
453 "",
454 ].join("\n"),
455 ),
456 ),
457 );
458 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
459
460 const first = yield* api.getFileRevisions({
461 repository: "acme/web",
462 number: 74,
463 paths: ["a.ts"],
464 });
465 // A path nobody has asked about before, which is what every tick after the first names.
466 const second = yield* api.getFileRevisions({
467 repository: "acme/web",
468 number: 74,
469 paths: ["b.ts"],
470 });
471
472 const both = [
473 ["a.ts", "2222222"],
474 ["b.ts", "4444444"],
475 ];
476 assert.deepStrictEqual([...first.revisions], both);
477 assert.deepStrictEqual([...second.revisions], both);
478 assert.strictEqual(mockedRequest.mock.calls.length, 1);
479 }),
480 );
481
482 it.effect("reads the patch afresh once the one it held has aged out", () =>
483 Effect.gen(function* () {
484 mockedRequest.mockReturnValue(
485 Effect.succeed(
486 response("diff --git a/a.ts b/a.ts\nindex 1111111..2222222 100644\n@@ -1 +1 @@\n"),
487 ),
488 );
489 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
490 const read = () =>
491 api.getFileRevisions({ repository: "acme/web", number: 75, paths: ["a.ts"] });
492
493 yield* read();
494 yield* read();
495 assert.strictEqual(mockedRequest.mock.calls.length, 1);
496
497 // Well inside the window the caller holds versions for: a refresh drops what it holds so
498 // that the read after it reaches Bitbucket, and this must not answer that read instead.
499 yield* TestClock.adjust(Duration.seconds(30));
500 yield* read();
501 assert.strictEqual(mockedRequest.mock.calls.length, 2);
502 }),
503 );
504
505 it.effect("asks Bitbucket nothing when no file has been ticked off", () =>
506 Effect.gen(function* () {
507 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
508
509 const revisions = yield* api.getFileRevisions({
510 repository: "acme/web",
511 number: 73,
512 paths: [],
513 });
514
515 assert.strictEqual(revisions.revisions.size, 0);
516 assert.strictEqual(mockedRequest.mock.calls.length, 0);
517 }),
518 );
519
520 it.effect("aggregates every diffstat page", () =>
521 Effect.gen(function* () {
522 const next = "https://api.bitbucket.org/2.0/diffstat?page=2";
523 mockedRequest
524 .mockReturnValueOnce(
525 Effect.succeed(
526 response(
527 valuePage(
528 [
529 { lines_added: 9, lines_removed: 2 },
530 { lines_added: 3, lines_removed: 1 },
531 ],
532 next,
533 ),
534 ),
535 ),
536 )
537 .mockReturnValueOnce(
538 Effect.succeed(response(valuePage([{ lines_added: 4, lines_removed: 7 }]))),
539 );
540 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
541
542 const stat = yield* api.getDiffStat({ repository: "acme/web", number: 7 });
543
544 expect(stat).toEqual({ additions: 16, deletions: 10, changedFiles: 3 });
545 expect(callAt(1).url).toBe(next);
546 }),
547 );
548
549 it.effect("returns the complete commit timeline oldest first across pages", () =>
550 Effect.gen(function* () {
551 const next = "https://api.bitbucket.org/2.0/commits?page=2";
552 mockedRequest
553 .mockReturnValueOnce(
554 Effect.succeed(
555 response(
556 valuePage(
557 [
558 { hash: "ddd", message: "fourth", date: "2026-07-04T00:00:00Z" },
559 { hash: "ccc", message: "third", date: "2026-07-03T00:00:00Z" },
560 ],
561 next,
562 ),
563 ),
564 ),
565 )
566 .mockReturnValueOnce(
567 Effect.succeed(
568 response(
569 valuePage([
570 { hash: "bbb", message: "second", date: "2026-07-02T00:00:00Z" },
571 { hash: "aaa", message: "first", date: "2026-07-01T00:00:00Z" },
572 ]),
573 ),
574 ),
575 );
576 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
577
578 const commits = yield* api.listCommits({ repository: "acme/web", number: 7 });
579
580 expect(commits.map((commit) => commit.oid)).toEqual(["aaa", "bbb", "ccc", "ddd"]);
581 expect(callAt(1).url).toBe(next);
582 }),
583 );
584
585 it.effect("returns build statuses from every page", () =>
586 Effect.gen(function* () {
587 const next = "https://api.bitbucket.org/2.0/statuses?page=2";
588 mockedRequest
589 .mockReturnValueOnce(
590 Effect.succeed(response(valuePage([{ name: "Build", state: "SUCCESSFUL" }], next))),
591 )
592 .mockReturnValueOnce(
593 Effect.succeed(response(valuePage([{ name: "Lint", state: "FAILED" }]))),
594 );
595 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
596
597 const checks = yield* api.listChecks({ repository: "acme/web", number: 7 });
598
599 expect(checks.map((check) => [check.name, check.status])).toEqual([
600 ["Build", "success"],
601 ["Lint", "failure"],
602 ]);
603 expect(callAt(1).url).toBe(next);
604 }),
605 );
606
607 it.effect("reads an empty conflict list as mergeable", () =>
608 Effect.gen(function* () {
609 mockedRequest.mockReturnValueOnce(Effect.succeed(response(page(0, 1))));
610 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
611
612 const mergeability = yield* api.getMergeability({ repository: "acme/web", number: 7 });
613
614 assert.strictEqual(mergeability, "mergeable");
615 expect(callAt(0).url).toBe("/repositories/acme/web/pullrequests/7/conflicts");
616 }),
617 );
618
619 it.effect("merges with Bitbucket's own name for the strategy", () =>
620 Effect.gen(function* () {
621 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
622 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
623
624 yield* api.runAction({
625 repository: "acme/web",
626 number: 7,
627 action: "merge",
628 mergeMethod: "rebase",
629 });
630
631 expect(callAt(0)).toMatchObject({
632 method: "POST",
633 url: "/repositories/acme/web/pullrequests/7/merge",
634 body: '{"merge_strategy":"rebase_fast_forward"}',
635 });
636 }),
637 );
638
639 it.effect("closes a pull request by declining it", () =>
640 Effect.gen(function* () {
641 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
642 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
643
644 yield* api.runAction({ repository: "acme/web", number: 7, action: "close" });
645
646 expect(callAt(0)).toMatchObject({
647 method: "POST",
648 url: "/repositories/acme/web/pullrequests/7/decline",
649 });
650 }),
651 );
652
653 it.effect("posts a comment as a JSON document, so the body stays text", () =>
654 Effect.gen(function* () {
655 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
656 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
657
658 yield* api.comment({ repository: "acme/web", number: 7, body: "true" });
659
660 expect(callAt(0)).toMatchObject({
661 method: "POST",
662 url: "/repositories/acme/web/pullrequests/7/comments",
663 body: '{"content":{"raw":"true"}}',
664 });
665 }),
666 );
667
668 it.effect("rewrites a title alone, without touching anything else", () =>
669 Effect.gen(function* () {
670 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
671 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
672
673 yield* api.updateChangeRequest({ repository: "acme/web", number: 7, title: "A new title" });
674
675 const call = callAt(0);
676 expect(call.method).toBe("PUT");
677 expect(call.url).toBe("/repositories/acme/web/pullrequests/7");
678 // Bitbucket's PUT is a partial update, so a field left out of the body is left as it was.
679 expect(JSON.parse(call.body ?? "")).toEqual({ title: "A new title" });
680 }),
681 );
682
683 it.effect("leaves out the half of the pull request it was not asked about", () =>
684 Effect.gen(function* () {
685 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
686 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
687
688 yield* api.updateChangeRequest({ repository: "acme/web", number: 7, body: "New body." });
689
690 expect(JSON.parse(callAt(0).body ?? "")).toEqual({ description: "New body." });
691 }),
692 );
693
694 it.effect("writes both fields when both were rewritten", () =>
695 Effect.gen(function* () {
696 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
697 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
698
699 yield* api.updateChangeRequest({
700 repository: "acme/web",
701 number: 7,
702 title: "A new title",
703 body: "New body.",
704 });
705
706 expect(JSON.parse(callAt(0).body ?? "")).toEqual({
707 title: "A new title",
708 description: "New body.",
709 });
710 }),
711 );
712
713 it.effect("rewrites a comment where it stands, whichever kind it is", () =>
714 Effect.gen(function* () {
715 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
716 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
717
718 yield* api.updateComment({
719 repository: "acme/web",
720 number: 7,
721 commentId: "10",
722 body: "Edited.",
723 });
724
725 expect(callAt(0)).toMatchObject({
726 method: "PUT",
727 url: "/repositories/acme/web/pullrequests/7/comments/10",
728 body: '{"content":{"raw":"Edited."}}',
729 });
730 }),
731 );
732
733 it.effect("fails the read when Bitbucket answers with something unreadable", () =>
734 Effect.gen(function* () {
735 mockedRequest.mockReturnValueOnce(
736 Effect.succeed(response(JSON.stringify({ error: "nope" }))),
737 );
738 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
739
740 const error = yield* Effect.flip(api.getPullRequest({ repository: "acme/web", number: 7 }));
741
742 assert.strictEqual(error._tag, "BitbucketPullRequestReadError");
743 }),
744 );
745
746 it.effect("states a failure once, without stacking one message inside another", () =>
747 Effect.gen(function* () {
748 mockedRequest.mockReturnValueOnce(
749 Effect.fail(
750 new BitbucketApi.BitbucketResponseError({
751 operation: "request",
752 status: 500,
753 responseBodyLength: 0,
754 }),
755 ),
756 );
757 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
758
759 const error = yield* Effect.flip(api.getViewer());
760
761 // The fact only; the provider adds the operation around it.
762 assert.strictEqual(error.detail, "Bitbucket returned HTTP 500.");
763 }),
764 );
765
766 it.effect("fails when the credentials belong to no named account", () =>
767 Effect.gen(function* () {
768 mockedRequest.mockReturnValueOnce(Effect.succeed(response(JSON.stringify({}))));
769 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
770
771 const error = yield* Effect.flip(api.getViewer());
772
773 assert.strictEqual(error._tag, "BitbucketViewerUnavailableError");
774 }),
775 );
776
777 it.effect("follows Bitbucket's cursor and reassembles a thread that spans two pages", () =>
778 Effect.gen(function* () {
779 mockedRequest.mockReturnValueOnce(
780 Effect.succeed(
781 response(
782 JSON.stringify({
783 next: "https://api.bitbucket.org/2.0/comments?page=2",
784 values: [
785 {
786 id: 10,
787 content: { raw: "rename this" },
788 user: { nickname: "bilal" },
789 created_on: "2026-06-16T05:04:32+00:00",
790 inline: { path: "src/a.ts", to: 12 },
791 },
792 ],
793 }),
794 ),
795 ),
796 );
797 mockedRequest.mockReturnValueOnce(
798 Effect.succeed(
799 response(
800 // The reply arrives a page after the remark it answers, which is why the threads
801 // are only assembled once every page is in hand.
802 JSON.stringify({
803 values: [
804 {
805 id: 11,
806 content: { raw: "done" },
807 user: { nickname: "julius" },
808 created_on: "2026-06-16T06:04:32+00:00",
809 parent: { id: 10 },
810 },
811 ],
812 }),
813 ),
814 ),
815 );
816 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
817
818 const { comments, threads, truncated } = yield* api.listComments({
819 repository: "acme/web",
820 number: 7,
821 });
822
823 expect(callAt(1).url).toBe("https://api.bitbucket.org/2.0/comments?page=2");
824 expect(comments.map((comment) => comment.id)).toEqual(["10", "11"]);
825 expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11"]);
826 assert.isFalse(truncated);
827 }),
828 );
829
830 it.effect("stops the comment walk at its bound and says the conversation was cut short", () =>
831 Effect.gen(function* () {
832 // Bitbucket that always names a next page: the walk has to end itself.
833 mockedRequest.mockReturnValue(
834 Effect.succeed(
835 response(
836 JSON.stringify({
837 next: "https://api.bitbucket.org/2.0/comments?page=2",
838 values: [
839 {
840 id: 10,
841 content: { raw: "again" },
842 created_on: "2026-06-16T05:04:32+00:00",
843 },
844 ],
845 }),
846 ),
847 ),
848 );
849 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
850
851 const { truncated } = yield* api.listComments({ repository: "acme/web", number: 7 });
852
853 assert.strictEqual(mockedRequest.mock.calls.length, 10);
854 assert.isTrue(truncated);
855 }),
856 );
857
858 it.effect("reassembles a thread from the flat comment list, replies included", () =>
859 Effect.gen(function* () {
860 mockedRequest.mockReturnValueOnce(
861 Effect.succeed(
862 response(
863 JSON.stringify({
864 values: [
865 {
866 id: 10,
867 content: { raw: "rename this" },
868 user: { nickname: "bilal" },
869 created_on: "2026-06-16T05:04:32+00:00",
870 inline: { path: "src/a.ts", to: 12, from: null },
871 resolution: { type: "pullrequest_comment_resolution" },
872 },
873 {
874 id: 11,
875 content: { raw: "done" },
876 user: { nickname: "julius" },
877 created_on: "2026-06-16T06:04:32+00:00",
878 parent: { id: 10 },
879 },
880 // A reply to a reply still belongs to the thread its root opened.
881 {
882 id: 12,
883 content: { raw: "thanks" },
884 user: { nickname: "bilal" },
885 created_on: "2026-06-16T07:04:32+00:00",
886 parent: { id: 11 },
887 },
888 {
889 id: 13,
890 content: { raw: "ship it" },
891 user: { nickname: "bilal" },
892 created_on: "2026-06-16T08:04:32+00:00",
893 },
894 ],
895 }),
896 ),
897 ),
898 );
899 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
900
901 const { threads } = yield* api.listComments({ repository: "acme/web", number: 7 });
902
903 assert.strictEqual(threads.length, 1);
904 expect(threads[0]).toMatchObject({
905 id: "10",
906 path: "src/a.ts",
907 line: 12,
908 side: "right",
909 isResolved: true,
910 });
911 expect(threads[0]?.comments.map((comment) => comment.id)).toEqual(["10", "11", "12"]);
912 }),
913 );
914
915 it.effect("writes a review's line comments, its summary, then its verdict", () =>
916 Effect.gen(function* () {
917 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
918 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
919
920 yield* api.submitReview({
921 repository: "acme/web",
922 number: 7,
923 verdict: "request-changes",
924 body: "Two things.",
925 comments: [
926 {
927 path: "src/a.ts",
928 position: { kind: "deleted", oldLine: 12 },
929 body: "why remove?",
930 },
931 ],
932 });
933
934 expect(callAt(0).url).toContain("/pullrequests/7/comments");
935 expect(JSON.parse(callAt(0).body ?? "")).toEqual({
936 content: { raw: "why remove?" },
937 inline: { path: "src/a.ts", from: 12 },
938 });
939 expect(callAt(1).url).toContain("/pullrequests/7/comments");
940 // The verdict goes last, so a review that failed part-way is never a rejection either.
941 expect(callAt(2).url).toContain("/pullrequests/7/request-changes");
942 }),
943 );
944
945 it.effect("resolves by creating the sub-resource and unresolves by deleting it", () =>
946 Effect.gen(function* () {
947 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
948 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
949
950 yield* api.setCommentResolution({
951 repository: "acme/web",
952 number: 7,
953 commentId: "10",
954 resolved: true,
955 });
956 yield* api.setCommentResolution({
957 repository: "acme/web",
958 number: 7,
959 commentId: "10",
960 resolved: false,
961 });
962
963 assert.strictEqual(callAt(0).method, "POST");
964 assert.strictEqual(callAt(1).method, "DELETE");
965 expect(callAt(0).url).toContain("/comments/10/resolve");
966 }),
967 );
968
969 it.effect("replies by naming the comment it answers", () =>
970 Effect.gen(function* () {
971 mockedRequest.mockReturnValue(Effect.succeed(response("{}")));
972 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
973
974 yield* api.replyToComment({
975 repository: "acme/web",
976 number: 7,
977 commentId: "10",
978 body: "Fixed.",
979 });
980
981 expect(JSON.parse(callAt(0).body ?? "")).toEqual({
982 content: { raw: "Fixed." },
983 parent: { id: 10 },
984 });
985 }),
986 );
987
988 it.effect("asks for the credentials' permission on this repository, and nobody else's", () =>
989 Effect.gen(function* () {
990 mockedRequest.mockReturnValue(
991 Effect.succeed(
992 response(
993 JSON.stringify({ values: [{ type: "repository_permission", permission: "read" }] }),
994 ),
995 ),
996 );
997 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
998
999 assert.isFalse(yield* api.getRepositoryPermission({ repository: "acme/web" }));
1000
1001 expect(new URL(callAt(0).url, "https://api.bitbucket.org").pathname).toBe(
1002 "/user/workspaces/acme/permissions/repositories",
1003 );
1004 assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/web"');
1005 }),
1006 );
1007
1008 it.effect("matches repository permissions after normalizing whitespace around segments", () =>
1009 Effect.gen(function* () {
1010 mockedRequest.mockImplementation((input) => {
1011 const url = new URL(input.url, "https://api.bitbucket.org");
1012 const matches =
1013 url.pathname === "/user/workspaces/acme/permissions/repositories" &&
1014 url.searchParams.get("q") === 'repository.full_name="acme/web"';
1015 return Effect.succeed(response(valuePage(matches ? [{ permission: "read" }] : [])));
1016 });
1017 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1018
1019 // An unmatched, empty permission page is treated as unknown and would allow this attempt.
1020 assert.isFalse(yield* api.getRepositoryPermission({ repository: " acme / web " }));
1021 }),
1022 );
1023
1024 it.effect("escapes a repository name before it goes inside a filter literal", () =>
1025 Effect.gen(function* () {
1026 mockedRequest.mockReturnValue(Effect.succeed(response(JSON.stringify({ values: [] }))));
1027 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1028
1029 yield* api.getRepositoryPermission({ repository: 'acme/we"b' });
1030
1031 // A quote would otherwise end the literal and leave the rest standing as filter syntax.
1032 assert.strictEqual(filterOfCall(0), 'repository.full_name="acme/we\\"b"');
1033 }),
1034 );
1035
1036 const permissionFailure = (status: number) =>
1037 Effect.fail(
1038 new BitbucketApi.BitbucketResponseError({
1039 operation: "request",
1040 status,
1041 responseBodyLength: 0,
1042 }),
1043 );
1044
1045 it.effect.each([403, 404, 410])(
1046 "reads HTTP %s from the permissions endpoint as an unknown permission",
1047 (status) =>
1048 Effect.gen(function* () {
1049 mockedRequest.mockReturnValue(permissionFailure(status));
1050 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1051
1052 assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" }));
1053 }),
1054 );
1055
1056 it.effect.each([401, 500])("still fails the permission read on HTTP %s", (status) =>
1057 Effect.gen(function* () {
1058 mockedRequest.mockReturnValue(permissionFailure(status));
1059 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1060
1061 const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" }));
1062 assert.strictEqual(error._tag, "BitbucketResponseError");
1063 if (error._tag === "BitbucketResponseError") {
1064 assert.strictEqual(error.status, status);
1065 }
1066 }),
1067 );
1068
1069 it.effect("reads the workspace's people and marks whoever is already a reviewer", () =>
1070 Effect.gen(function* () {
1071 mockedRequest
1072 .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] }))))
1073 .mockReturnValueOnce(
1074 Effect.succeed(
1075 response(
1076 JSON.stringify({ values: [{ user: bilal }, { user: octocat }, { user: hubot }] }),
1077 ),
1078 ),
1079 );
1080 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1081
1082 const list = yield* api.listReviewerCandidates({ repository: "acme/web", number: 7 });
1083
1084 // The people live on the workspace: nothing on a repository lists who may review it.
1085 expect(callAt(1).url).toBe("/workspaces/acme/members?pagelen=50");
1086 expect(list.candidates.map((candidate) => [candidate.id, candidate.isRequested])).toEqual([
1087 ["{octocat}", true],
1088 ["{hubot}", false],
1089 ]);
1090 assert.isFalse(list.truncated);
1091 }),
1092 );
1093
1094 it.effect("writes the reviewer set back with the one being asked added to it", () =>
1095 Effect.gen(function* () {
1096 mockedRequest
1097 .mockReturnValueOnce(Effect.succeed(response(pullRequestJson({ reviewers: [octocat] }))))
1098 .mockReturnValueOnce(Effect.succeed(response("{}")));
1099 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1100
1101 yield* api.setReviewerRequest({
1102 repository: "acme/web",
1103 number: 7,
1104 reviewers: [{ id: "{hubot}" }],
1105 requested: true,
1106 });
1107
1108 // Bitbucket writes `reviewers` whole, so the one already on the pull request travels with
1109 // the new one or the request would take them off it.
1110 const call = callAt(1);
1111 expect(call.method).toBe("PUT");
1112 expect(call.url).toBe("/repositories/acme/web/pullrequests/7");
1113 expect(JSON.parse(call.body ?? "")).toEqual({
1114 reviewers: [{ uuid: "{octocat}" }, { uuid: "{hubot}" }],
1115 });
1116 }),
1117 );
1118
1119 it.effect("takes a reviewer out of the set rather than clearing it", () =>
1120 Effect.gen(function* () {
1121 mockedRequest
1122 .mockReturnValueOnce(
1123 Effect.succeed(response(pullRequestJson({ reviewers: [octocat, hubot] }))),
1124 )
1125 .mockReturnValueOnce(Effect.succeed(response("{}")));
1126 const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi;
1127
1128 yield* api.setReviewerRequest({
1129 repository: "acme/web",
1130 number: 7,
1131 reviewers: [{ id: "{hubot}" }],
1132 requested: false,
1133 });
1134
1135 expect(JSON.parse(callAt(1).body ?? "")).toEqual({ reviewers: [{ uuid: "{octocat}" }] });
1136 }),
1137 );
1138});